diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index 700896ebe..950ce355e 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -11,7 +11,7 @@ description: Atomic editor document reads, commits, and change events document. -`dmn.editor` reads and updates the six collections that make up the editor +`dmn.editor` reads and updates the seven collections that make up the editor layout as one revisioned document. Use it when one user action changes more than one collection, or when a consumer needs a single ordered change stream. @@ -24,6 +24,7 @@ type EditorField = | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'spritePositions' | 'layerGroups'; interface EditorDocumentV1 { @@ -33,6 +34,7 @@ interface EditorDocumentV1 { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; layerGroups: LayerGroups; } @@ -47,22 +49,127 @@ that top-level collection. It is not an item-level diff. Since the multi-key update, `keys` entries in `EditorDocumentV1` (returned by `get()`) and in `EditorPatchV1` (delivered by `onCommitted()`) use the - `KeySlot` union (`string | MultiKeySlot`), not plain strings. See the - [Keys API](/docs/api-reference/keys) for the slot shape and canonical - identifier rules. + `KeySlot` union (`string | MultiKeySlot`), not plain strings. See the [Keys + API](/docs/api-reference/keys) for the slot shape and canonical identifier + rules. Every element in the position collections (`keyPositions`, `statPositions`, - `graphPositions`, `knobPositions`) carries a stable `id` (UUID) assigned and - owned by the app. Treat it as opaque: echo back the value you read and never - invent one. A written element with a missing or unknown `id` gets a fresh one - from the backend. If the same `id` appears more than once, for example when - you copy an element you read and append it, the element in its original slot - keeps the identity and the remaining copies get fresh ones. See the - [Keys API](/docs/api-reference/keys) for the identity rules. + `graphPositions`, `knobPositions`, `spritePositions`) carries a stable `id` + (UUID) assigned and owned by the app. Treat it as opaque: echo back the value + you read and never invent one. A written element whose `id` is missing or + unknown inherits the `id` of an existing element whose remaining fields match; + with no match, the backend assigns a fresh one. Omitting an `id` does not + guarantee a fresh identity, and editing an element's values while omitting its + `id` may give it one. If the same `id` appears more than once, for example + when you copy an element you read and append it, the element in its original + slot keeps the identity and the remaining copies get fresh ones. The `poseId` + carried by each pose of a sprite element follows the same rules. See the [Keys + API](/docs/api-reference/keys) for the identity rules. +## Sprite Position Types + +Reactive sprites are image layers that switch poses while their trigger keys +are held. Unlike key elements they are not index-coupled to key mappings; each +pose references the responsible key element ids directly. Documents you read +always carry `id` and `poseId`; echo both back when you write. +The collection and value limits noted below are enforced when you commit; +documents you read echo stored data as is. + +```typescript +// Keyed by tab/mode id (e.g., "4key") +type SpritePositions = Record; + +interface ReactiveSpritePosition { + id?: string; // stable element identity (UUID) + dx: number; // element box origin and size. The box is the base image box and feeds overlay window sizing + dy: number; + width: number; + height: number; + rotation?: number; // placement rotation around the element-box center, -180–180 deg, default 0 + hidden: boolean; + zIndex: number | null; + layerName?: string | null; // omitted by the backend when unset + groupId?: string | null; // omitted by the backend when unset + className: string | null; + useInlineStyles: boolean | null; + baseImage: string | null; // image shown when no pose matches; always fills the element box + referenceNaturalSize: { + source: string | null; + width: number; + height: number; + } | null; // intrinsic pixel size that defines the pixel scale (integers, 1–32768). source must equal baseImage when one is set and be null otherwise; null falls back to drawing every image in the element box + pivot: { x: number; y: number }; // normalized shared axis, 0–1 each, default {0.5, 0.5}. Base-image pivot and transform origin for every pose + idleTransform: SpriteTransform; // base transform when no pose matches + poses: SpritePose[]; // commits accept at most 64 + activation: 'whileHeld' | 'onPress'; // default whileHeld. onPress plays the pose once on key-down, then returns to idle + pressDurationMs: number; // onPress playback duration, 1–5000, default 300 + transitionMs: number; // pose transition duration, 0–1000, default 0. Ignored by onPress, which plays for pressDurationMs + transitionEasing: string; // CSS easing, default 'cubic-bezier(0.4, 0, 0.2, 1)' +} + +interface SpritePose { + poseId?: string; // stable pose identity (UUID) + name?: string | null; // omitted by the backend when unset + triggers: string[]; // key element ids; commits accept at most 512, sorted and deduplicated + transform: SpriteTransform; + pivot?: { x: number; y: number } | null; // independent pivot within the pose image; null or omission follows the sprite's shared pivot + imageOverride: string | null; // replaces baseImage while this pose is active + imageOverrideMetrics: { + source: string; + width: number; + height: number; + } | null; // intrinsic pixel size of imageOverride (integers, 1–32768). Optional; when set, source must equal the path exactly. Normalized to null when imageOverride is absent +} + +interface SpriteTransform { + x: number; // -2000–2000 px + y: number; // -2000–2000 px + rotation: number; // -180–180 deg + scale: number; // 0.1–10 +} +``` + +The element-level `rotation` rotates the entire sprite, including all poses, +around the center of its logical box. It is independent of +`idleTransform.rotation` and each pose's `transform.rotation`, which remain +local rotations around the shared pivot. Rotating a selection changes the +sprite's `dx`, `dy`, and element-level `rotation` together; pose transforms, +triggers, image references, and transition directions remain unchanged. +New sprites default to 0 when `rotation` is omitted. For an existing sprite, +schemaVersion 1 and 2 commits preserve its stored rotation when the field is +omitted. `null`, non-finite values, and values outside `-180..180` are rejected. + +A pose activates while the set of held keys, among the keys the sprite +references, exactly matches its trigger set; keys the sprite does not reference +are ignored. When no pose matches exactly, the single-trigger poses of the held +keys are blended with equal weight: x, y and scale are averaged, rotation uses a +circular mean, and the image comes from the first override in poseId order. +Poses with two or more triggers never take part in blending, so a sprite whose +held keys only appear in such poses returns to idle. Two poses of one sprite may +not share the same trigger set, and commits reject empty trigger lists. + +The element box is the base-image box. The base image always fills it, and the +shared axis P is the sprite pivot × (width, height). A pose whose pivot is null +or omitted uses the same normalized point. A pose with a pivot value aligns that +independent point within its image to P. A pose image with a different intrinsic +size is drawn at intrinsic pixels × the reference scale, where the reference +scale is (width, height) ÷ referenceNaturalSize per axis. Without a reference +size or the pose image's own metrics, the image uses the element-box size. +Moving the shared pivot keeps the base image in place while linked poses follow +the new axis. Poses with an independent pivot keep their screen position by +compensating x/y in the same commit. Moving a pose pivot or switching the link +also compensates x/y to avoid a sudden image jump. An out-of-range pivot +rejects the commit with `VALIDATION_FAILED` and a `details.validationCode` of +`INVALID_SPRITE_PIVOT` or `INVALID_SPRITE_POSE_PIVOT`. Invalid image metrics use +`STALE_SPRITE_IMAGE_METRICS` or `INVALID_SPRITE_IMAGE_METRICS`. A schemaVersion 1 +patch that omits `pivot`, `referenceNaturalSize`, `imageOverrideMetrics`, +`activation`, or `pressDurationMs` on an existing element or pose keeps the +stored value. Sending null for a pose pivot links it to the shared pivot. +Changing `baseImage` or `imageOverride` while omitting its metrics clears them. + ## Read the Current Document ### `dmn.editor.get(): Promise` @@ -94,10 +201,10 @@ platform, while no application can guarantee recovery from hardware or firmware that violates those barriers. - The app's Undo/Redo path also restores the six editor collections, custom-tab - metadata, selected mode, counters, preset settings, and per-tab note settings - in one backend store transaction. This internal command is intentionally not - part of the public plugin API. + The app's Undo/Redo path also restores the seven editor collections, + custom-tab metadata, selected mode, counters, preset settings, and per-tab + note settings in one backend store transaction. This internal command is + intentionally not part of the public plugin API. ```typescript @@ -218,19 +325,19 @@ interface EditorCommitError { } ``` -| Code | Meaning | `retryable` | -| ------------------------ | ------------------------------------------------------------------------- | ----------- | -| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` | -| `PLUGIN_REVISION_CONFLICT` | The plugin-scoped revision is stale; read, reconcile, and commit again | `true` | -| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` | -| `TOO_MANY_GESTURE_IDS` | `gestureIds` exceeds 32 entries or the combined unique set exceeds 32 IDs | `false` | -| `INVALID_GESTURE_ID` | A gesture ID is not a UUID within the 64-byte limit | `false` | -| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` | -| `MULTI_KEY_UNSUPPORTED` | A `keys` write did not declare `multiKey` while multi-key slots exist | `false` | -| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` | -| `IO_ERROR` | The document could not be persisted | `true` | -| `HISTORY_IN_PROGRESS` | An undo/redo barrier is in progress; retry after it settles | `true` | -| `HISTORY_EPOCH_CONFLICT` | The observed history epoch is stale; re-read and commit again | `true` | +| Code | Meaning | `retryable` | +| -------------------------- | ------------------------------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` | +| `PLUGIN_REVISION_CONFLICT` | The plugin-scoped revision is stale; read, reconcile, and commit again | `true` | +| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` | +| `TOO_MANY_GESTURE_IDS` | `gestureIds` exceeds 32 entries or the combined unique set exceeds 32 IDs | `false` | +| `INVALID_GESTURE_ID` | A gesture ID is not a UUID within the 64-byte limit | `false` | +| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` | +| `MULTI_KEY_UNSUPPORTED` | A `keys` write did not declare `multiKey` while multi-key slots exist | `false` | +| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` | +| `IO_ERROR` | The document could not be persisted | `true` | +| `HISTORY_IN_PROGRESS` | An undo/redo barrier is in progress; retry after it settles | `true` | +| `HISTORY_EPOCH_CONFLICT` | The observed history epoch is stale; re-read and commit again | `true` | `details.currentRevision`, `details.validationCode`, `details.field`, or `details.currentHistoryEpoch` is included when it applies to the error. diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 9ed70de84..a9e9b8ce7 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -337,9 +337,20 @@ gradient siblings: interface KeyPosition { id?: string; // stable element identity (UUID), assigned and owned by the app // ...position, image, note, and counter fields... + rotation: number; // degrees, -180 to 180. Defaults to 0. Content rotation about the box center imageMode?: 'replace' | 'overlay'; // image placement, defaults to replace (image replaces the key) - idleImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; // identity when absent - activeImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; + idleImageTransform?: { + offsetX: number; + offsetY: number; + rotation: number; + scale: number; + }; // identity when absent + activeImageTransform?: { + offsetX: number; + offsetY: number; + rotation: number; + scale: number; + }; noteOpacityTop?: number; noteOpacityBottom?: number; noteGradient?: GradientSpec | null; @@ -387,10 +398,25 @@ interface ElementShadowSpec { } ``` +`rotation` is the element rotation in degrees. Keys, stats, graphs, and knobs +share the field. The logical box (`dx`/`dy`/`width`/`height`) stays axis +aligned while the face, the note track, and the outside counter rotate +together about the box center. Omitting the field means 0; `null`, non-finite +values, and values outside `-180..180` are rejected. Automatic hitline +correction (`noteAutoYCorrection`) aligns unrotated keys to the content top, +and aligns rotated keys that flow in the same direction (within 0.5°) to the +top edge that is furthest along that direction. A key rotated on its own +starts at its own top edge, and note offsets apply after the correction. +Sprites also have an element-level placement `rotation`, independent of their +idle and per-pose rotations. See [Sprite Position Types](/docs/api-reference/editor#sprite-position-types). + Every element position (`keyPositions`, `statPositions`, `graphPositions`, -`knobPositions`) carries a stable `id`. Treat it as opaque: echo back the value -you read and never invent one. A write whose `id` is missing or unknown gets a -fresh one assigned by the backend. When the same `id` appears more than once, +`knobPositions`, `spritePositions`) carries a stable `id`. Treat it as opaque: echo back the value +you read and never invent one. A write whose `id` is missing or unknown +inherits the `id` of an existing element whose remaining fields match; with no +match, the backend assigns a fresh one. Omitting an `id` does not guarantee a +fresh identity, and editing an element's values while omitting its `id` may +give it one. When the same `id` appears more than once, for example when you copy an element you read and append it, the element in its original slot keeps the identity and the remaining copies get fresh ones. Loading a full preset re-issues every `id`; a tab preset re-issues only the diff --git a/docs/content/en/api-reference/overlay/page.mdx b/docs/content/en/api-reference/overlay/page.mdx index 8b821de72..839cef289 100644 --- a/docs/content/en/api-reference/overlay/page.mdx +++ b/docs/content/en/api-reference/overlay/page.mdx @@ -64,6 +64,7 @@ interface ResizePayload { width: number; height: number; anchor?: string; + contentLeftOffset?: number; contentTopOffset?: number; fixedPositionDeltaX?: number; fixedPositionDeltaY?: number; diff --git a/docs/content/en/api-reference/presets/page.mdx b/docs/content/en/api-reference/presets/page.mdx index c525eb2bd..19b3038af 100644 --- a/docs/content/en/api-reference/presets/page.mdx +++ b/docs/content/en/api-reference/presets/page.mdx @@ -23,9 +23,9 @@ Saves the current configuration to a preset file. Opens a file save dialog. ```javascript const result = await dmn.presets.save(); if (result.success) { - console.log("Preset saved"); + console.log('Preset saved'); } else { - console.error("Save failed:", result.error); + console.error('Save failed:', result.error); } ``` @@ -37,9 +37,9 @@ picker dialog. ```javascript const result = await dmn.presets.load(); if (result.success) { - console.log("Preset loaded"); + console.log('Preset loaded'); } else { - console.error("Load failed:", result.error); + console.error('Load failed:', result.error); } ``` @@ -56,7 +56,10 @@ const result = await dmn.presets.saveTab(); Loads a tab preset file into the currently selected tab. Opens a file picker dialog. Tab presets carry no `tabOrder` or `barCount`, so they never change the -global tab order. +global tab order. Element collections stored in the file (keys, stats, graphs, +knobs, sprites) replace the tab's contents wholesale, so elements missing from +the file's tab are removed. Older presets that lack a collection entirely leave +those elements untouched. ```javascript const result = await dmn.presets.loadTab(); @@ -80,6 +83,7 @@ interface PresetSnapshot { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; customTabs: CustomTab[]; tabOrder: string[]; barCount: number; @@ -88,48 +92,49 @@ interface PresetSnapshot { } const unsub = dmn.presets.onSnapshot((snapshot) => { - console.log("Preset loaded:", snapshot.selectedKeyType); - console.log("Key mappings:", snapshot.keys); + console.log('Preset loaded:', snapshot.selectedKeyType); + console.log('Key mappings:', snapshot.keys); }); ``` - Individual change events (`onChanged`, `onPositionsChanged`, etc.) are not emitted during preset load. - Use `onSnapshot` to detect preset loads. + Individual change events (`onChanged`, `onPositionsChanged`, etc.) are not + emitted during preset load. Use `onSnapshot` to detect preset loads. --- + ## Example Usage ```javascript // @id preset-manager -if (dmn.window.type !== "main") return; +if (dmn.window.type !== 'main') return; // Save preset menu item dmn.ui.contextMenu.addGridMenuItem({ - id: "save-preset", - label: "Save Preset", + id: 'save-preset', + label: 'Save Preset', onClick: async () => { const result = await dmn.presets.save(); if (result.success) { - await dmn.ui.dialog.alert("Preset saved!"); + await dmn.ui.dialog.alert('Preset saved!'); } }, }); // Load preset menu item dmn.ui.contextMenu.addGridMenuItem({ - id: "load-preset", - label: "Load Preset", + id: 'load-preset', + label: 'Load Preset', onClick: async () => { const confirmed = await dmn.ui.dialog.confirm( - "This will overwrite the current configuration. Continue?" + 'This will overwrite the current configuration. Continue?', ); if (confirmed) { const result = await dmn.presets.load(); if (result.success) { - await dmn.ui.dialog.alert("Preset loaded!"); + await dmn.ui.dialog.alert('Preset loaded!'); } } }, diff --git a/docs/content/en/custom-css/variables/page.mdx b/docs/content/en/custom-css/variables/page.mdx index 6da3315cd..798b701a1 100644 --- a/docs/content/en/custom-css/variables/page.mdx +++ b/docs/content/en/custom-css/variables/page.mdx @@ -186,6 +186,32 @@ resets it together with its spacing. } ``` +## Sprite Style Variables + +Reactive sprites render a single image layer that switches poses while its +trigger keys are held. Placement and the rotation origin stay inline because the +window bounds are computed from them; the appearance channels below are the ones +you can replace. Disable **Inline Styles Priority** on the sprite to use them. + +`--sprite-transform` replaces only the image's local pose transform. The +element-level placement `rotation` remains app-managed around the box center +and is applied outside that pose transform. + +| Variable | Description | Type | Example | +| --------------------- | -------------------------------------------------------------------------------------- | -------------- | -------------------------- | +| `--sprite-transform` | Replacement transform for the resolved pose (an onPress sprite then only swaps images) | `` | `rotate(15deg) scale(1.2)` | +| `--sprite-transition` | Replacement transition for the pose change (whileHeld only) | `` | `transform 120ms ease-out` | + +```css +.left-hand [data-sprite-element] > img { + --sprite-transition: transform 90ms cubic-bezier(0.2, 0, 0, 1); +} +``` + +The image element already has the source aspect ratio and fills its box, so +`object-fit` has nothing to change. Overriding the element size itself voids the +pivot alignment of pose images. + ## Selector Reference ### Key Selectors @@ -225,6 +251,16 @@ resets it together with its spacing. | `.knobClass [data-knob-element]` | A specific class's knob surface | | `[data-knob-indicator]` | Center rotation indicator | +### Sprite Selectors + +| Selector | Description | +| ------------------------------------ | --------------------------------------------------------------- | +| `[data-sprite-element]` | Every sprite element box | +| `[data-sprite-element] > img` | The sprite image layer | +| `[data-sprite-state="inactive"]` | Sprites with no trigger key held | +| `[data-sprite-state="active"]` | Sprites with a trigger key held (onPress sprites stay inactive) | +| `.spriteClass [data-sprite-element]` | A specific class's sprite | + ## Standard CSS Properties With **Inline Styles Priority** disabled, you can directly use standard visual diff --git a/docs/content/en/settings/page.mdx b/docs/content/en/settings/page.mdx index cfce5ba4c..d2251f7a2 100644 --- a/docs/content/en/settings/page.mdx +++ b/docs/content/en/settings/page.mdx @@ -66,7 +66,11 @@ const current = pluginSettings.get(); console.log("API Key:", current.apiKey); // Change settings values -await pluginSettings.set({ theme: "light" }); +try { + await pluginSettings.set({ theme: "light" }); +} catch (error) { + console.error("Failed to save settings:", error); +} // Open settings panel (default: property panel) const confirmed = await pluginSettings.open(); @@ -113,6 +117,12 @@ interface PluginSettingsDefinition { | `reset()` | Reset to defaults | | `subscribe(listener)` | Subscribe to settings changes | +`set()` and `reset()` reject their promises if saving fails. The failed edit is +restored from saved settings without overwriting a newer edit started during +recovery. Change callbacks and subscriber notifications run only after a successful +save. A property panel save failure shows an error and resolves `open()` to `false`; +a modal save failure rejects `open()`. + ## onChange vs subscribe() Both detect settings changes but have different purposes: diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index 58be2949b..d2dfcdae7 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -11,7 +11,7 @@ description: 에디터 문서의 원자적 조회, 커밋, 변경 이벤트 API 있습니다. -`dmn.editor`는 에디터 레이아웃을 이루는 여섯 컬렉션을 revision이 있는 문서 +`dmn.editor`는 에디터 레이아웃을 이루는 일곱 컬렉션을 revision이 있는 문서 하나로 조회하고 갱신합니다. 사용자 동작 하나가 여러 컬렉션을 함께 바꾸거나, 소비자가 순서가 보장된 변경 흐름 하나를 받아야 할 때 사용하세요. @@ -24,6 +24,7 @@ type EditorField = | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'spritePositions' | 'layerGroups'; interface EditorDocumentV1 { @@ -33,6 +34,7 @@ interface EditorDocumentV1 { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; layerGroups: LayerGroups; } @@ -53,14 +55,114 @@ type EditorPatchV1 = { 위치 컬렉션(`keyPositions`, `statPositions`, `graphPositions`, - `knobPositions`)의 모든 요소는 앱이 발급·소유하는 안정 `id`(UUID)를 - 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, 직접 - 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 값을 - 발급합니다. 읽은 요소를 복사해 배열에 덧붙이는 경우처럼 같은 `id`가 여러 번 - 나오면, 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 받습니다. + `knobPositions`, `spritePositions`)의 모든 요소는 앱이 발급·소유하는 안정 + `id`(UUID)를 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, + 직접 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 나머지 필드가 같은 기존 + 요소의 신원을 승계합니다. 맞는 항목이 없으면 백엔드가 새 값을 발급합니다. + `id`를 빼는 것이 새 신원을 보장하지는 않고, 값을 바꾸면서 `id`까지 빼면 새 + 신원이 될 수 있습니다. 읽은 요소를 복사해 배열에 덧붙이는 경우처럼 같은 `id`가 + 여러 번 나오면, 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 + 받습니다. 스프라이트 요소의 각 자세가 가진 `poseId`도 같은 규칙을 따릅니다. 신원 규칙은 [Keys API](/docs/api-reference/keys)를 참고하세요. +## 스프라이트 위치 타입 + +반응형 스프라이트는 담당 키를 누르고 있는 동안 자세가 바뀌는 이미지 레이어입니다. +키 요소와 달리 키 매핑에 인덱스로 결합하지 않고, 자세마다 담당 키 요소 id를 직접 +참조합니다. 읽은 문서에는 `id`와 `poseId`가 항상 실려 있습니다. 쓸 때는 둘 다 +그대로 돌려보내세요. 아래 표기된 개수·값 상한은 커밋 시점에 +검증하고, 읽은 문서는 저장된 데이터를 그대로 되돌려줍니다. + +```typescript +// 탭/모드 id(예: "4key")로 키잉 +type SpritePositions = Record; + +interface ReactiveSpritePosition { + id?: string; // 안정 요소 신원 (UUID) + dx: number; // 요소 상자 원점과 크기. 상자가 곧 기본 이미지 상자이고 오버레이 창 크기 계산에 들어갑니다 + dy: number; + width: number; + height: number; + rotation?: number; // 요소 상자 중심을 축으로 하는 배치 회전, -180–180도, 기본 0 + hidden: boolean; + zIndex: number | null; + layerName?: string | null; // 미설정이면 백엔드가 키를 생략합니다 + groupId?: string | null; // 미설정이면 백엔드가 키를 생략합니다 + className: string | null; + useInlineStyles: boolean | null; + baseImage: string | null; // 일치하는 자세가 없을 때 보여줄 이미지. 항상 요소 상자를 채웁니다 + referenceNaturalSize: { + source: string | null; + width: number; + height: number; + } | null; // 픽셀 배율의 기준이 되는 원본 크기(정수, 1–32768). baseImage가 있으면 source가 그 경로와 같아야 하고 없으면 null이어야 합니다. null이면 모든 이미지를 요소 상자에 그대로 그립니다 + pivot: { x: number; y: number }; // 정규화 공통 축, 각각 0–1, 기본 {0.5, 0.5}. 기본 이미지의 기준점이자 모든 자세 변환의 축 + idleTransform: SpriteTransform; // 일치하는 자세가 없을 때의 기본 변환 + poses: SpritePose[]; // 커밋은 최대 64개까지 허용 + activation: 'whileHeld' | 'onPress'; // 기본 whileHeld. onPress는 누른 순간 자세를 한 번 재생하고 idle로 복귀 + pressDurationMs: number; // onPress 재생 시간, 1–5000, 기본 300 + transitionMs: number; // 자세 전환 시간, 0–1000, 기본 0. onPress는 무시하고 pressDurationMs 동안 재생 + transitionEasing: string; // CSS easing, 기본 'cubic-bezier(0.4, 0, 0.2, 1)' +} + +interface SpritePose { + poseId?: string; // 안정 자세 신원 (UUID) + name?: string | null; // 미설정이면 백엔드가 키를 생략합니다 + triggers: string[]; // 키 요소 id 목록, 커밋은 최대 512개까지 허용하며 정렬·중복 제거 + transform: SpriteTransform; + pivot?: { x: number; y: number } | null; // 상태 이미지의 독립 기준점. null 또는 생략이면 스프라이트의 공통 기준점을 따라갑니다 + imageOverride: string | null; // 이 자세가 활성인 동안 baseImage를 대체 + imageOverrideMetrics: { + source: string; + width: number; + height: number; + } | null; // imageOverride의 원본 픽셀 크기(정수, 1–32768). 선택 사항이고, 있으면 source가 경로와 정확히 같아야 합니다. imageOverride가 없으면 null로 정규화됩니다 +} + +interface SpriteTransform { + x: number; // -2000–2000 px + y: number; // -2000–2000 px + rotation: number; // -180–180 도 + scale: number; // 0.1–10 +} +``` + +요소의 `rotation`은 논리 상자의 중심을 축으로 모든 자세를 포함한 스프라이트 +전체를 돌립니다. 공통 기준점을 축으로 도는 로컬 회전인 `idleTransform.rotation`, +각 자세의 `transform.rotation`과는 독립적입니다. 선택 전체를 회전하면 +스프라이트의 `dx`, `dy`, 요소 `rotation`을 함께 바꾸며 자세 변환, 트리거, +이미지 참조와 자세 전환 방향은 유지됩니다. +새 스프라이트에서 `rotation`을 생략하면 0입니다. 이미 있는 스프라이트의 +schemaVersion 1·2 커밋에서는 생략 시 저장된 회전값을 유지합니다. +`null`, 비유한값, `-180~180` 밖의 값은 거절됩니다. + +자세는 스프라이트가 참조하는 키 중 현재 눌린 키 집합이 트리거 집합과 정확히 +일치할 때 활성화되고, 스프라이트가 참조하지 않는 키는 무시합니다. 정확히 +일치하는 자세가 없으면 눌린 키 각각의 단일 트리거 자세를 같은 비중으로 +섞습니다. x·y·scale은 산술 평균, rotation은 원형 평균이고 이미지는 poseId +사전순 첫 override를 씁니다. 트리거가 둘 이상인 자세는 섞기에 참여하지 않으므로 +눌린 키가 그런 자세에만 있으면 idle로 돌아갑니다. 한 스프라이트의 두 자세가 같은 +트리거 집합을 가질 수 없고, 빈 트리거 목록은 커밋에서 거부됩니다. + +요소 상자가 곧 기본 이미지 상자입니다. 기본 이미지는 항상 상자를 채우고, 공통 축 P는 +스프라이트의 pivot × (width, height)입니다. 상태의 pivot이 null이거나 생략되면 상태 +이미지도 같은 정규화 기준점을 사용합니다. 상태에 pivot 값이 있으면 그 상태 이미지의 +독립 기준점을 P에 맞춥니다. 원본 크기가 다른 상태 이미지는 원본 픽셀 × 기준 배율로 +그립니다. 기준 배율은 (width, height)를 referenceNaturalSize로 축별로 나눈 값입니다. +기준 크기나 상태 이미지의 크기가 없으면 그 이미지를 요소 상자 크기로 그립니다. +에디터에서 공통 기준점을 옮기면 기본 이미지는 제자리에 남고, 공통 기준점에 연결된 +상태는 새 축을 따라갑니다. 독립 기준점을 가진 상태는 x/y를 같은 커밋에서 보정해 +화면 위치를 유지합니다. 상태 기준점을 옮기거나 연결을 전환할 때도 상태 이미지가 +갑자기 움직이지 않도록 x/y를 보정합니다. 범위를 벗어난 기준점은 커밋이 +`VALIDATION_FAILED`로 거절되며 `details.validationCode`는 `INVALID_SPRITE_PIVOT` +또는 `INVALID_SPRITE_POSE_PIVOT`입니다. 원본 크기가 경로와 어긋나거나 범위를 벗어나면 +`STALE_SPRITE_IMAGE_METRICS` 또는 `INVALID_SPRITE_IMAGE_METRICS`입니다. +schemaVersion 1 패치에서 이미 있는 요소·자세의 `pivot`, `referenceNaturalSize`, +`imageOverrideMetrics`, `activation`, `pressDurationMs`를 생략하면 저장된 값이 +유지됩니다. 상태의 `pivot`을 null로 보내면 공통 기준점 연결로 바뀝니다. 다만 +`baseImage`나 `imageOverride`를 바꾸면서 크기를 생략하면 크기는 비워집니다. + ## 현재 문서 조회 ### `dmn.editor.get(): Promise` @@ -92,7 +194,7 @@ console.log(revision, document.keyPositions); 고장까지 애플리케이션이 절대 보장할 수는 없습니다. - 앱의 Undo/Redo도 편집 컬렉션 6개, 커스텀 탭 정보, 선택 모드, 카운터, 프리셋 + 앱의 Undo/Redo도 편집 컬렉션 7개, 커스텀 탭 정보, 선택 모드, 카운터, 프리셋 설정, 탭별 노트 설정을 백엔드 store 트랜잭션 한 번으로 함께 복원합니다. 이 내부 커맨드는 공개 플러그인 API로 노출하지 않습니다. @@ -212,19 +314,19 @@ interface EditorCommitError { } ``` -| 코드 | 의미 | `retryable` | -| ------------------------ | --------------------------------------------------------- | ----------- | -| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` | -| `PLUGIN_REVISION_CONFLICT` | 플러그인 범위 revision이 오래됨. 다시 조회한 뒤 커밋 | `true` | -| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` | -| `TOO_MANY_GESTURE_IDS` | `gestureIds`가 32개를 넘거나 합친 고유 ID가 32개를 초과함 | `false` | -| `INVALID_GESTURE_ID` | gesture ID가 UUID가 아니거나 64바이트를 초과함 | `false` | -| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` | -| `MULTI_KEY_UNSUPPORTED` | 멀티 키 슬롯이 있는데 `multiKey` 선언 없이 `keys`를 씀 | `false` | -| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` | -| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` | -| `HISTORY_IN_PROGRESS` | undo/redo 진행 중. 끝난 뒤 재시도 | `true` | -| `HISTORY_EPOCH_CONFLICT` | 관측한 history epoch이 오래됨. 다시 조회한 뒤 커밋 | `true` | +| 코드 | 의미 | `retryable` | +| -------------------------- | --------------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` | +| `PLUGIN_REVISION_CONFLICT` | 플러그인 범위 revision이 오래됨. 다시 조회한 뒤 커밋 | `true` | +| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` | +| `TOO_MANY_GESTURE_IDS` | `gestureIds`가 32개를 넘거나 합친 고유 ID가 32개를 초과함 | `false` | +| `INVALID_GESTURE_ID` | gesture ID가 UUID가 아니거나 64바이트를 초과함 | `false` | +| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` | +| `MULTI_KEY_UNSUPPORTED` | 멀티 키 슬롯이 있는데 `multiKey` 선언 없이 `keys`를 씀 | `false` | +| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` | +| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` | +| `HISTORY_IN_PROGRESS` | undo/redo 진행 중. 끝난 뒤 재시도 | `true` | +| `HISTORY_EPOCH_CONFLICT` | 관측한 history epoch이 오래됨. 다시 조회한 뒤 커밋 | `true` | 오류에 해당할 때 `details.currentRevision`, `details.validationCode`, `details.field`, `details.currentHistoryEpoch`가 함께 제공됩니다. diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 607703faa..957b5c581 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -111,13 +111,24 @@ interface KeyPosition { dy: number; width: number; height: number; + rotation: number; // 도, -180~180. 생략하면 0. 상자 중심 기준 콘텐츠 회전 activeImage?: string; inactiveImage?: string; activeTransparent?: boolean; idleTransparent?: boolean; imageMode?: 'replace' | 'overlay'; // 이미지 배치. 없으면 replace(이미지가 키를 대체) - idleImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; // 없으면 identity - activeImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; + idleImageTransform?: { + offsetX: number; + offsetY: number; + rotation: number; + scale: number; + }; // 없으면 identity + activeImageTransform?: { + offsetX: number; + offsetY: number; + rotation: number; + scale: number; + }; count: number; noteColor: string | { type: 'gradient'; top: string; bottom: string }; noteOpacity: number; @@ -178,9 +189,11 @@ interface ElementShadowSpec { ``` 모든 요소 위치(`keyPositions`, `statPositions`, `graphPositions`, -`knobPositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을 -그대로 되돌려 보내고, 직접 만들지 마세요. `id`가 없거나 미확인인 쓰기는 -백엔드가 새 값을 발급합니다. 같은 `id`가 여러 번 나오면(읽은 요소를 복사해 +`knobPositions`, `spritePositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을 +그대로 되돌려 보내고, 직접 만들지 마세요. `id`가 없거나 미확인인 쓰기는 나머지 +필드가 같은 기존 요소의 신원을 승계합니다. 맞는 항목이 없으면 백엔드가 +새 값을 발급합니다. `id`를 빼는 것이 새 신원을 보장하지는 않고, 값을 바꾸면서 +`id`까지 빼면 새 신원이 될 수 있습니다. 같은 `id`가 여러 번 나오면(읽은 요소를 복사해 덧붙이는 경우처럼) 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 받습니다. 전체 프리셋을 불러오면 모든 `id`가 재발급되고, 탭 프리셋은 그 프리셋이 위치를 준 컬렉션만 재발급됩니다. 재정렬을 가로질러 요소를 추적할 @@ -218,6 +231,16 @@ interface ElementShadowSpec { 생략하면 기존 기본 그림자가 유지되며, 그림자를 명시적으로 끄려면 `enabled: false`인 spec을 저장하세요. +`rotation`은 요소 회전(도)입니다. 키·통계·그래프·노브가 같은 필드를 쓰며 +`dx`/`dy`/`width`/`height` 논리 상자는 축 정렬 그대로 두고 얼굴, 노트 +트랙, 외부 카운터가 상자 중심을 축으로 함께 돕니다. 생략하면 0이고 +`null`, 비유한값, `-180~180` 밖의 값은 거절됩니다. 자동 히트라인 +보정(`noteAutoYCorrection`)은 회전 0인 키는 콘텐츠 상단에, 회전한 키는 +같은 방향(각도 차 0.5° 이내)으로 도는 키끼리 가장 앞선 상변에 시작선을 +맞춥니다. 혼자 돌린 키는 자기 상변에서 시작하고 노트 오프셋은 그 뒤에 +적용됩니다. 스프라이트도 요소 배치용 `rotation`을 가지며 대기·자세별 회전과 +독립적으로 적용합니다. [스프라이트 위치 타입](/docs/api-reference/editor#스프라이트-위치-타입)을 참고하세요. + ```javascript const positions = await dmn.keys.getPositions(); console.log('4key 위치:', positions['4key']); diff --git a/docs/content/ko/api-reference/overlay/page.mdx b/docs/content/ko/api-reference/overlay/page.mdx index 784b73f0d..6552be691 100644 --- a/docs/content/ko/api-reference/overlay/page.mdx +++ b/docs/content/ko/api-reference/overlay/page.mdx @@ -71,7 +71,10 @@ interface ResizePayload { width: number; height: number; anchor?: string; + contentLeftOffset?: number; contentTopOffset?: number; + fixedPositionDeltaX?: number; + fixedPositionDeltaY?: number; } ``` diff --git a/docs/content/ko/api-reference/presets/page.mdx b/docs/content/ko/api-reference/presets/page.mdx index 97a160aa1..a420ce783 100644 --- a/docs/content/ko/api-reference/presets/page.mdx +++ b/docs/content/ko/api-reference/presets/page.mdx @@ -16,9 +16,9 @@ description: 설정 프리셋 저장 및 불러오기 API ```javascript const result = await dmn.presets.save(); if (result.success) { - console.log("프리셋 저장 완료"); + console.log('프리셋 저장 완료'); } else { - console.error("저장 실패:", result.error); + console.error('저장 실패:', result.error); } ``` @@ -31,9 +31,9 @@ if (result.success) { ```javascript const result = await dmn.presets.load(); if (result.success) { - console.log("프리셋 불러오기 완료"); + console.log('프리셋 불러오기 완료'); } else { - console.error("불러오기 실패:", result.error); + console.error('불러오기 실패:', result.error); } ``` @@ -52,7 +52,9 @@ const result = await dmn.presets.saveTab(); 탭 프리셋 파일을 현재 선택된 탭에 불러옵니다. 파일 선택 다이얼로그가 열립니다. 탭 프리셋에는 `tabOrder`와 `barCount`가 들어 있지 않아 전역 탭 순서를 -건드리지 않습니다. +건드리지 않습니다. 파일에 담긴 요소 컬렉션(키·통계·그래프·노브·스프라이트)은 탭 +내용을 통째로 대체하므로 파일 쪽 탭에 없는 요소는 사라집니다. 컬렉션 자체가 +없는 옛 프리셋은 그 요소를 그대로 둡니다. ```javascript const result = await dmn.presets.loadTab(); @@ -65,32 +67,32 @@ const result = await dmn.presets.loadTab(); ```javascript // @id preset-manager -if (dmn.window.type !== "main") return; +if (dmn.window.type !== 'main') return; // 프리셋 저장 메뉴 dmn.ui.contextMenu.addGridMenuItem({ - id: "save-preset", - label: "프리셋 저장", + id: 'save-preset', + label: '프리셋 저장', onClick: async () => { const result = await dmn.presets.save(); if (result.success) { - await dmn.ui.dialog.alert("프리셋이 저장되었습니다!"); + await dmn.ui.dialog.alert('프리셋이 저장되었습니다!'); } }, }); // 프리셋 불러오기 메뉴 dmn.ui.contextMenu.addGridMenuItem({ - id: "load-preset", - label: "프리셋 불러오기", + id: 'load-preset', + label: '프리셋 불러오기', onClick: async () => { const confirmed = await dmn.ui.dialog.confirm( - "현재 설정이 덮어씌워집니다. 계속하시겠습니까?" + '현재 설정이 덮어씌워집니다. 계속하시겠습니까?', ); if (confirmed) { const result = await dmn.presets.load(); if (result.success) { - await dmn.ui.dialog.alert("프리셋을 불러왔습니다!"); + await dmn.ui.dialog.alert('프리셋을 불러왔습니다!'); } } }, @@ -117,6 +119,7 @@ interface PresetSnapshot { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; customTabs: CustomTab[]; tabOrder: string[]; barCount: number; @@ -127,36 +130,37 @@ interface PresetSnapshot { ```javascript const unsub = dmn.presets.onSnapshot((snapshot) => { - console.log("프리셋 로드됨:", snapshot.selectedKeyType); - console.log("키 매핑:", snapshot.keys); + console.log('프리셋 로드됨:', snapshot.selectedKeyType); + console.log('키 매핑:', snapshot.keys); }); ``` - 프리셋 로드 시 개별 이벤트(`onChanged`, `onPositionsChanged` 등)는 발행되지 않습니다. - 프리셋 로드를 감지하려면 `onSnapshot`을 사용하세요. + 프리셋 로드 시 개별 이벤트(`onChanged`, `onPositionsChanged` 등)는 발행되지 + 않습니다. 프리셋 로드를 감지하려면 `onSnapshot`을 사용하세요. ## 프리셋에 포함되는 항목 프리셋은 “전체 설정 덤프”가 아니라, 아래 항목들을 파일로 저장/복원합니다. -| 필드 | 설명 | -| --- | --- | -| `keys` | 키 매핑 | -| `positions` | 키 위치/크기/개별 옵션(클래스, 이미지, 카운터 설정 포함) | -| `customTabs` | 커스텀 탭 목록 | -| `tabOrder` | 내장 모드와 커스텀 탭을 합친 표시 순서 | -| `barCount` | 그중 툴바 바에 나오는 앞쪽 개수 (1~4) | -| `selectedKeyType` | 선택된 탭/모드 | -| `backgroundColor` | 오버레이 배경색 | -| `noteSettings` / `noteEffect` | 노트 이펙트 관련 설정 | -| `laboratoryEnabled` | 실험실(랩) 기능 사용 여부 | -| `useCustomCSS` / `customCSS` | 커스텀 CSS 사용 여부/내용 | -| `useCustomJS` / `customJS` | 커스텀 JS 사용 여부/내용(플러그인 목록 포함) | +| 필드 | 설명 | +| ----------------------------- | -------------------------------------------------------- | +| `keys` | 키 매핑 | +| `positions` | 키 위치/크기/개별 옵션(클래스, 이미지, 카운터 설정 포함) | +| `customTabs` | 커스텀 탭 목록 | +| `tabOrder` | 내장 모드와 커스텀 탭을 합친 표시 순서 | +| `barCount` | 그중 툴바 바에 나오는 앞쪽 개수 (1~4) | +| `selectedKeyType` | 선택된 탭/모드 | +| `backgroundColor` | 오버레이 배경색 | +| `noteSettings` / `noteEffect` | 노트 이펙트 관련 설정 | +| `laboratoryEnabled` | 실험실(랩) 기능 사용 여부 | +| `useCustomCSS` / `customCSS` | 커스텀 CSS 사용 여부/내용 | +| `useCustomJS` / `customJS` | 커스텀 JS 사용 여부/내용(플러그인 목록 포함) | - 플러그인 설정(`dmn.plugin.storage`)은 프리셋에 포함되지 않습니다. 플러그인 데이터를 포함하려면 별도로 관리하세요. + 플러그인 설정(`dmn.plugin.storage`)은 프리셋에 포함되지 않습니다. 플러그인 + 데이터를 포함하려면 별도로 관리하세요. diff --git a/docs/content/ko/custom-css/variables/page.mdx b/docs/content/ko/custom-css/variables/page.mdx index c4f7b39df..1ff10c7da 100644 --- a/docs/content/ko/custom-css/variables/page.mdx +++ b/docs/content/ko/custom-css/variables/page.mdx @@ -182,6 +182,29 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 } ``` +## 스프라이트 스타일 변수 + +반응형 스프라이트는 담당 키를 누르는 동안 자세가 바뀌는 이미지 한 층을 그립니다. +배치와 회전 기준점은 창 크기 계산에 그대로 들어가므로 인라인으로 남고, 아래 +외관 채널만 교체할 수 있습니다. 스프라이트의 **인라인 스타일 우선**을 끄고 쓰세요. + +`--sprite-transform`은 이미지의 로컬 자세 변환만 교체합니다. 요소 배치용 +`rotation`은 앱이 상자 중심을 축으로 적용하며 자세 변환 바깥에서 함께 돕니다. + +| 변수 | 설명 | 타입 | 예시 | +| --------------------- | ------------------------------------------------- | -------------- | -------------------------- | +| `--sprite-transform` | 해석된 자세의 변환 교체 (onPress는 이미지만 바뀜) | `` | `rotate(15deg) scale(1.2)` | +| `--sprite-transition` | 자세 전환 트랜지션 교체 (whileHeld에만 적용) | `` | `transform 120ms ease-out` | + +```css +.왼손 [data-sprite-element] > img { + --sprite-transition: transform 90ms cubic-bezier(0.2, 0, 0, 1); +} +``` + +이미지 요소는 원본 비율 그대로 상자를 채우므로 `object-fit`이 바꿀 것이 없습니다. +크기 자체를 CSS로 덮으면 자세 이미지의 기준점 정합은 보장하지 않습니다. + ## 선택자 레퍼런스 ### 키 선택자 @@ -221,6 +244,16 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 | `.노브클래스 [data-knob-element]` | 특정 클래스의 노브 표면 | | `[data-knob-indicator]` | 노브 중앙 회전 표시 | +### 스프라이트 선택자 + +| 선택자 | 설명 | +| ----------------------------------------- | ----------------------------------------------------- | +| `[data-sprite-element]` | 모든 스프라이트 요소 상자 | +| `[data-sprite-element] > img` | 스프라이트 이미지 층 | +| `[data-sprite-state="inactive"]` | 트리거 키를 누르지 않은 스프라이트 | +| `[data-sprite-state="active"]` | 트리거 키를 누른 스프라이트 (onPress는 항상 inactive) | +| `.스프라이트클래스 [data-sprite-element]` | 특정 클래스의 스프라이트 | + ## 표준 CSS 속성 **인라인 스타일 우선**이 꺼진 요소에는 다음과 같은 표준 외형 속성을 직접 사용할 diff --git a/docs/content/ko/settings/page.mdx b/docs/content/ko/settings/page.mdx index 9e20c9d93..945379ae2 100644 --- a/docs/content/ko/settings/page.mdx +++ b/docs/content/ko/settings/page.mdx @@ -66,7 +66,11 @@ const current = pluginSettings.get(); console.log("API Key:", current.apiKey); // 설정값 변경 -await pluginSettings.set({ theme: "light" }); +try { + await pluginSettings.set({ theme: "light" }); +} catch (error) { + console.error("설정 저장 실패:", error); +} // 설정 패널 열기 (기본: 속성 패널) const confirmed = await pluginSettings.open(); @@ -112,6 +116,11 @@ interface PluginSettingsDefinition { | `reset()` | 기본값으로 초기화 | | `subscribe(listener)` | 설정 변경 구독 | +`set()`과 `reset()`은 저장이 실패하면 Promise를 거부합니다. 실패한 편집은 저장된 +설정으로 복원하며, 복원 중 시작한 새 편집은 덮지 않습니다. 변경 콜백과 구독 알림은 +저장 성공 후에만 전달됩니다. 속성 패널의 저장 실패는 안내 후 `open()`이 `false`로 +끝나며, 모달 방식의 저장 실패는 `open()` 호출자에게 오류로 전달됩니다. + ## onChange vs subscribe() 두 방식 모두 설정 변경을 감지하지만 용도가 다릅니다: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a2951ac9a..9a44980b1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1158,6 +1158,7 @@ dependencies = [ "dirs-next", "fern", "futures-util", + "imagesize", "infer", "libc", "local-ip-address", @@ -2221,6 +2222,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + [[package]] name = "indexmap" version = "1.9.3" @@ -5126,6 +5133,7 @@ dependencies = [ "libc", "mio 1.1.1", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 920d65503..c5bbea473 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ zip = "0.6" [dependencies] anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["float_roundtrip"] } base64 = "0.22" minisign-verify = "0.2.5" thiserror = "1.0" @@ -44,6 +44,7 @@ zip = "0.6" sha2 = "0.10" quick-xml = "0.38" infer = "0.19" +imagesize = "0.15" rodio = { git = "https://github.com/lee-sihun/rodio", rev = "7b771e15", default-features = false, features = ["playback"] } symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis", "wav", "aiff", "mp3", "isomp4", "aac", "pcm", "adpcm", "flac", "alac"] } tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros"] } @@ -56,6 +57,9 @@ wuff = "0.2.8" [dev-dependencies] libc = "0.2" +[target."cfg(unix)".dependencies] +tokio = { version = "1", features = ["signal"] } + [target."cfg(windows)".dependencies] cpal = { version = "0.17.3", optional = true, default-features = false, features = ["asio"] } windows = { version = "0.61.3", features = [ diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 819681c32..e36a7c90a 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -205,6 +205,20 @@ pub fn run() { } configure_main_window(app.handle()); + #[cfg(unix)] + { + let handle = app.handle().clone(); + state::window::unix_termination::install(move || { + if let Some(state) = handle.try_state::() { + state.request_frontend_shutdown(handle.clone()); + } + }) + .map_err(|error| { + log::error!("failed to install SIGTERM shutdown handler: {error}"); + error + })?; + } + #[cfg(target_os = "macos")] { state::window::macos_termination::install(app.handle()) diff --git a/src-tauri/src/commands/editor/history.rs b/src-tauri/src/commands/editor/history.rs index 2074b34f0..9a6f2538d 100644 --- a/src-tauri/src/commands/editor/history.rs +++ b/src-tauri/src/commands/editor/history.rs @@ -411,6 +411,7 @@ fn preset_history_event_projection( stat_positions: snapshot.document.stat_positions.clone(), graph_positions: snapshot.document.graph_positions.clone(), knob_positions: snapshot.document.knob_positions.clone(), + sprite_positions: snapshot.document.sprite_positions.clone(), custom_tabs: snapshot.custom_tabs.clone(), tab_order: snapshot.tab_order.clone(), bar_count: snapshot.bar_count, diff --git a/src-tauri/src/commands/editor/state.rs b/src-tauri/src/commands/editor/state.rs index 8342e85d1..1c2b86459 100644 --- a/src-tauri/src/commands/editor/state.rs +++ b/src-tauri/src/commands/editor/state.rs @@ -26,6 +26,7 @@ fn legacy_event_name(field: EditorField) -> &'static str { EditorField::StatPositions => "statPositions:changed", EditorField::GraphPositions => "graphPositions:changed", EditorField::KnobPositions => "knobPositions:changed", + EditorField::SpritePositions => "spritePositions:changed", EditorField::LayerGroups => "layerGroups:changed", } } @@ -77,6 +78,9 @@ pub(crate) fn publish_legacy_editor_fields( EditorField::KnobPositions => { emit_best_effort(app, event, &change.document.knob_positions); } + EditorField::SpritePositions => { + emit_best_effort(app, event, &change.document.sprite_positions); + } EditorField::LayerGroups => { emit_best_effort(app, event, &change.document.layer_groups); } @@ -300,6 +304,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ]; assert_eq!(&fields, projected_legacy_fields(true, &fields)); @@ -311,6 +316,7 @@ mod tests { "statPositions:changed", "graphPositions:changed", "knobPositions:changed", + "spritePositions:changed", "layerGroups:changed", ] ); @@ -353,4 +359,76 @@ mod tests { assert_eq!(wire["details"]["validationCode"], "INVALID_REQUEST_PAYLOAD"); assert_eq!(wire["retryable"], false); } + + #[test] + fn nullable_contract_rejects_null_for_non_nullable_sprite_fields() { + let sprite = crate::models::ReactiveSpritePosition { + poses: vec![crate::models::SpritePose::default()], + ..crate::models::ReactiveSpritePosition::default() + }; + let changes = serde_json::json!({ + "schemaVersion": 1, + "spritePositions": { "4key": [sprite] } + }); + + for pointer in [ + "/spritePositions/4key/0/activation", + "/spritePositions/4key/0/pressDurationMs", + ] { + let mut invalid_changes = changes.clone(); + *invalid_changes.pointer_mut(pointer).unwrap() = Value::Null; + let error = decode_editor_commit_request(serde_json::json!({ + "baseRevision": 0, + "mutationId": uuid::Uuid::new_v4().to_string(), + "changes": invalid_changes + })) + .unwrap_err(); + assert_eq!( + error + .details + .and_then(|details| details.validation_code) + .as_deref(), + Some("INVALID_REQUEST_PAYLOAD") + ); + } + } + + #[test] + fn omitted_and_explicit_sprite_fields_have_distinct_mutation_fingerprints() { + let sprite = crate::models::ReactiveSpritePosition { + poses: vec![crate::models::SpritePose::default()], + ..crate::models::ReactiveSpritePosition::default() + }; + let explicit_changes = serde_json::json!({ + "schemaVersion": 1, + "spritePositions": { "4key": [sprite] } + }); + let mut omitted_changes = explicit_changes.clone(); + let sprite = omitted_changes + .pointer_mut("/spritePositions/4key/0") + .and_then(Value::as_object_mut) + .unwrap(); + sprite.remove("referenceNaturalSize"); + sprite.remove("activation"); + sprite["poses"][0] + .as_object_mut() + .unwrap() + .remove("imageOverrideMetrics"); + let mutation_id = uuid::Uuid::new_v4().to_string(); + let decode = |changes| { + decode_editor_commit_request(serde_json::json!({ + "baseRevision": 0, + "mutationId": &mutation_id, + "changes": changes + })) + .unwrap() + }; + let explicit = decode(explicit_changes); + let omitted = decode(omitted_changes); + + assert_ne!( + crate::state::editor::request_fingerprint(&explicit).unwrap(), + crate::state::editor::request_fingerprint(&omitted).unwrap() + ); + } } diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 954f2b36f..c1d33fbb8 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -126,6 +126,7 @@ fn reset_all_editor_data( store.stat_positions = stat_positions.clone(); store.graph_positions.clear(); store.knob_positions.clear(); + store.sprite_positions.clear(); store.layer_groups.clear(); store.key_counters = zeroed_counters(keys); store.custom_tabs.clear(); @@ -223,6 +224,7 @@ fn apply_reset_mode_data(store: &mut AppStoreData, mode: &str, kind: ModeResetKi store.graph_positions.insert(mode.to_string(), Vec::new()); store.knob_positions.insert(mode.to_string(), Vec::new()); + store.sprite_positions.insert(mode.to_string(), Vec::new()); store.layer_groups.remove(mode); store.tab_css_overrides.remove(mode); store.tab_note_overrides.remove(mode); @@ -278,10 +280,24 @@ fn mode_positions_equal_without_ids( position.position.id.clear(); } + let mut current_sprites = current.sprite_positions.get(mode).cloned(); + let mut candidate_sprites = candidate.sprite_positions.get(mode).cloned(); + for sprite in current_sprites + .iter_mut() + .chain(candidate_sprites.iter_mut()) + .flatten() + { + sprite.id.clear(); + for pose in &mut sprite.poses { + pose.pose_id.clear(); + } + } + current_keys == candidate_keys && optional_collections_equal(current_stats.as_deref(), candidate_stats.as_deref()) && optional_collections_equal(current_graphs.as_deref(), candidate_graphs.as_deref()) && optional_collections_equal(current_knobs.as_deref(), candidate_knobs.as_deref()) + && optional_collections_equal(current_sprites.as_deref(), candidate_sprites.as_deref()) } fn optional_collections_equal( @@ -386,6 +402,7 @@ fn delete_custom_tab_data(store: &mut AppStoreData, id: &str, plan: &CustomTabDe store.stat_positions.remove(id); store.graph_positions.remove(id); store.knob_positions.remove(id); + store.sprite_positions.remove(id); store.layer_groups.remove(id); store.tab_css_overrides.remove(id); store.tab_note_overrides.remove(id); @@ -672,6 +689,7 @@ fn keys_reset_all_inner( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::All, @@ -705,6 +723,7 @@ fn keys_reset_all_inner( let stat_positions = transaction.change.document.stat_positions; let graph_positions = transaction.change.document.graph_positions; let knob_positions = transaction.change.document.knob_positions; + let sprite_positions = transaction.change.document.sprite_positions; let layer_groups = transaction.change.document.layer_groups; let selected_key_type = transaction.change.selected_key_type; let custom_tabs: Vec = Vec::new(); @@ -721,6 +740,7 @@ fn keys_reset_all_inner( emit_best_effort(app, "statPositions:changed", &stat_positions); emit_best_effort(app, "graphPositions:changed", &graph_positions); emit_best_effort(app, "knobPositions:changed", &knob_positions); + emit_best_effort(app, "spritePositions:changed", &sprite_positions); emit_best_effort(app, "layerGroups:changed", &layer_groups); emit_best_effort( app, @@ -794,6 +814,7 @@ fn keys_reset_mode_inner( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(mode.clone()), @@ -841,6 +862,11 @@ fn keys_reset_mode_inner( "knobPositions:changed", &transaction.change.document.knob_positions, ); + emit_best_effort( + app, + "spritePositions:changed", + &transaction.change.document.sprite_positions, + ); emit_best_effort( app, "layerGroups:changed", diff --git a/src-tauri/src/commands/keys/keys/custom_tabs.rs b/src-tauri/src/commands/keys/keys/custom_tabs.rs index be9fc015b..fad48e7f3 100644 --- a/src-tauri/src/commands/keys/keys/custom_tabs.rs +++ b/src-tauri/src/commands/keys/keys/custom_tabs.rs @@ -270,6 +270,7 @@ fn custom_tabs_delete_inner( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], plugin_instances_reset: PluginInstancesResetScope::Mode(id.clone()), @@ -342,6 +343,11 @@ fn custom_tabs_delete_inner( "knobPositions:changed", &transaction.change.document.knob_positions, ); + emit_best_effort( + app, + "spritePositions:changed", + &transaction.change.document.sprite_positions, + ); emit_best_effort( app, "layerGroups:changed", diff --git a/src-tauri/src/commands/keys/keys/tests.rs b/src-tauri/src/commands/keys/keys/tests.rs index e1b1795c8..19593a57f 100644 --- a/src-tauri/src/commands/keys/keys/tests.rs +++ b/src-tauri/src/commands/keys/keys/tests.rs @@ -776,6 +776,7 @@ fn reset_mode_with_changed_keys_preserves_other_live_mode_counters() { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(reset_mode.to_string()), @@ -843,6 +844,7 @@ fn reset_mode_with_default_keys_applies_counter_only_reset_to_runtime() { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(mode.to_string()), @@ -903,6 +905,7 @@ fn reset_mode_replays_queued_increment_for_unchanged_mapping() { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(reset_mode.to_string()), @@ -974,6 +977,7 @@ fn reset_mode_drops_queued_increment_for_replaced_key() { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(mode.to_string()), @@ -1041,6 +1045,7 @@ fn reset_all_drops_queued_increment_for_removed_custom_mode() { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::All, @@ -1103,6 +1108,7 @@ fn reset_all_with_default_keys_applies_counter_only_reset_to_runtime() { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::All, @@ -1179,6 +1185,7 @@ fn reset_all_with_changed_keys_zeroes_every_live_counter() { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::All, diff --git a/src-tauri/src/commands/layout/overlay.rs b/src-tauri/src/commands/layout/overlay.rs index 73d706b34..615b68ce2 100644 --- a/src-tauri/src/commands/layout/overlay.rs +++ b/src-tauri/src/commands/layout/overlay.rs @@ -17,6 +17,8 @@ pub struct OverlayResizeArgs { #[serde(default)] pub anchor: Option, #[serde(default)] + pub content_left_offset: Option, + #[serde(default)] pub content_top_offset: Option, #[serde(default)] pub fixed_position_delta_x: Option, @@ -152,6 +154,7 @@ pub async fn overlay_resize( payload.width, payload.height, payload.anchor, + payload.content_left_offset, payload.content_top_offset, payload.fixed_position_delta_x, payload.fixed_position_delta_y, diff --git a/src-tauri/src/commands/media/image.rs b/src-tauri/src/commands/media/image.rs index 749c0d8e1..f7ceefa0d 100644 --- a/src-tauri/src/commands/media/image.rs +++ b/src-tauri/src/commands/media/image.rs @@ -14,7 +14,7 @@ use tauri::{Manager, WebviewWindow}; use crate::{ commands::dialog::parented_file_dialog, errors::{CmdResult, CommandError}, - state::assets::image_asset::import_image_file, + state::assets::image_asset::{import_image_file, SUPPORTED_IMAGE_EXTENSIONS}, }; #[derive(Serialize)] @@ -44,15 +44,9 @@ pub async fn image_load( app: tauri::AppHandle, window: WebviewWindow, ) -> CmdResult { - let picked = parented_file_dialog( - &window, - "Images", - &[ - "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "ico", "avif", - ], - ) - .pick_file() - .await; + let picked = parented_file_dialog(&window, "Images", SUPPORTED_IMAGE_EXTENSIONS) + .pick_file() + .await; let Some(file) = picked else { return Ok(ImageLoadResponse { diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index d408828dd..f0b596701 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -20,17 +20,19 @@ use crate::{ errors::{CmdResult, CommandError}, models::{ default_bar_count, default_missing_note_gradient_multipliers, normalize_key_mappings, - scrub_removed_text_outline_fields, AppStoreData, CustomCss, CustomCssPatch, CustomJs, - CustomJsPatch, EditorCommitOrigin, EditorField, FontSettings, FontType, GradientSpec, - GraphPositions, KeyMappings, KeyPosition, KeyPositions, KeySlot, KnobPositions, - LayerGroups, NoteSettings, NoteSettingsPatch, SettingsPatchInput, StatPositions, TabCss, - TabCssOverrides, TabNoteSettings, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, - SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, + rewrite_coupled_sprite_image_reference, scrub_removed_text_outline_fields, AppStoreData, + CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, EditorCommitOrigin, EditorField, + FontSettings, FontType, GradientSpec, GraphPositions, KeyMappings, KeyPosition, + KeyPositions, KeySlot, KnobPositions, LayerGroups, NoteSettings, NoteSettingsPatch, + SettingsPatchInput, SpritePositions, StatPositions, TabCss, TabCssOverrides, + TabNoteSettings, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, + SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, }, services::settings::apply_patch_to_store, state::{ assets::image_asset::{import_image_bytes, import_image_file}, editor::validate_history_restore_metadata, + migration::{fill_missing_sprite_image_metrics, migrate_legacy_sprite_wire}, tab_metadata::{ legacy_tab_order, normalize_bar_count, normalize_tab_order, reconcile_custom_tab_metadata, @@ -56,7 +58,7 @@ use imported_assets::{ #[cfg(test)] use imported_assets::{ merge_tab_preset_fonts, restore_position_image_reference, restore_preset_local_fonts_in_dir, - restore_preset_local_sounds_in_dir, + restore_preset_local_images_in_dir, restore_preset_local_sounds_in_dir, }; #[cfg(test)] use validation::invalid_position_style_detail; @@ -154,6 +156,7 @@ fn preset_load_from_path( let mut stat_positions = preset.stat_positions.unwrap_or_default(); let mut graph_positions = preset.graph_positions.unwrap_or_default(); let mut knob_positions = preset.knob_positions.unwrap_or_default(); + let mut sprite_positions = preset.sprite_positions.unwrap_or_default(); migrate_imported_font_weights( &mut positions, &mut stat_positions, @@ -221,6 +224,7 @@ fn preset_load_from_path( &mut stat_positions, &mut graph_positions, &mut knob_positions, + &mut sprite_positions, preset.embedded_local_images.as_deref(), )?; restore_preset_local_sounds( @@ -288,6 +292,7 @@ fn preset_load_from_path( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], admission, @@ -303,6 +308,7 @@ fn preset_load_from_path( store.stat_positions = stat_positions; store.graph_positions = graph_positions; store.knob_positions = knob_positions; + store.sprite_positions = sprite_positions; store.custom_tabs = custom_tabs; store.tab_order = tab_order; store.bar_count = bar_count; @@ -360,6 +366,7 @@ fn preset_load_from_path( let stat_positions = transaction.change.document.stat_positions; let graph_positions = transaction.change.document.graph_positions; let knob_positions = transaction.change.document.knob_positions; + let sprite_positions = transaction.change.document.sprite_positions; let layer_groups = transaction.change.document.layer_groups; if let Err(error) = state.emit_settings_changed(&diff, app) { @@ -376,6 +383,7 @@ fn preset_load_from_path( stat_positions, graph_positions, knob_positions, + sprite_positions, custom_tabs, tab_order, bar_count, @@ -438,6 +446,7 @@ fn preset_load_tab_from_path( stat_positions, graph_positions, knob_positions, + sprite_positions, selected_key_type, tab_note_overrides, layer_groups, @@ -471,23 +480,14 @@ fn preset_load_tab_from_path( src_key_positions.insert(current_tab_id.clone(), v.clone()); } - let imported_stat_positions = stat_positions.unwrap_or_default(); - let mut src_stat_positions: StatPositions = HashMap::new(); - if let Some(v) = imported_stat_positions.get(&source_tab_id) { - src_stat_positions.insert(current_tab_id.clone(), v.clone()); - } - - let imported_graph_positions = graph_positions.unwrap_or_default(); - let mut src_graph_positions: GraphPositions = HashMap::new(); - if let Some(v) = imported_graph_positions.get(&source_tab_id) { - src_graph_positions.insert(current_tab_id.clone(), v.clone()); - } - - let imported_knob_positions = knob_positions.unwrap_or_default(); - let mut src_knob_positions: KnobPositions = HashMap::new(); - if let Some(v) = imported_knob_positions.get(&source_tab_id) { - src_knob_positions.insert(current_tab_id.clone(), v.clone()); - } + let mut src_stat_positions = + select_tab_preset_elements(stat_positions.as_ref(), &source_tab_id, ¤t_tab_id); + let mut src_graph_positions = + select_tab_preset_elements(graph_positions.as_ref(), &source_tab_id, ¤t_tab_id); + let mut src_knob_positions = + select_tab_preset_elements(knob_positions.as_ref(), &source_tab_id, ¤t_tab_id); + let mut src_sprite_positions = + select_tab_preset_elements(sprite_positions.as_ref(), &source_tab_id, ¤t_tab_id); migrate_imported_font_weights( &mut src_key_positions, @@ -509,6 +509,7 @@ fn preset_load_tab_from_path( &mut src_stat_positions, &mut src_graph_positions, &mut src_knob_positions, + &mut src_sprite_positions, embedded_local_images.as_deref(), )?; restore_preset_local_sounds( @@ -524,6 +525,7 @@ fn preset_load_tab_from_path( let imported_stat_positions = src_stat_positions.remove(¤t_tab_id); let imported_graph_positions = src_graph_positions.remove(¤t_tab_id); let imported_knob_positions = src_knob_positions.remove(¤t_tab_id); + let imported_sprite_positions = src_sprite_positions.remove(¤t_tab_id); let imported_override = imported_tab_note_overrides.get(&source_tab_id).cloned(); let imported_groups = layer_groups.map(|groups| groups.get(&source_tab_id).cloned().unwrap_or_default()); @@ -566,6 +568,7 @@ fn preset_load_tab_from_path( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], admission, @@ -575,6 +578,7 @@ fn preset_load_tab_from_path( let stat_positions_written = imported_stat_positions.is_some(); let graph_positions_written = imported_graph_positions.is_some(); let knob_positions_written = imported_knob_positions.is_some(); + let sprite_positions_written = imported_sprite_positions.is_some(); merge_tab_preset_key_pair(store, ¤t_tab_id, src_keys, imported_key_positions); if let Some(positions) = imported_stat_positions { store @@ -591,6 +595,11 @@ fn preset_load_tab_from_path( .knob_positions .insert(current_tab_id.clone(), positions); } + if let Some(positions) = imported_sprite_positions { + store + .sprite_positions + .insert(current_tab_id.clone(), positions); + } rekey_tab_preset_elements( store, ¤t_tab_id, @@ -598,6 +607,7 @@ fn preset_load_tab_from_path( stat_positions_written, graph_positions_written, knob_positions_written, + sprite_positions_written, ); apply_tab_note_override( store, @@ -662,6 +672,7 @@ fn preset_load_tab_from_path( let full_stat_positions = transaction.change.document.stat_positions; let full_graph_positions = transaction.change.document.graph_positions; let full_knob_positions = transaction.change.document.knob_positions; + let full_sprite_positions = transaction.change.document.sprite_positions; let full_layer_groups = transaction.change.document.layer_groups; if let Some(diff) = settings_diff.as_ref() { @@ -676,6 +687,7 @@ fn preset_load_tab_from_path( emit_best_effort(app, "statPositions:changed", &full_stat_positions); emit_best_effort(app, "graphPositions:changed", &full_graph_positions); emit_best_effort(app, "knobPositions:changed", &full_knob_positions); + emit_best_effort(app, "spritePositions:changed", &full_sprite_positions); emit_best_effort(app, "tabNote:changed_all", &full_tab_note_overrides); // OBS 브릿지: 탭 프리셋 로드 시 전체 스냅샷 재전송 @@ -806,6 +818,24 @@ fn normalize_imported_tab_css_overrides( overrides } +/// 탭 프리셋의 요소 컬렉션 하나를 대상 탭 키로 옮긴다. 컬렉션이 있는데 원본 탭 항목이 +/// 없으면 "요소 없음"이라 빈 목록을 넣어 기존 요소를 지운다 - 없으면 남는다. +/// 컬렉션 자체가 없는 옛 프리셋만 기존 요소를 그대로 둔다 +fn select_tab_preset_elements( + collection: Option<&HashMap>>, + source_tab_id: &str, + current_tab_id: &str, +) -> HashMap> { + let mut selected = HashMap::new(); + if let Some(collection) = collection { + selected.insert( + current_tab_id.to_string(), + collection.get(source_tab_id).cloned().unwrap_or_default(), + ); + } + selected +} + fn choose_tab_preset_source_tab( keys: &KeyMappings, selected_key_type: Option<&str>, @@ -866,6 +896,7 @@ fn rekey_tab_preset_elements( stat_positions_written: bool, graph_positions_written: bool, knob_positions_written: bool, + sprite_positions_written: bool, ) { crate::state::native_element_id::rekey_mode_element_ids_for_collections( store, @@ -874,6 +905,7 @@ fn rekey_tab_preset_elements( stat_positions_written, graph_positions_written, knob_positions_written, + sprite_positions_written, ); // 프리셋이 위치를 주지 않은 컬렉션은 기존 요소가 값 그대로 남는다 - // 신원을 회전시키지 않고 정렬이 덧붙인 빈 항목만 채운다 @@ -884,6 +916,7 @@ fn rekey_tab_preset_elements( !stat_positions_written, !graph_positions_written, !knob_positions_written, + !sprite_positions_written, ); } diff --git a/src-tauri/src/commands/preset/load/imported_assets.rs b/src-tauri/src/commands/preset/load/imported_assets.rs index 9117eb19e..1722eda36 100644 --- a/src-tauri/src/commands/preset/load/imported_assets.rs +++ b/src-tauri/src/commands/preset/load/imported_assets.rs @@ -222,6 +222,28 @@ pub(super) fn restore_preset_local_images( stat_positions: &mut StatPositions, graph_positions: &mut GraphPositions, knob_positions: &mut KnobPositions, + sprite_positions: &mut SpritePositions, + embedded_local_images: Option<&[EmbeddedLocalImage]>, +) -> CmdResult<()> { + let app_data_dir = app.path().app_data_dir()?; + restore_preset_local_images_in_dir( + &app_data_dir.join("images"), + key_positions, + stat_positions, + graph_positions, + knob_positions, + sprite_positions, + embedded_local_images, + ) +} + +pub(super) fn restore_preset_local_images_in_dir( + images_dir: &Path, + key_positions: &mut KeyPositions, + stat_positions: &mut StatPositions, + graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, + sprite_positions: &mut SpritePositions, embedded_local_images: Option<&[EmbeddedLocalImage]>, ) -> CmdResult<()> { let has_any_images = key_positions.values().any(|positions| { @@ -244,6 +266,14 @@ pub(super) fn restore_preset_local_images( option_has_non_empty_text(&knob_position.position.active_image) || option_has_non_empty_text(&knob_position.position.inactive_image) }) + }) || sprite_positions.values().any(|sprites| { + sprites.iter().any(|sprite| { + option_has_non_empty_text(&sprite.base_image) + || sprite + .poses + .iter() + .any(|pose| option_has_non_empty_text(&pose.image_override)) + }) }); if !has_any_images { @@ -257,20 +287,18 @@ pub(super) fn restore_preset_local_images( .collect(); let mut restored_path_cache: HashMap = HashMap::new(); - let app_data_dir = app.path().app_data_dir()?; - let images_dir = app_data_dir.join("images"); - fs::create_dir_all(&images_dir)?; + fs::create_dir_all(images_dir)?; for positions in key_positions.values_mut() { for position in positions.iter_mut() { restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut position.active_image, )?; restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut position.inactive_image, @@ -281,13 +309,13 @@ pub(super) fn restore_preset_local_images( for positions in stat_positions.values_mut() { for stat_position in positions.iter_mut() { restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut stat_position.position.active_image, )?; restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut stat_position.position.inactive_image, @@ -298,13 +326,13 @@ pub(super) fn restore_preset_local_images( for positions in graph_positions.values_mut() { for graph_position in positions.iter_mut() { restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut graph_position.position.active_image, )?; restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut graph_position.position.inactive_image, @@ -315,13 +343,13 @@ pub(super) fn restore_preset_local_images( for positions in knob_positions.values_mut() { for knob_position in positions.iter_mut() { restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut knob_position.position.active_image, )?; restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut knob_position.position.inactive_image, @@ -329,6 +357,31 @@ pub(super) fn restore_preset_local_images( } } + for sprites in sprite_positions.values_mut() { + for sprite in sprites { + rewrite_coupled_sprite_image_reference(sprite, |image_ref| { + restore_position_image_reference( + images_dir, + &embedded_map, + &mut restored_path_cache, + image_ref, + ) + })?; + for pose in &mut sprite.poses { + rewrite_coupled_sprite_image_reference(pose, |image_ref| { + restore_position_image_reference( + images_dir, + &embedded_map, + &mut restored_path_cache, + image_ref, + ) + })?; + } + } + } + + fill_missing_sprite_image_metrics(sprite_positions); + Ok(()) } diff --git a/src-tauri/src/commands/preset/load/tests.rs b/src-tauri/src/commands/preset/load/tests.rs index baf117d49..d3a4a6b2b 100644 --- a/src-tauri/src/commands/preset/load/tests.rs +++ b/src-tauri/src/commands/preset/load/tests.rs @@ -3,10 +3,150 @@ use crate::{ defaults::{default_keys, default_positions}, models::{ CustomCssHistoryEntry, CustomFont, FontWeightRange, GraphPosition, GraphStatType, - GraphType, JsPlugin, KnobPosition, StatPosition, StatType, + GraphType, JsPlugin, KnobPosition, ReactiveSpritePosition, SpriteAnchor, + SpriteImageMetrics, SpritePose, SpriteReferenceNaturalSize, StatPosition, StatType, }, }; +fn png_header(width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes +} + +#[test] +fn preset_image_restore_fills_missing_sprite_metrics() { + let root = std::env::temp_dir().join(format!( + "dmnote-preset-sprite-metrics-{}", + uuid::Uuid::new_v4() + )); + let mut sprites = SpritePositions::from([( + "4key".to_string(), + vec![ReactiveSpritePosition { + base_image: Some(format!("{PRESET_LOCAL_IMAGE_PREFIX}base")), + poses: vec![SpritePose { + image_override: Some(format!("{PRESET_LOCAL_IMAGE_PREFIX}pose")), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + )]); + let embedded = [ + EmbeddedLocalImage { + image_id: "base".to_string(), + extension: Some("png".to_string()), + data_base64: BASE64_STANDARD.encode(png_header(640, 360)), + }, + EmbeddedLocalImage { + image_id: "pose".to_string(), + extension: Some("png".to_string()), + data_base64: BASE64_STANDARD.encode(png_header(320, 240)), + }, + ]; + + restore_preset_local_images_in_dir( + &root.join("images"), + &mut KeyPositions::new(), + &mut StatPositions::new(), + &mut GraphPositions::new(), + &mut KnobPositions::new(), + &mut sprites, + Some(&embedded), + ) + .unwrap(); + + let sprite = &sprites["4key"][0]; + assert_eq!( + sprite + .reference_natural_size + .as_ref() + .map(|size| (size.width, size.height)), + Some((640, 360)) + ); + assert_eq!( + sprite.poses[0] + .image_override_metrics + .as_ref() + .map(|size| (size.width, size.height)), + Some((320, 240)) + ); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn preset_reader_migrates_legacy_sprite_wire_before_deserialization() { + let root = std::env::temp_dir().join(format!( + "dmnote-preset-sprite-wire-{}", + uuid::Uuid::new_v4() + )); + let path = root.join("preset.json"); + std::fs::create_dir_all(&root).unwrap(); + let mut value = serde_json::to_value(PresetFile { + sprite_positions: Some(SpritePositions::from([( + "4key".to_string(), + vec![ReactiveSpritePosition { + dx: 10.0, + dy: 20.0, + poses: vec![SpritePose::default()], + ..ReactiveSpritePosition::default() + }], + )])), + ..PresetFile::default() + }) + .unwrap(); + let sprite = value["spritePositions"]["4key"][0].as_object_mut().unwrap(); + sprite.remove("rotation"); + sprite.insert( + "imageRect".to_string(), + serde_json::json!({ "x": 5.0, "y": -2.0, "width": 320.0, "height": 180.0 }), + ); + sprite.insert("imagePlacement".to_string(), serde_json::json!("pivot")); + sprite["poses"][0]["contactPoint"] = serde_json::json!({ "x": 0.5, "y": 1.0 }); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + + let preset = read_preset_file(&path).unwrap(); + let sprite = &preset.sprite_positions.unwrap()["4key"][0]; + assert_eq!(sprite.rotation, 0.0); + assert_eq!((sprite.dx, sprite.dy), (15.0, 18.0)); + assert_eq!((sprite.width, sprite.height), (320.0, 180.0)); + let wire = serde_json::to_value(sprite).unwrap(); + for field in ["imageRect", "imagePlacement"] { + assert!(wire.get(field).is_none()); + } + assert!(wire["poses"][0].get("contactPoint").is_none()); + + std::fs::remove_dir_all(root).unwrap(); +} + +fn pivot_sprite_with_local_images(base_image: &Path, pose_image: &Path) -> ReactiveSpritePosition { + let base_image = base_image.to_string_lossy().into_owned(); + let pose_image = pose_image.to_string_lossy().into_owned(); + ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + base_image: Some(base_image.clone()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some(base_image), + width: 800, + height: 600, + }), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + pivot: Some(SpriteAnchor { x: 0.25, y: 0.75 }), + image_override: Some(pose_image.clone()), + image_override_metrics: Some(SpriteImageMetrics { + source: pose_image, + width: 320, + height: 240, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + } +} + #[test] fn full_preset_settings_patch_preserves_custom_css_history() { let history = vec![CustomCssHistoryEntry { @@ -61,6 +201,282 @@ fn synthesized_custom_tabs_are_deterministic_without_preset_metadata() { ); } +#[test] +fn reactive_sprite_and_images_survive_preset_export_import_round_trip() { + let root = std::env::temp_dir().join(format!( + "dmnote-reactive-sprite-preset-round-trip-{}", + uuid::Uuid::new_v4() + )); + let source_dir = root.join("source"); + let restored_dir = root.join("restored"); + std::fs::create_dir_all(&source_dir).unwrap(); + let base_image = source_dir.join("base.png"); + let pose_image = source_dir.join("pose.png"); + std::fs::write(&base_image, b"base-image").unwrap(); + std::fs::write(&pose_image, b"pose-image").unwrap(); + + let sprite = pivot_sprite_with_local_images(&base_image, &pose_image); + let sprite_id = sprite.id.clone(); + let pose_id = sprite.poses[0].pose_id.clone(); + let trigger_id = sprite.poses[0].triggers[0].clone(); + let sprites = SpritePositions::from([("4key".to_string(), vec![sprite])]); + + let (_, _, _, _, exported_sprites, embedded) = + crate::commands::preset::save::build_preset_image_payload( + &KeyPositions::new(), + &StatPositions::new(), + &GraphPositions::new(), + &KnobPositions::new(), + &sprites, + ) + .unwrap(); + assert_eq!(embedded.len(), 2); + let exported = &exported_sprites["4key"][0]; + assert_eq!( + exported.reference_natural_size.as_ref().unwrap().source, + exported.base_image + ); + assert_eq!( + exported.poses[0] + .image_override_metrics + .as_ref() + .unwrap() + .source, + exported.poses[0].image_override.as_deref().unwrap() + ); + + let preset_bytes = serde_json::to_vec(&PresetFile { + sprite_positions: Some(exported_sprites), + embedded_local_images: Some(embedded), + ..PresetFile::default() + }) + .unwrap(); + let preset_json = std::str::from_utf8(&preset_bytes).unwrap(); + assert!(!preset_json.contains(base_image.to_string_lossy().as_ref())); + assert!(!preset_json.contains(pose_image.to_string_lossy().as_ref())); + let imported: PresetFile = serde_json::from_slice(&preset_bytes).unwrap(); + let mut imported_sprites = imported.sprite_positions.unwrap(); + + restore_preset_local_images_in_dir( + &restored_dir, + &mut KeyPositions::new(), + &mut StatPositions::new(), + &mut GraphPositions::new(), + &mut KnobPositions::new(), + &mut imported_sprites, + imported.embedded_local_images.as_deref(), + ) + .unwrap(); + + let restored = &imported_sprites["4key"][0]; + assert_eq!(restored.id, sprite_id); + assert_eq!(restored.poses[0].pose_id, pose_id); + assert_eq!(restored.poses[0].triggers, [trigger_id]); + assert_eq!( + restored.poses[0].pivot, + Some(SpriteAnchor { x: 0.25, y: 0.75 }) + ); + let restored_base = Path::new(restored.base_image.as_deref().unwrap()); + let restored_pose = Path::new(restored.poses[0].image_override.as_deref().unwrap()); + assert_eq!( + restored.reference_natural_size.as_ref().unwrap().source, + restored.base_image + ); + assert_eq!( + restored.poses[0] + .image_override_metrics + .as_ref() + .unwrap() + .source, + restored.poses[0].image_override.as_deref().unwrap() + ); + assert_eq!(std::fs::read(restored_base).unwrap(), b"base-image"); + assert_eq!(std::fs::read(restored_pose).unwrap(), b"pose-image"); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn pivot_sprite_sources_survive_tab_preset_image_round_trip() { + let root = std::env::temp_dir().join(format!( + "dmnote-pivot-sprite-tab-preset-round-trip-{}", + uuid::Uuid::new_v4() + )); + let source_dir = root.join("source"); + let restored_dir = root.join("restored"); + std::fs::create_dir_all(&source_dir).unwrap(); + let base_image = source_dir.join("base.png"); + let pose_image = source_dir.join("pose.png"); + std::fs::write(&base_image, b"tab-base-image").unwrap(); + std::fs::write(&pose_image, b"tab-pose-image").unwrap(); + + let sprites = SpritePositions::from([( + "source-tab".to_string(), + vec![pivot_sprite_with_local_images(&base_image, &pose_image)], + )]); + let (_, _, _, _, exported_sprites, embedded) = + crate::commands::preset::save::build_preset_image_payload( + &KeyPositions::new(), + &StatPositions::new(), + &GraphPositions::new(), + &KnobPositions::new(), + &sprites, + ) + .unwrap(); + let preset_bytes = serde_json::to_vec(&PresetFile { + sprite_positions: Some(exported_sprites), + selected_key_type: Some("source-tab".to_string()), + embedded_local_images: Some(embedded), + ..PresetFile::default() + }) + .unwrap(); + let preset_json = std::str::from_utf8(&preset_bytes).unwrap(); + assert!(!preset_json.contains(base_image.to_string_lossy().as_ref())); + assert!(!preset_json.contains(pose_image.to_string_lossy().as_ref())); + + let imported: PresetFile = serde_json::from_slice(&preset_bytes).unwrap(); + let mut imported_sprites = SpritePositions::from([( + "target-tab".to_string(), + imported.sprite_positions.unwrap()["source-tab"].clone(), + )]); + restore_preset_local_images_in_dir( + &restored_dir, + &mut KeyPositions::new(), + &mut StatPositions::new(), + &mut GraphPositions::new(), + &mut KnobPositions::new(), + &mut imported_sprites, + imported.embedded_local_images.as_deref(), + ) + .unwrap(); + + let restored = &imported_sprites["target-tab"][0]; + assert_eq!( + restored.poses[0].pivot, + Some(SpriteAnchor { x: 0.25, y: 0.75 }) + ); + assert_eq!( + restored.reference_natural_size.as_ref().unwrap().source, + restored.base_image + ); + assert_eq!( + restored.poses[0] + .image_override_metrics + .as_ref() + .unwrap() + .source, + restored.poses[0].image_override.as_deref().unwrap() + ); + assert_eq!( + std::fs::read(restored.base_image.as_ref().unwrap()).unwrap(), + b"tab-base-image" + ); + assert_eq!( + std::fs::read(restored.poses[0].image_override.as_ref().unwrap()).unwrap(), + b"tab-pose-image" + ); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn preset_import_round_trips_sprite_activation_and_ignores_removed_match_mode() { + let root = std::env::temp_dir().join(format!( + "dmnote-stale-sprite-strategy-preset-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("preset.json"); + let sprites = SpritePositions::from([( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + )]); + let mut value = serde_json::to_value(PresetFile { + sprite_positions: Some(sprites), + ..PresetFile::default() + }) + .unwrap(); + let sprite = value + .pointer_mut("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object_mut) + .unwrap(); + sprite.insert("activation".to_string(), serde_json::json!("onPress")); + sprite + .get_mut("poses") + .and_then(serde_json::Value::as_array_mut) + .and_then(|poses| poses.first_mut()) + .and_then(serde_json::Value::as_object_mut) + .unwrap() + .insert("matchMode".to_string(), serde_json::json!("exact")); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + + let imported = read_preset_file(&path).unwrap(); + let serialized = serde_json::to_value(imported).unwrap(); + let sprite = serialized + .pointer("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object) + .unwrap(); + + assert_eq!(sprite["activation"], serde_json::json!("onPress")); + assert!(!sprite["poses"][0] + .as_object() + .unwrap() + .contains_key("matchMode")); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn preset_import_defaults_missing_sprite_oneshot_fields() { + let root = std::env::temp_dir().join(format!( + "dmnote-missing-sprite-oneshot-fields-preset-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("preset.json"); + let sprites = SpritePositions::from([( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + )]); + let mut value = serde_json::to_value(PresetFile { + sprite_positions: Some(sprites), + ..PresetFile::default() + }) + .unwrap(); + let sprite = value + .pointer_mut("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object_mut) + .unwrap(); + sprite.remove("activation"); + sprite.remove("pressDurationMs"); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + + let imported = read_preset_file(&path).unwrap(); + let sprite = &imported.sprite_positions.as_ref().unwrap()["4key"][0]; + + assert_eq!( + serde_json::to_value(sprite.activation).unwrap(), + serde_json::json!("whileHeld") + ); + assert_eq!(sprite.press_duration_ms, 300); + let _ = std::fs::remove_dir_all(root); +} + #[test] fn committed_preset_css_paths_exclude_unrelated_store_paths() { let mut committed = AppStoreData { @@ -348,6 +764,39 @@ fn damaged_gradient_preset_reports_element_and_field_with_existing_error_code() let _ = std::fs::remove_dir_all(temp_dir); } +#[test] +fn preset_rejects_invalid_element_rotations_with_path_for_all_position_types() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("preset.json"); + for collection in POSITION_COLLECTION_FIELDS { + for invalid in [ + serde_json::json!(-180.1), + serde_json::json!(180.1), + serde_json::Value::Null, + serde_json::json!("45"), + serde_json::json!(true), + ] { + let mut preset = serde_json::json!({}); + preset[collection] = serde_json::json!({ + "custom mode": [{}, { "rotation": invalid }] + }); + std::fs::write(&path, serde_json::to_vec(&preset).unwrap()).unwrap(); + let error = read_preset_file(&path) + .err() + .expect("invalid rotation must be rejected"); + assert_eq!(error.to_string(), format!( + "invalid-preset: {collection}[\"custom mode\"][1].rotation: must be a finite number between -180 and 180" + )); + } + for rotation in [-180.0, -45.5, 0.0, 180.0] { + let mut preset = serde_json::json!({}); + preset[collection] = + serde_json::json!({ "custom mode": [{}, { "rotation": rotation }] }); + assert_eq!(invalid_position_style_detail(&preset), None); + } + } +} + #[test] fn preset_rejects_out_of_range_image_transform_with_path() { let preset = serde_json::json!({ @@ -1117,6 +1566,27 @@ fn tab_preset_keeps_all_local_faces_for_a_new_family() { .is_none()); } +#[test] +fn tab_preset_collection_without_source_tab_entry_clears_target_tab() { + let sprites = SpritePositions::from([( + "other-tab".to_string(), + vec![ReactiveSpritePosition::default()], + )]); + + // 컬렉션은 있는데 원본 탭 항목이 없다 - 빈 탭이라 대상 탭도 비운다 + let selected = select_tab_preset_elements(Some(&sprites), "4key", "target"); + assert_eq!(selected.get("target").map(Vec::len), Some(0)); + + // 원본 탭 항목이 있으면 대상 탭 키로 옮긴다 + let selected = select_tab_preset_elements(Some(&sprites), "other-tab", "target"); + assert_eq!(selected.get("target").map(Vec::len), Some(1)); + assert!(!selected.contains_key("other-tab")); + + // 컬렉션 자체가 없는 옛 프리셋은 대상 탭을 건드리지 않는다 + let selected = select_tab_preset_elements::(None, "4key", "target"); + assert!(selected.is_empty()); +} + #[test] fn tab_preset_key_pair_merge_preserves_other_modes_and_latest_target_positions() { let mut store = AppStoreData { @@ -1205,9 +1675,9 @@ fn old_tab_preset_rekeys_only_written_collections_on_every_application() { let untouched_id = store.key_positions["untouched"][0].id.clone(); let original_ids = target_preset_ids(&store); - rekey_tab_preset_elements(&mut store, "target", true, true, true, false); + rekey_tab_preset_elements(&mut store, "target", true, true, true, false, false); let first_ids = target_preset_ids(&store); - rekey_tab_preset_elements(&mut store, "target", true, true, true, false); + rekey_tab_preset_elements(&mut store, "target", true, true, true, false, false); let second_ids = target_preset_ids(&store); assert!(original_ids[..3] @@ -1240,7 +1710,7 @@ fn tab_preset_without_key_positions_keeps_existing_key_ids() { .unwrap() .push(KeyPosition::default()); - rekey_tab_preset_elements(&mut store, "target", false, false, false, false); + rekey_tab_preset_elements(&mut store, "target", false, false, false, false, false); // 값이 그대로인 기존 키는 신원을 지키고, 덧붙은 슬롯만 새 id를 받는다 assert_eq!(store.key_positions["target"][0].id, original_ids[0]); diff --git a/src-tauri/src/commands/preset/load/validation.rs b/src-tauri/src/commands/preset/load/validation.rs index 783fbbd4b..6fd5eeba5 100644 --- a/src-tauri/src/commands/preset/load/validation.rs +++ b/src-tauri/src/commands/preset/load/validation.rs @@ -1,9 +1,11 @@ use super::*; +use crate::models::{ELEMENT_ROTATION_MAX, ELEMENT_ROTATION_MIN}; pub(super) fn read_preset_file(path: &Path) -> CmdResult { let content = fs::read_to_string(path)?; let mut value: serde_json::Value = serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset"))?; + migrate_legacy_sprite_wire(&mut value); scrub_removed_text_outline_fields(&mut value); if let Some(detail) = invalid_position_style_detail(&value) { return Err(CommandError::msg(format!("invalid-preset: {detail}"))); @@ -61,6 +63,16 @@ pub(super) fn invalid_position_style_detail(preset: &serde_json::Value) -> Optio let Some(entry) = entry.as_object() else { continue; }; + if let Some(value) = entry.get("rotation") { + if !value.as_f64().is_some_and(|rotation| { + rotation.is_finite() + && (ELEMENT_ROTATION_MIN..=ELEMENT_ROTATION_MAX).contains(&rotation) + }) { + return Some(format!( + "{collection_name}[{mode:?}][{index}].rotation: must be a finite number between {ELEMENT_ROTATION_MIN} and {ELEMENT_ROTATION_MAX}" + )); + } + } for field in ELEMENT_FIELDS { let error = match field { "noteBorderGradient" => invalid_note_gradient_error( diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index 9fcd1181c..9a2e0deaa 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -9,7 +9,8 @@ use serde::{Deserialize, Serialize}; pub(crate) use crate::models::BUILTIN_TAB_IDS; use crate::models::{ CustomCss, CustomJs, CustomTab, FontSettings, GraphPositions, KeyMappings, KeyPositions, - KnobPositions, LayerGroups, NoteSettings, StatPositions, TabCssOverrides, TabNoteOverrides, + KnobPositions, LayerGroups, NoteSettings, SpritePositions, StatPositions, TabCssOverrides, + TabNoteOverrides, }; use crate::state::assets::local_asset_path::{file_url_to_path, FileUrlPath}; @@ -29,6 +30,7 @@ pub struct PresetSnapshot { pub stat_positions: StatPositions, pub graph_positions: GraphPositions, pub knob_positions: KnobPositions, + pub sprite_positions: SpritePositions, pub custom_tabs: Vec, pub tab_order: Vec, pub bar_count: u8, @@ -45,6 +47,8 @@ pub(crate) struct PresetFile { pub graph_positions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub knob_positions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sprite_positions: Option, pub background_color: Option, pub note_settings: Option, pub note_effect: Option, diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs index bfbd4ef1f..c7f74c49c 100644 --- a/src-tauri/src/commands/preset/save.rs +++ b/src-tauri/src/commands/preset/save.rs @@ -11,8 +11,9 @@ use crate::{ commands::dialog::parented_file_dialog, errors::{CmdResult, CommandError}, models::{ - FontSettings, FontType, GraphPositions, KeyMappings, KeyPositions, KnobPositions, - LayerGroups, StatPositions, TabCssOverrides, TabNoteOverrides, + rewrite_coupled_sprite_image_reference, FontSettings, FontType, GraphPositions, + KeyMappings, KeyPositions, KnobPositions, LayerGroups, SpritePositions, StatPositions, + TabCssOverrides, TabNoteOverrides, }, state::{atomic_file::atomic_replace, AppState}, }; @@ -58,13 +59,20 @@ fn preset_save_from_path(app: AppHandle, path: PathBuf) -> CmdResult CmdResult CmdResult CmdResult CmdResult CmdResult<( KeyPositions, StatPositions, GraphPositions, KnobPositions, + SpritePositions, Vec, )> { let mut exported_key_positions = key_positions.clone(); let mut exported_stat_positions = stat_positions.clone(); let mut exported_graph_positions = graph_positions.clone(); let mut exported_knob_positions = knob_positions.clone(); + let mut exported_sprite_positions = sprite_positions.clone(); let mut embedded_local_images = Vec::new(); let mut path_to_image_id: HashMap = HashMap::new(); @@ -486,11 +505,33 @@ fn build_preset_image_payload( } } + for sprites in exported_sprite_positions.values_mut() { + for sprite in sprites { + rewrite_coupled_sprite_image_reference(sprite, |image_ref| { + rewrite_position_image_reference( + image_ref, + &mut embedded_local_images, + &mut path_to_image_id, + ) + })?; + for pose in &mut sprite.poses { + rewrite_coupled_sprite_image_reference(pose, |image_ref| { + rewrite_position_image_reference( + image_ref, + &mut embedded_local_images, + &mut path_to_image_id, + ) + })?; + } + } + } + Ok(( exported_key_positions, exported_stat_positions, exported_graph_positions, exported_knob_positions, + exported_sprite_positions, embedded_local_images, )) } @@ -705,11 +746,12 @@ mod tests { }; let key_positions = KeyPositions::from([("4key".to_string(), vec![position])]); - let (exported, _, _, _, embedded) = build_preset_image_payload( + let (exported, _, _, _, _, embedded) = build_preset_image_payload( &key_positions, &StatPositions::new(), &GraphPositions::new(), &KnobPositions::new(), + &SpritePositions::new(), ) .unwrap(); diff --git a/src-tauri/src/commands/preset/tests.rs b/src-tauri/src/commands/preset/tests.rs index 74918de72..232e1fe0f 100644 --- a/src-tauri/src/commands/preset/tests.rs +++ b/src-tauri/src/commands/preset/tests.rs @@ -1,11 +1,128 @@ -use super::PresetFile; +use super::{PresetFile, PresetSnapshot}; // file URL 경로 테스트가 유닉스 전용이라 Windows에선 미사용 경고 방지 #[cfg(not(target_os = "windows"))] use super::local_source_path_from_image_ref; -use crate::models::{KeyCounterColor, KeySlot, NoteColor}; +use crate::models::{ + default_bar_count, KeyCounterColor, KeySlot, NoteColor, ReactiveSpritePosition, SpritePose, + SpritePositions, +}; use serde::{Deserialize, Serialize}; use serde_json::json; +// 스냅샷 wire 키 집합 고정 - 프론트 공개 타입 PresetSnapshot과 같은 계약 +#[test] +fn preset_snapshot_serializes_expected_wire_keys() { + let snapshot = PresetSnapshot { + keys: Default::default(), + positions: Default::default(), + stat_positions: Default::default(), + graph_positions: Default::default(), + knob_positions: Default::default(), + sprite_positions: Default::default(), + custom_tabs: Vec::new(), + tab_order: Vec::new(), + bar_count: default_bar_count(), + selected_key_type: "4key".to_string(), + tab_note_overrides: Default::default(), + }; + let value = serde_json::to_value(&snapshot).expect("snapshot serializes"); + let mut keys: Vec<&str> = value + .as_object() + .expect("snapshot is an object") + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + "barCount", + "customTabs", + "graphPositions", + "keys", + "knobPositions", + "positions", + "selectedKeyType", + "spritePositions", + "statPositions", + "tabNoteOverrides", + "tabOrder", + ] + ); +} + +#[test] +fn preset_sprite_wire_keys_include_oneshot_fields_and_exclude_removed_match_mode() { + let snapshot = PresetSnapshot { + sprite_positions: SpritePositions::from([( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + rotation: 45.5, + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + )]), + keys: Default::default(), + positions: Default::default(), + stat_positions: Default::default(), + graph_positions: Default::default(), + knob_positions: Default::default(), + custom_tabs: Vec::new(), + tab_order: Vec::new(), + bar_count: default_bar_count(), + selected_key_type: "4key".to_string(), + tab_note_overrides: Default::default(), + }; + let value = serde_json::to_value(snapshot).unwrap(); + let sprite = value["spritePositions"]["4key"][0].as_object().unwrap(); + let mut sprite_keys = sprite.keys().map(String::as_str).collect::>(); + sprite_keys.sort_unstable(); + assert_eq!( + sprite_keys, + [ + "activation", + "baseImage", + "className", + "dx", + "dy", + "height", + "hidden", + "id", + "idleTransform", + "pivot", + "poses", + "pressDurationMs", + "referenceNaturalSize", + "rotation", + "transitionEasing", + "transitionMs", + "useInlineStyles", + "width", + "zIndex", + ] + ); + assert_eq!(sprite["rotation"], 45.5); + let pose = sprite["poses"][0].as_object().unwrap(); + let mut pose_keys = pose.keys().map(String::as_str).collect::>(); + pose_keys.sort_unstable(); + assert_eq!( + pose_keys, + [ + "imageOverride", + "imageOverrideMetrics", + "pivot", + "poseId", + "transform", + "triggers" + ] + ); +} + #[test] fn preset_round_trip_preserves_layer_groups_and_tab_css_overrides() { let value = json!({ diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 6861832a2..4a149b3f0 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -283,6 +283,7 @@ mod tests { "TOO_MANY_LAYER_GROUPS", "TOO_MANY_MODES", "TOO_MANY_PLUGIN_INSTANCES", + "TOO_MANY_SPRITE_POSE_TRIGGERS", "TOO_MANY_RENDER_ITEMS", "TOO_MANY_SLOTS_PER_MEMBER", ]; diff --git a/src-tauri/src/models/bootstrap.rs b/src-tauri/src/models/bootstrap.rs index 8d7887dde..ccee5a615 100644 --- a/src-tauri/src/models/bootstrap.rs +++ b/src-tauri/src/models/bootstrap.rs @@ -18,6 +18,7 @@ pub struct BootstrapPayload { pub stat_positions: StatPositions, pub graph_positions: GraphPositions, pub knob_positions: KnobPositions, + pub sprite_positions: SpritePositions, pub custom_tabs: Vec, pub tab_order: Vec, pub bar_count: u8, diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs index 5761d15f2..19b1c3ae3 100644 --- a/src-tauri/src/models/editor.rs +++ b/src-tauri/src/models/editor.rs @@ -1,15 +1,18 @@ -use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; +use serde_json::Value; use super::{ AppStoreData, GradientSpec, GradientStop, GraphPosition, GraphPositions, GraphType, ImageFit, ImageMode, KeyCounterAlign, KeyCounterAlignMode, KeyCounterPlacement, KeyCounters, KeyMappings, KeyPosition, KeyPositions, KeySlot, KnobPosition, KnobPositions, LayerGroups, NoteAlignment, - SlotMatch, StatPosition, StatPositions, StatType, + SlotMatch, SpritePositions, StatPosition, StatPositions, StatType, }; pub const EDITOR_SCHEMA_VERSION: u16 = 1; pub const EDITOR_COMMIT_SCHEMA_VERSION_V2: u16 = 2; -pub const EDITOR_OPS_VERSION: u16 = 2; +pub const EDITOR_OPS_VERSION: u16 = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -19,6 +22,7 @@ pub enum EditorField { StatPositions, GraphPositions, KnobPositions, + SpritePositions, LayerGroups, } @@ -31,6 +35,8 @@ pub struct EditorDocumentV1 { pub stat_positions: StatPositions, pub graph_positions: GraphPositions, pub knob_positions: KnobPositions, + #[serde(default)] + pub sprite_positions: SpritePositions, pub layer_groups: LayerGroups, } @@ -43,6 +49,7 @@ impl EditorDocumentV1 { stat_positions: store.stat_positions.clone(), graph_positions: store.graph_positions.clone(), knob_positions: store.knob_positions.clone(), + sprite_positions: store.sprite_positions.clone(), layer_groups: store.layer_groups.clone(), } } @@ -53,6 +60,7 @@ impl EditorDocumentV1 { store.stat_positions = self.stat_positions.clone(); store.graph_positions = self.graph_positions.clone(); store.knob_positions = self.knob_positions.clone(); + store.sprite_positions = self.sprite_positions.clone(); store.layer_groups = self.layer_groups.clone(); } @@ -72,6 +80,9 @@ impl EditorDocumentV1 { if let Some(value) = patch.knob_positions.as_ref() { self.knob_positions = value.clone(); } + if let Some(value) = patch.sprite_positions.as_ref() { + self.sprite_positions = value.clone(); + } if let Some(value) = patch.layer_groups.as_ref() { self.layer_groups = value.clone(); } @@ -94,6 +105,9 @@ impl EditorDocumentV1 { if self.knob_positions != next.knob_positions { fields.push(EditorField::KnobPositions); } + if self.sprite_positions != next.sprite_positions { + fields.push(EditorField::SpritePositions); + } if self.layer_groups != next.layer_groups { fields.push(EditorField::LayerGroups); } @@ -117,6 +131,9 @@ impl EditorDocumentV1 { EditorField::KnobPositions => { patch.knob_positions = Some(self.knob_positions.clone()); } + EditorField::SpritePositions => { + patch.sprite_positions = Some(self.sprite_positions.clone()); + } EditorField::LayerGroups => { patch.layer_groups = Some(self.layer_groups.clone()); } @@ -126,7 +143,116 @@ impl EditorDocumentV1 { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SpritePosePatchPresence { + pivot: bool, + image_override: bool, + image_override_metrics: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SpritePositionPatchPresence { + rotation: bool, + base_image: bool, + reference_natural_size: bool, + activation: bool, + press_duration_ms: bool, + poses: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub(crate) struct SpritePositionsPatchPresence(HashMap>); + +impl SpritePositionsPatchPresence { + fn from_patch_value(value: &Value) -> Option { + let modes = value.get("spritePositions")?.as_object()?; + let presence = modes + .iter() + .map(|(mode, sprites)| { + let sprites = sprites + .as_array() + .map(|sprites| { + sprites + .iter() + .map(|sprite| { + let object = sprite.as_object(); + let poses = object + .and_then(|sprite| sprite.get("poses")) + .and_then(Value::as_array) + .map(|poses| { + poses + .iter() + .map(|pose| { + let pose = pose.as_object(); + SpritePosePatchPresence { + pivot: pose.is_some_and(|pose| { + pose.contains_key("pivot") + }), + image_override: pose.is_some_and(|pose| { + pose.contains_key("imageOverride") + }), + image_override_metrics: pose.is_some_and( + |pose| { + pose.contains_key( + "imageOverrideMetrics", + ) + }, + ), + } + }) + .collect() + }) + .unwrap_or_default(); + SpritePositionPatchPresence { + rotation: object + .is_some_and(|sprite| sprite.contains_key("rotation")), + base_image: object + .is_some_and(|sprite| sprite.contains_key("baseImage")), + reference_natural_size: object.is_some_and(|sprite| { + sprite.contains_key("referenceNaturalSize") + }), + activation: object + .is_some_and(|sprite| sprite.contains_key("activation")), + press_duration_ms: object.is_some_and(|sprite| { + sprite.contains_key("pressDurationMs") + }), + poses, + } + }) + .collect() + }) + .unwrap_or_default(); + (mode.clone(), sprites) + }) + .collect(); + Some(Self(presence)) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct EditorPatchV1Input { + schema_version: u16, + #[serde(default)] + keys: Option, + #[serde(default)] + key_positions: Option, + #[serde(default)] + stat_positions: Option, + #[serde(default)] + graph_positions: Option, + #[serde(default)] + knob_positions: Option, + #[serde(default)] + sprite_positions: Option, + #[serde(default)] + layer_groups: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorPatchV1 { pub schema_version: u16, @@ -141,7 +267,33 @@ pub struct EditorPatchV1 { #[serde(default, skip_serializing_if = "Option::is_none")] pub knob_positions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub sprite_positions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub layer_groups: Option, + #[serde(skip)] + pub(crate) sprite_positions_presence: Option, +} + +impl<'de> Deserialize<'de> for EditorPatchV1 { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + let sprite_positions_presence = SpritePositionsPatchPresence::from_patch_value(&value); + let input = EditorPatchV1Input::deserialize(value).map_err(D::Error::custom)?; + Ok(Self { + schema_version: input.schema_version, + keys: input.keys, + key_positions: input.key_positions, + stat_positions: input.stat_positions, + graph_positions: input.graph_positions, + knob_positions: input.knob_positions, + sprite_positions: input.sprite_positions, + layer_groups: input.layer_groups, + sprite_positions_presence, + }) + } } impl Default for EditorPatchV1 { @@ -153,7 +305,9 @@ impl Default for EditorPatchV1 { stat_positions: None, graph_positions: None, knob_positions: None, + sprite_positions: None, layer_groups: None, + sprite_positions_presence: None, } } } @@ -166,6 +320,7 @@ impl EditorPatchV1 { EditorField::StatPositions => self.stat_positions.is_some(), EditorField::GraphPositions => self.graph_positions.is_some(), EditorField::KnobPositions => self.knob_positions.is_some(), + EditorField::SpritePositions => self.sprite_positions.is_some(), EditorField::LayerGroups => self.layer_groups.is_some(), } } @@ -177,12 +332,100 @@ impl EditorPatchV1 { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ] .into_iter() .filter(|field| self.includes(*field)) .collect() } + + pub(crate) fn merge_omitted_sprite_fields(&mut self, current: &SpritePositions) { + let (Some(candidate), Some(presence)) = ( + self.sprite_positions.as_mut(), + self.sprite_positions_presence.as_ref(), + ) else { + return; + }; + + for (mode, candidate_sprites) in candidate { + let Some(sprite_presence) = presence.0.get(mode) else { + continue; + }; + for (sprite, sprite_presence) in candidate_sprites.iter_mut().zip(sprite_presence) { + if sprite.id.is_empty() { + continue; + } + let Some(current_sprite) = current + .values() + .flatten() + .find(|current_sprite| current_sprite.id == sprite.id) + else { + continue; + }; + + if !sprite_presence.rotation { + sprite.rotation = current_sprite.rotation; + } + if !sprite_presence.base_image { + sprite.base_image = current_sprite.base_image.clone(); + } + if !sprite_presence.reference_natural_size { + sprite.reference_natural_size = if sprite_presence.base_image + && sprite.base_image != current_sprite.base_image + { + if sprite.base_image.is_none() { + current_sprite + .reference_natural_size + .clone() + .map(|mut reference| { + reference.source = None; + reference + }) + } else { + None + } + } else { + current_sprite.reference_natural_size.clone() + }; + } + if !sprite_presence.activation { + sprite.activation = current_sprite.activation; + } + if !sprite_presence.press_duration_ms { + sprite.press_duration_ms = current_sprite.press_duration_ms; + } + + for (pose, pose_presence) in sprite.poses.iter_mut().zip(&sprite_presence.poses) { + if pose.pose_id.is_empty() { + continue; + } + let Some(current_pose) = current_sprite + .poses + .iter() + .find(|current_pose| current_pose.pose_id == pose.pose_id) + else { + continue; + }; + if !pose_presence.pivot { + pose.pivot = current_pose.pivot; + } + if !pose_presence.image_override { + pose.image_override = current_pose.image_override.clone(); + } + if !pose_presence.image_override_metrics { + pose.image_override_metrics = if pose_presence.image_override + && pose.image_override != current_pose.image_override + { + None + } else { + current_pose.image_override_metrics.clone() + }; + } + } + } + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -192,6 +435,7 @@ pub enum EditorElementTypeV1 { Stat, Graph, Knob, + Sprite, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -293,6 +537,7 @@ pub enum EditorElementPropertyPatchV1 { NoteAutoYCorrection(bool), NoteAlignment(NoteAlignment), NoteBorderSide(EditorNoteBorderSideV1), + Rotation(f64), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -523,6 +768,10 @@ pub enum EditorOpV1 { id: String, bounds: EditorBoundsV1, }, + ResizeSprite { + id: String, + bounds: EditorBoundsV1, + }, DeleteElement { #[serde(rename = "elementType")] element_type: EditorElementTypeV1, @@ -578,6 +827,7 @@ impl EditorOpV1 { .iter() .any(|element| matches!(element, EditorFrozenElementV1::Key { .. })), Self::SetBounds { .. } + | Self::ResizeSprite { .. } | Self::PatchElement { .. } | Self::ReorderElements { .. } | Self::SetElementGroups { .. } @@ -588,6 +838,7 @@ impl EditorOpV1 { pub fn target_id(&self) -> Option<&str> { match self { Self::SetBounds { id, .. } + | Self::ResizeSprite { id, .. } | Self::DeleteElement { id, .. } | Self::PatchElement { id, .. } | Self::SetKeySlot { id, .. } => Some(id), @@ -638,6 +889,9 @@ pub enum EditorFrozenElementV1 { Knob { position: KnobPosition, }, + Sprite { + position: super::ReactiveSpritePosition, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -674,15 +928,17 @@ impl EditorFrozenElementV1 { Self::Stat { position } => &position.position.id, Self::Graph { position } => &position.position.id, Self::Knob { position } => &position.position.id, + Self::Sprite { position } => &position.id, } } - pub(crate) fn position_mut(&mut self) -> &mut KeyPosition { + pub(crate) fn key_position_mut(&mut self) -> Option<&mut KeyPosition> { match self { - Self::Key { position, .. } => position, - Self::Stat { position } => &mut position.position, - Self::Graph { position } => &mut position.position, - Self::Knob { position } => &mut position.position, + Self::Key { position, .. } => Some(position), + Self::Stat { position } => Some(&mut position.position), + Self::Graph { position } => Some(&mut position.position), + Self::Knob { position } => Some(&mut position.position), + Self::Sprite { .. } => None, } } } diff --git a/src-tauri/src/models/editor/tests.rs b/src-tauri/src/models/editor/tests.rs index 76f972d7e..b4b0a9f90 100644 --- a/src-tauri/src/models/editor/tests.rs +++ b/src-tauri/src/models/editor/tests.rs @@ -1,9 +1,395 @@ use super::*; +use crate::models::{ + ReactiveSpritePosition, SpriteActivation, SpriteAnchor, SpriteImageMetrics, SpritePose, + SpriteReferenceNaturalSize, +}; // TS canonical 배열과 공유하는 property 태그 fixture const PROPERTY_TAG_FIXTURE: &str = include_str!("../../../../tests/fixtures/editor-property-tags.json"); +fn sprite_with_pose() -> ReactiveSpritePosition { + ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + } +} + +fn insert_sprite_activation_and_removed_match_mode(value: &mut serde_json::Value, pointer: &str) { + let sprite = value + .pointer_mut(pointer) + .and_then(serde_json::Value::as_object_mut) + .expect("sprite object exists"); + sprite.insert("activation".to_string(), serde_json::json!("onPress")); + sprite + .get_mut("poses") + .and_then(serde_json::Value::as_array_mut) + .and_then(|poses| poses.first_mut()) + .expect("pose exists") + .as_object_mut() + .expect("pose object exists") + .insert("matchMode".to_string(), serde_json::json!("exact")); +} + +fn assert_sprite_activation_round_trips_and_match_mode_is_omitted( + value: &serde_json::Value, + pointer: &str, +) { + let sprite = value + .pointer(pointer) + .and_then(serde_json::Value::as_object) + .expect("sprite object exists"); + assert_eq!(sprite["activation"], serde_json::json!("onPress")); + assert!(!sprite["poses"][0] + .as_object() + .expect("pose object exists") + .contains_key("matchMode")); +} + +#[test] +fn v1_editor_patch_round_trips_sprite_activation_and_ignores_removed_match_mode() { + let mut sprite_positions = SpritePositions::new(); + sprite_positions.insert("4key".to_string(), vec![sprite_with_pose()]); + let mut value = serde_json::to_value(EditorPatchV1 { + sprite_positions: Some(sprite_positions), + ..EditorPatchV1::default() + }) + .unwrap(); + insert_sprite_activation_and_removed_match_mode(&mut value, "/spritePositions/4key/0"); + + let restored: EditorPatchV1 = serde_json::from_value(value).unwrap(); + let serialized = serde_json::to_value(restored).unwrap(); + + assert_sprite_activation_round_trips_and_match_mode_is_omitted( + &serialized, + "/spritePositions/4key/0", + ); +} + +#[test] +fn changes_presence_merge_preserves_omitted_existing_sprite_fields() { + for schema_version in [EDITOR_SCHEMA_VERSION, EDITOR_COMMIT_SCHEMA_VERSION_V2] { + let sprite_id = uuid::Uuid::new_v4().to_string(); + let pose_id = uuid::Uuid::new_v4().to_string(); + let current_sprite = ReactiveSpritePosition { + id: sprite_id, + rotation: -45.5, + base_image: Some("/images/base.png".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some("/images/base.png".to_string()), + width: 800, + height: 600, + }), + activation: SpriteActivation::OnPress, + press_duration_ms: 900, + poses: vec![SpritePose { + pose_id, + pivot: Some(SpriteAnchor { x: 0.2, y: 0.8 }), + image_override: Some("/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/pose.png".to_string(), + width: 320, + height: 240, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let mut current = SpritePositions::new(); + current.insert("4key".to_string(), vec![current_sprite.clone()]); + let mut candidate = current_sprite; + candidate.transition_easing = "linear".to_string(); + let mut raw = serde_json::to_value(EditorPatchV1 { + schema_version, + sprite_positions: Some(HashMap::from([("4key".to_string(), vec![candidate])])), + ..EditorPatchV1::default() + }) + .unwrap(); + let sprite = raw + .pointer_mut("/spritePositions/4key/0") + .and_then(Value::as_object_mut) + .unwrap(); + for field in [ + "rotation", + "baseImage", + "referenceNaturalSize", + "activation", + "pressDurationMs", + ] { + sprite.remove(field); + } + let pose = sprite["poses"][0].as_object_mut().unwrap(); + pose.remove("pivot"); + pose.remove("imageOverride"); + pose.remove("imageOverrideMetrics"); + + let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap(); + patch.merge_omitted_sprite_fields(¤t); + let merged = &patch.sprite_positions.as_ref().unwrap()["4key"][0]; + let expected = ¤t["4key"][0]; + assert_eq!(merged.rotation, expected.rotation); + assert_eq!(merged.base_image, expected.base_image); + assert_eq!( + merged.reference_natural_size, + expected.reference_natural_size + ); + assert_eq!(merged.activation, expected.activation); + assert_eq!(merged.press_duration_ms, expected.press_duration_ms); + assert_eq!(merged.poses[0].pivot, expected.poses[0].pivot); + assert_eq!( + merged.poses[0].image_override, + expected.poses[0].image_override + ); + assert_eq!( + merged.poses[0].image_override_metrics, + expected.poses[0].image_override_metrics + ); + assert_eq!(merged.transition_easing, "linear"); + } +} + +#[test] +fn changes_presence_merge_keeps_new_defaults_and_applies_nullable_nulls() { + let mut existing = sprite_with_pose(); + existing.rotation = 45.5; + existing.reference_natural_size = Some(SpriteReferenceNaturalSize { + source: Some("/images/base.png".to_string()), + width: 800, + height: 600, + }); + existing.base_image = Some("/images/base.png".to_string()); + existing.poses[0].image_override = Some("/images/pose.png".to_string()); + existing.poses[0].pivot = Some(SpriteAnchor { x: 0.2, y: 0.8 }); + existing.poses[0].image_override_metrics = Some(SpriteImageMetrics { + source: "/images/pose.png".to_string(), + width: 320, + height: 240, + }); + let mut current = SpritePositions::new(); + current.insert("4key".to_string(), vec![existing.clone()]); + + let mut raw = serde_json::to_value(EditorPatchV1 { + sprite_positions: Some(HashMap::from([( + "4key".to_string(), + vec![existing.clone(), sprite_with_pose()], + )])), + ..EditorPatchV1::default() + }) + .unwrap(); + let sprites = raw["spritePositions"]["4key"].as_array_mut().unwrap(); + let existing_raw = sprites[0].as_object_mut().unwrap(); + existing_raw.insert("rotation".to_string(), serde_json::json!(0.0)); + existing_raw.insert("referenceNaturalSize".to_string(), Value::Null); + let existing_pose = existing_raw["poses"][0].as_object_mut().unwrap(); + existing_pose.insert("pivot".to_string(), Value::Null); + existing_pose.insert("imageOverrideMetrics".to_string(), Value::Null); + let new_raw = sprites[1].as_object_mut().unwrap(); + for field in [ + "rotation", + "referenceNaturalSize", + "activation", + "pressDurationMs", + ] { + new_raw.remove(field); + } + let new_pose = new_raw["poses"][0].as_object_mut().unwrap(); + new_pose.remove("imageOverrideMetrics"); + + let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap(); + patch.merge_omitted_sprite_fields(¤t); + let sprites = &patch.sprite_positions.as_ref().unwrap()["4key"]; + assert_eq!(sprites[0].rotation, 0.0); + assert_eq!(sprites[1].rotation, 0.0); + assert_eq!(sprites[0].reference_natural_size, None); + assert_eq!(sprites[0].poses[0].pivot, None); + assert_eq!(sprites[0].poses[0].image_override_metrics, None); + assert_eq!(sprites[1].reference_natural_size, None); + assert_eq!(sprites[1].activation, SpriteActivation::WhileHeld); + assert_eq!(sprites[1].press_duration_ms, 300); +} + +#[test] +fn changes_presence_clears_omitted_metrics_when_image_paths_change() { + for schema_version in [EDITOR_SCHEMA_VERSION, EDITOR_COMMIT_SCHEMA_VERSION_V2] { + let mut existing = sprite_with_pose(); + existing.base_image = Some("/images/base-a.png".to_string()); + existing.reference_natural_size = Some(SpriteReferenceNaturalSize { + source: Some("/images/base-a.png".to_string()), + width: 800, + height: 600, + }); + existing.poses[0].image_override = Some("/images/pose-a.png".to_string()); + existing.poses[0].image_override_metrics = Some(SpriteImageMetrics { + source: "/images/pose-a.png".to_string(), + width: 320, + height: 240, + }); + let current = HashMap::from([("4key".to_string(), vec![existing.clone()])]); + + let mut changed = existing; + changed.base_image = Some("/images/base-b.png".to_string()); + changed.poses[0].image_override = Some("/images/pose-b.png".to_string()); + let mut raw = serde_json::to_value(EditorPatchV1 { + schema_version, + sprite_positions: Some(HashMap::from([("4key".to_string(), vec![changed])])), + ..EditorPatchV1::default() + }) + .unwrap(); + raw["spritePositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("referenceNaturalSize"); + raw["spritePositions"]["4key"][0]["poses"][0] + .as_object_mut() + .unwrap() + .remove("imageOverrideMetrics"); + + let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap(); + patch.merge_omitted_sprite_fields(¤t); + let sprite = &patch.sprite_positions.unwrap()["4key"][0]; + assert_eq!(sprite.reference_natural_size, None); + assert_eq!(sprite.poses[0].image_override_metrics, None); + } +} + +#[test] +fn changes_presence_clears_omitted_reference_when_base_image_becomes_whitespace() { + for schema_version in [EDITOR_SCHEMA_VERSION, EDITOR_COMMIT_SCHEMA_VERSION_V2] { + let mut existing = sprite_with_pose(); + existing.base_image = Some("/images/base.png".to_string()); + existing.reference_natural_size = Some(SpriteReferenceNaturalSize { + source: Some("/images/base.png".to_string()), + width: 100, + height: 100, + }); + let current = HashMap::from([("4key".to_string(), vec![existing.clone()])]); + existing.base_image = Some(" ".to_string()); + let mut raw = serde_json::to_value(EditorPatchV1 { + schema_version, + sprite_positions: Some(HashMap::from([("4key".to_string(), vec![existing])])), + ..EditorPatchV1::default() + }) + .unwrap(); + raw["spritePositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("referenceNaturalSize"); + + let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap(); + patch.merge_omitted_sprite_fields(¤t); + assert_eq!( + patch.sprite_positions.unwrap()["4key"][0].reference_natural_size, + None + ); + } +} + +#[test] +fn changes_presence_keeps_size_with_null_source_when_base_image_is_cleared() { + for schema_version in [EDITOR_SCHEMA_VERSION, EDITOR_COMMIT_SCHEMA_VERSION_V2] { + let mut existing = sprite_with_pose(); + existing.base_image = Some("/images/base.png".to_string()); + existing.reference_natural_size = Some(SpriteReferenceNaturalSize { + source: Some("/images/base.png".to_string()), + width: 800, + height: 600, + }); + let current = HashMap::from([("4key".to_string(), vec![existing.clone()])]); + existing.base_image = None; + let mut raw = serde_json::to_value(EditorPatchV1 { + schema_version, + sprite_positions: Some(HashMap::from([("4key".to_string(), vec![existing])])), + ..EditorPatchV1::default() + }) + .unwrap(); + raw["spritePositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("referenceNaturalSize"); + + let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap(); + patch.merge_omitted_sprite_fields(¤t); + let reference = patch.sprite_positions.unwrap()["4key"][0] + .reference_natural_size + .clone() + .unwrap(); + assert_eq!(reference.source, None); + assert_eq!((reference.width, reference.height), (800, 600)); + } +} + +#[test] +fn changes_presence_rejects_non_nullable_nulls() { + let sprite = sprite_with_pose(); + let raw = serde_json::to_value(EditorPatchV1 { + sprite_positions: Some(HashMap::from([("4key".to_string(), vec![sprite])])), + ..EditorPatchV1::default() + }) + .unwrap(); + + for pointer in [ + "/spritePositions/4key/0/activation", + "/spritePositions/4key/0/pressDurationMs", + ] { + let mut invalid = raw.clone(); + *invalid.pointer_mut(pointer).unwrap() = Value::Null; + assert!(serde_json::from_value::(invalid).is_err()); + } +} + +#[test] +fn frozen_sprite_insert_round_trips_activation_and_ignores_removed_match_mode() { + let mut value = serde_json::to_value(EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![EditorFrozenElementV1::Sprite { + position: sprite_with_pose(), + }], + groups: Vec::new(), + z_updates: Vec::new(), + }) + .unwrap(); + insert_sprite_activation_and_removed_match_mode(&mut value, "/elements/0/position"); + + let restored: EditorOpV1 = serde_json::from_value(value).unwrap(); + let serialized = serde_json::to_value(restored).unwrap(); + + assert_sprite_activation_round_trips_and_match_mode_is_omitted( + &serialized, + "/elements/0/position", + ); +} + +#[test] +fn v1_editor_patch_defaults_missing_sprite_oneshot_fields() { + let mut sprite_positions = SpritePositions::new(); + sprite_positions.insert("4key".to_string(), vec![sprite_with_pose()]); + let mut value = serde_json::to_value(EditorPatchV1 { + sprite_positions: Some(sprite_positions), + ..EditorPatchV1::default() + }) + .unwrap(); + let sprite = value + .pointer_mut("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object_mut) + .unwrap(); + sprite.remove("activation"); + sprite.remove("pressDurationMs"); + let restored: EditorPatchV1 = serde_json::from_value(value).unwrap(); + let serialized = serde_json::to_value(restored).unwrap(); + let sprite = serialized + .pointer("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object) + .unwrap(); + + assert_eq!(sprite["activation"], serde_json::json!("whileHeld")); + assert_eq!(sprite["pressDurationMs"], serde_json::json!(300)); +} + fn paint(color: &str) -> EditorPaintDescriptorV1 { EditorPaintDescriptorV1 { color: color.to_string(), @@ -11,7 +397,7 @@ fn paint(color: &str) -> EditorPaintDescriptorV1 { } } -// 75개 variant 전수: (patch, property 태그, value wire) 고정 표본 +// 76개 variant 전수: (patch, property 태그, value wire) 고정 표본 fn property_patch_samples() -> Vec<( EditorElementPropertyPatchV1, &'static str, @@ -275,13 +661,14 @@ fn property_patch_samples() -> Vec<( "noteBorderSide", json!("vertical"), ), + (P::Rotation(-45.5), "rotation", json!(-45.5)), ] } #[test] fn property_patch_wire_pins_all_tag_value_pairs_and_roundtrips() { let samples = property_patch_samples(); - assert_eq!(samples.len(), 75); + assert_eq!(samples.len(), 76); for (patch, tag, value) in samples { let wire = serde_json::to_value(&patch).unwrap(); assert_eq!( @@ -611,4 +998,16 @@ fn editor_request_detects_every_direct_key_mapping_mutation() { }]), ) .may_change_keys()); + let sprite_id = uuid::Uuid::new_v4().to_string(); + let resize = EditorOpV1::ResizeSprite { + id: sprite_id.clone(), + bounds: EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 200.0, + height: 100.0, + }, + }; + assert_eq!(resize.target_id(), Some(sprite_id.as_str())); + assert!(!request(None, Some(vec![resize])).may_change_keys()); } diff --git a/src-tauri/src/models/element_positions.rs b/src-tauri/src/models/element_positions.rs index cae2840af..51836b0c7 100644 --- a/src-tauri/src/models/element_positions.rs +++ b/src-tauri/src/models/element_positions.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use super::{ - default_key_height, default_key_note_color, default_key_note_opacity, + default_element_rotation, default_key_height, default_key_note_color, default_key_note_opacity, default_note_auto_y_correction, default_note_border_opacity, default_note_effect_enabled, default_note_glow_enabled, default_note_glow_opacity, default_note_glow_size, GradientSpec, ImageFit, KeyCounterSettings, NoteColor, @@ -20,6 +20,7 @@ pub type KeyPositions = HashMap>; pub type StatPositions = HashMap>; pub type GraphPositions = HashMap>; pub type KnobPositions = HashMap>; +pub type SpritePositions = HashMap>; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "kebab-case")] @@ -44,6 +45,9 @@ pub const IMAGE_TRANSFORM_ROTATION_MAX: f64 = 180.0; pub const IMAGE_TRANSFORM_SCALE_MIN: f64 = 0.1; pub const IMAGE_TRANSFORM_SCALE_MAX: f64 = 10.0; +pub const ELEMENT_ROTATION_MIN: f64 = -180.0; +pub const ELEMENT_ROTATION_MAX: f64 = 180.0; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ImageMode { @@ -91,6 +95,8 @@ pub struct KeyPosition { pub width: f64, #[serde(default = "default_key_height")] pub height: f64, + #[serde(default = "default_element_rotation")] + pub rotation: f64, /// 레이어 표시 여부 (true면 숨김) #[serde(default)] pub hidden: bool, @@ -267,6 +273,7 @@ impl Default for KeyPosition { dy: 0.0, width: 60.0, height: default_key_height(), + rotation: default_element_rotation(), hidden: false, active_image: None, inactive_image: None, @@ -411,6 +418,269 @@ pub struct KnobPosition { pub position: KeyPosition, } +pub const SPRITE_TRANSFORM_OFFSET_MIN: f64 = -2_000.0; +pub const SPRITE_TRANSFORM_OFFSET_MAX: f64 = 2_000.0; +pub const SPRITE_TRANSFORM_ROTATION_MIN: f64 = -180.0; +pub const SPRITE_TRANSFORM_ROTATION_MAX: f64 = 180.0; +pub const SPRITE_TRANSFORM_SCALE_MIN: f64 = 0.1; +pub const SPRITE_TRANSFORM_SCALE_MAX: f64 = 10.0; +pub const SPRITE_TRANSITION_MS_MAX: u32 = 1_000; +pub const SPRITE_PRESS_DURATION_MS_MIN: u32 = 1; +pub const SPRITE_PRESS_DURATION_MS_MAX: u32 = 5_000; +pub const SPRITE_IMAGE_DIMENSION_MIN: u32 = 1; +pub const SPRITE_IMAGE_DIMENSION_MAX: u32 = 32_768; +pub const MAX_SPRITE_POSES: usize = 64; +pub const MAX_SPRITE_POSE_TRIGGERS: usize = 512; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SpriteAnchor { + pub x: f64, + pub y: f64, +} + +impl Default for SpriteAnchor { + fn default() -> Self { + Self { x: 0.5, y: 0.5 } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SpriteTransform { + pub x: f64, + pub y: f64, + pub rotation: f64, + pub scale: f64, +} + +impl Default for SpriteTransform { + fn default() -> Self { + Self { + x: 0.0, + y: 0.0, + rotation: 0.0, + scale: 1.0, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SpriteImageMetrics { + pub source: String, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SpriteReferenceNaturalSize { + pub source: Option, + pub width: u32, + pub height: u32, +} + +pub(crate) fn is_renderable_image_ref(value: Option<&str>) -> bool { + value.is_some_and(|value| !value.trim().is_empty()) +} + +pub(crate) trait CoupledSpriteImageReference { + fn image_reference(&self) -> &Option; + fn image_reference_mut(&mut self) -> &mut Option; + fn coupled_source(&self) -> Option<&str>; + fn set_coupled_source(&mut self, source: Option<&str>); +} + +pub(crate) fn rewrite_coupled_sprite_image_reference( + target: &mut T, + rewrite: impl FnOnce(&mut Option) -> R, +) -> R +where + T: CoupledSpriteImageReference, +{ + let original = target.image_reference().clone(); + let source_was_coupled = original.as_deref().is_some_and(|image_ref| { + is_renderable_image_ref(Some(image_ref)) && target.coupled_source() == Some(image_ref) + }); + let result = rewrite(target.image_reference_mut()); + + if source_was_coupled { + let rewritten = target + .image_reference() + .as_deref() + .filter(|image_ref| is_renderable_image_ref(Some(image_ref))) + .map(str::to_owned); + target.set_coupled_source(rewritten.as_deref()); + } + + result +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SpriteActivation { + #[default] + WhileHeld, + OnPress, +} + +pub(crate) fn default_sprite_press_duration_ms() -> u32 { + 300 +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SpritePose { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub pose_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub triggers: Vec, + pub transform: SpriteTransform, + #[serde(default)] + pub pivot: Option, + #[serde(default)] + pub image_override: Option, + #[serde(default)] + pub image_override_metrics: Option, +} + +impl SpritePose { + pub fn normalize_triggers(&mut self) -> bool { + let original = self.triggers.clone(); + self.triggers.sort_unstable(); + self.triggers.dedup(); + self.triggers != original + } +} + +impl CoupledSpriteImageReference for SpritePose { + fn image_reference(&self) -> &Option { + &self.image_override + } + + fn image_reference_mut(&mut self) -> &mut Option { + &mut self.image_override + } + + fn coupled_source(&self) -> Option<&str> { + self.image_override_metrics + .as_ref() + .map(|metrics| metrics.source.as_str()) + } + + fn set_coupled_source(&mut self, source: Option<&str>) { + match source { + Some(source) => { + if let Some(metrics) = self.image_override_metrics.as_mut() { + metrics.source = source.to_string(); + } + } + None => self.image_override_metrics = None, + } + } +} + +pub(crate) fn default_sprite_transition_ms() -> u32 { + 0 +} + +pub(crate) fn default_sprite_transition_easing() -> String { + "cubic-bezier(0.4, 0, 0.2, 1)".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ReactiveSpritePosition { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + pub dx: f64, + pub dy: f64, + pub width: f64, + pub height: f64, + #[serde(default = "default_element_rotation")] + pub rotation: f64, + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub z_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_id: Option, + #[serde(default)] + pub class_name: Option, + #[serde(default)] + pub use_inline_styles: Option, + #[serde(default)] + pub base_image: Option, + #[serde(default)] + pub reference_natural_size: Option, + pub pivot: SpriteAnchor, + pub idle_transform: SpriteTransform, + #[serde(default)] + pub poses: Vec, + #[serde(default)] + pub activation: SpriteActivation, + #[serde(default = "default_sprite_press_duration_ms")] + pub press_duration_ms: u32, + #[serde(default = "default_sprite_transition_ms")] + pub transition_ms: u32, + #[serde(default = "default_sprite_transition_easing")] + pub transition_easing: String, +} + +impl Default for ReactiveSpritePosition { + fn default() -> Self { + Self { + id: String::new(), + dx: 0.0, + dy: 0.0, + width: 200.0, + height: 200.0, + rotation: default_element_rotation(), + hidden: false, + z_index: None, + layer_name: None, + group_id: None, + class_name: None, + use_inline_styles: None, + base_image: None, + reference_natural_size: None, + pivot: SpriteAnchor::default(), + idle_transform: SpriteTransform::default(), + poses: Vec::new(), + activation: SpriteActivation::default(), + press_duration_ms: default_sprite_press_duration_ms(), + transition_ms: default_sprite_transition_ms(), + transition_easing: default_sprite_transition_easing(), + } + } +} + +impl CoupledSpriteImageReference for ReactiveSpritePosition { + fn image_reference(&self) -> &Option { + &self.base_image + } + + fn image_reference_mut(&mut self) -> &mut Option { + &mut self.base_image + } + + fn coupled_source(&self) -> Option<&str> { + self.reference_natural_size + .as_ref() + .and_then(|reference| reference.source.as_deref()) + } + + fn set_coupled_source(&mut self, source: Option<&str>) { + if let Some(reference) = self.reference_natural_size.as_mut() { + reference.source = source.map(str::to_owned); + } + } +} + pub(super) fn default_true() -> bool { true } diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 30a27faaf..fc6676050 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -23,13 +23,26 @@ pub use counter::{ use counter::{default_counter_font_size, default_counter_font_weight, default_gap}; pub use editor::*; use element_positions::default_true; +pub(crate) use element_positions::{ + default_sprite_press_duration_ms, default_sprite_transition_ms, is_renderable_image_ref, + rewrite_coupled_sprite_image_reference, +}; +// 테스트 기대값 조립에서만 참조 +#[cfg(test)] +pub(crate) use element_positions::default_sprite_transition_easing; pub use element_positions::{ ElementShadowSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, ImageMode, ImageTransform, KeyPosition, KeyPositions, KnobPosition, KnobPositions, NoteAlignment, - StatPosition, StatPositions, StatType, IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, - IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, - IMAGE_TRANSFORM_SCALE_MIN, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, - SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, + ReactiveSpritePosition, SpriteActivation, SpriteAnchor, SpriteImageMetrics, SpritePose, + SpritePositions, SpriteReferenceNaturalSize, SpriteTransform, StatPosition, StatPositions, + StatType, ELEMENT_ROTATION_MAX, ELEMENT_ROTATION_MIN, IMAGE_TRANSFORM_OFFSET_MAX, + IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, + IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, MAX_SPRITE_POSES, + MAX_SPRITE_POSE_TRIGGERS, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, + SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, SPRITE_IMAGE_DIMENSION_MAX, SPRITE_IMAGE_DIMENSION_MIN, + SPRITE_PRESS_DURATION_MS_MAX, SPRITE_PRESS_DURATION_MS_MIN, SPRITE_TRANSFORM_OFFSET_MAX, + SPRITE_TRANSFORM_OFFSET_MIN, SPRITE_TRANSFORM_ROTATION_MAX, SPRITE_TRANSFORM_ROTATION_MIN, + SPRITE_TRANSFORM_SCALE_MAX, SPRITE_TRANSFORM_SCALE_MIN, SPRITE_TRANSITION_MS_MAX, }; pub use gesture::*; pub use key_slot::{ @@ -37,7 +50,7 @@ pub use key_slot::{ SlotMatch, MAX_SLOT_KEYS, }; use note_settings::{ - default_key_height, default_key_note_color, default_key_note_opacity, + default_element_rotation, default_key_height, default_key_note_color, default_key_note_opacity, default_note_auto_y_correction, default_note_border_opacity, default_note_effect_enabled, default_note_glow_enabled, default_note_glow_opacity, default_note_glow_size, }; @@ -702,6 +715,8 @@ pub struct AppStoreData { #[serde(default)] pub knob_positions: KnobPositions, #[serde(default)] + pub sprite_positions: SpritePositions, + #[serde(default)] pub layer_groups: LayerGroups, #[serde(default)] pub key_counters: KeyCounters, @@ -732,6 +747,7 @@ pub struct AppStoreData { /// 분리 패널 창 존재 여부 (재시작 복원용) #[serde(default)] pub panel_detached: bool, + pub overlay_last_content_left_offset: Option, pub overlay_last_content_top_offset: Option, #[serde(default)] pub overlay_bounds_are_logical: bool, @@ -791,6 +807,7 @@ impl Default for AppStoreData { stat_positions: StatPositions::new(), graph_positions: GraphPositions::new(), knob_positions: KnobPositions::new(), + sprite_positions: SpritePositions::new(), layer_groups: LayerGroups::new(), key_counters: KeyCounters::new(), background_color: "transparent".to_string(), @@ -807,6 +824,7 @@ impl Default for AppStoreData { overlay_bounds: None, panel_bounds: None, panel_detached: false, + overlay_last_content_left_offset: None, overlay_last_content_top_offset: None, overlay_bounds_are_logical: false, key_counter_enabled: false, diff --git a/src-tauri/src/models/note_settings.rs b/src-tauri/src/models/note_settings.rs index 46329f117..ae91ac2a7 100644 --- a/src-tauri/src/models/note_settings.rs +++ b/src-tauri/src/models/note_settings.rs @@ -10,6 +10,9 @@ pub(super) fn default_note_effect_enabled() -> bool { pub(super) fn default_key_height() -> f64 { 60.0 } +pub(super) fn default_element_rotation() -> f64 { + 0.0 +} pub(super) fn default_key_note_color() -> NoteColor { NoteColor::Solid("#FFFFFF".to_string()) } diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs index 73d043d15..568005a6a 100644 --- a/src-tauri/src/models/tests.rs +++ b/src-tauri/src/models/tests.rs @@ -1,10 +1,12 @@ use super::{ compact_canonical_rgba, key_mappings_contain_multi, normalize_key_mappings, normalize_key_slot, - note_border_representative_hex, scrub_removed_text_outline_fields, AppStoreData, FadePosition, - GradientSpec, GraphPosition, GraphStatType, GraphType, ImageTransform, KeyCounterAlign, - KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, - KeyPosition, KeySlot, KnobPosition, NoteAlignment, NoteColor, NoteSettings, SlotMatch, - StatPosition, StatType, MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, + note_border_representative_hex, rewrite_coupled_sprite_image_reference, + scrub_removed_text_outline_fields, AppStoreData, FadePosition, GradientSpec, GraphPosition, + GraphStatType, GraphType, ImageTransform, KeyCounterAlign, KeyCounterAlignMode, + KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, KeyPosition, KeySlot, + KnobPosition, NoteAlignment, NoteColor, NoteSettings, ReactiveSpritePosition, SlotMatch, + SpriteAnchor, SpritePose, SpriteReferenceNaturalSize, SpriteTransform, StatPosition, StatType, + MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, }; use serde::Deserialize; @@ -23,6 +25,128 @@ struct ValidNoteBorderStopColor { representative: String, } +#[test] +fn element_rotation_constants_and_default_match_shared_fixture() { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Range { + min: f64, + max: f64, + } + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Fixture { + rotation: Range, + default: f64, + } + let fixture: Fixture = serde_json::from_str(include_str!( + "../../../tests/fixtures/element-rotation-parity.json" + )) + .unwrap(); + assert_eq!(super::ELEMENT_ROTATION_MIN, fixture.rotation.min); + assert_eq!(super::ELEMENT_ROTATION_MAX, fixture.rotation.max); + assert_eq!(super::default_element_rotation(), fixture.default); + assert_eq!(KeyPosition::default().rotation, fixture.default); + assert_eq!(ReactiveSpritePosition::default().rotation, fixture.default); +} + +#[test] +fn rotated_position_coordinates_survive_exact_json_round_trip() { + fn check( + position: T, + field: &str, + decimal: &str, + ) { + let expected: f64 = decimal.parse().unwrap(); + let mut payload = serde_json::to_value(position).unwrap(); + payload[field] = serde_json::json!(expected); + let wire = serde_json::to_string(&payload).unwrap(); + let assert_exact = |position: &T| { + let encoded = serde_json::to_value(position).unwrap(); + assert_eq!( + encoded[field].as_f64().unwrap().to_bits(), + expected.to_bits() + ); + }; + + let direct: T = serde_json::from_str(&wire).unwrap(); + assert_exact(&direct); + let ipc_payload: serde_json::Value = serde_json::from_str(&wire).unwrap(); + let decoded: T = serde_json::from_value(ipc_payload).unwrap(); + assert_exact(&decoded); + let stored = serde_json::to_string(&decoded).unwrap(); + let restored: T = serde_json::from_str(&stored).unwrap(); + assert_exact(&restored); + } + + check(KeyPosition::default(), "dy", "209.77425768631718"); + check( + ReactiveSpritePosition::default(), + "dx", + "114.55722993194729", + ); +} + +#[test] +fn element_rotation_defaults_and_round_trips_flattened_positions() { + fn check(mut value: serde_json::Value) { + value.as_object_mut().unwrap().remove("rotation"); + let legacy: T = serde_json::from_value(value.clone()).unwrap(); + let serialized = serde_json::to_value(legacy).unwrap(); + assert_eq!(serialized["rotation"], 0.0); + assert!(serialized.get("position").is_none()); + for rotation in [-180.0, -45.5, 0.0, 45.5, 180.0] { + value["rotation"] = serde_json::json!(rotation); + let decoded: T = serde_json::from_value(value.clone()).unwrap(); + let wire = serde_json::to_value(decoded).unwrap(); + assert_eq!(wire["rotation"], rotation); + let restored: T = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(restored).unwrap(), wire); + } + for invalid in [ + serde_json::Value::Null, + serde_json::json!("45"), + serde_json::json!(true), + ] { + value["rotation"] = invalid; + assert!(serde_json::from_value::(value.clone()).is_err()); + } + } + + let position = KeyPosition::default(); + check::(serde_json::to_value(&position).unwrap()); + check::( + serde_json::to_value(ReactiveSpritePosition::default()).unwrap(), + ); + check::( + serde_json::to_value(StatPosition { + stat_type: StatType::Kps, + position: position.clone(), + }) + .unwrap(), + ); + check::( + serde_json::to_value(GraphPosition { + stat_type: GraphStatType::Kps, + graph_type: GraphType::Line, + graph_speed: 100, + graph_color: "#123456".to_string(), + show_avg_line: true, + position: position.clone(), + }) + .unwrap(), + ); + check::( + serde_json::to_value(KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position, + }) + .unwrap(), + ); +} + #[test] fn app_store_data_defaults_missing_bar_count_to_four() { let mut value = serde_json::to_value(AppStoreData::default()).unwrap(); @@ -44,6 +168,129 @@ fn legacy_string_key_mappings_round_trip_without_loss() { assert_eq!(serde_json::to_value(mappings).unwrap(), raw); } +#[test] +fn sprite_pose_optional_fields_round_trip_with_canonical_pivot_null() { + let pose = SpritePose { + name: Some("왼팔".to_string()), + ..SpritePose::default() + }; + + let serialized = serde_json::to_value(&pose).unwrap(); + assert_eq!(serialized["name"], "왼팔"); + assert!(serialized.get("matchMode").is_none()); + + let restored: SpritePose = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored.name.as_deref(), Some("왼팔")); + + let unnamed = serde_json::to_value(SpritePose::default()).unwrap(); + assert!(unnamed.get("name").is_none()); + assert_eq!(unnamed["pivot"], serde_json::Value::Null); +} + +#[test] +fn sprite_pose_trigger_normalization_reports_changes_once() { + let mut pose = SpritePose { + triggers: vec!["b".to_string(), "a".to_string(), "b".to_string()], + ..SpritePose::default() + }; + + assert!(pose.normalize_triggers()); + assert_eq!(pose.triggers, ["a", "b"]); + assert!(!pose.normalize_triggers()); +} + +#[test] +fn coupled_image_rewrite_keeps_null_reference_source_without_base_image() { + let mut sprite = ReactiveSpritePosition { + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: None, + width: 100, + height: 100, + }), + ..ReactiveSpritePosition::default() + }; + + rewrite_coupled_sprite_image_reference(&mut sprite, |image_ref| { + assert_eq!(image_ref, &None); + }); + + assert_eq!(sprite.reference_natural_size.unwrap().source, None); +} + +#[test] +fn sprite_fields_follow_single_model_wire_contract() { + let mut raw = serde_json::to_value(ReactiveSpritePosition::default()).unwrap(); + let sprite = raw.as_object_mut().unwrap(); + sprite.insert("imageFit".to_string(), serde_json::json!("contain")); + sprite.remove("imagePlacement"); + sprite.remove("referenceNaturalSize"); + sprite.insert( + "imageRect".to_string(), + serde_json::json!({ "x": 10, "y": 20, "width": 40, "height": 50 }), + ); + sprite.insert( + "poses".to_string(), + serde_json::json!([{ + "poseId": "pose-a", + "triggers": [], + "transform": SpriteTransform::default(), + "imageOverride": null, + "contactPoint": { "x": 0.5, "y": 1.0 }, + "imagePivot": { "x": 0.25, "y": 0.75 } + }]), + ); + + let defaulted: ReactiveSpritePosition = serde_json::from_value(raw).unwrap(); + assert_eq!((defaulted.width, defaulted.height), (200.0, 200.0)); + assert_eq!(defaulted.pivot, SpriteAnchor { x: 0.5, y: 0.5 }); + assert_eq!(defaulted.reference_natural_size, None); + assert_eq!(defaulted.poses[0].image_override_metrics, None); + + let serialized = serde_json::to_value(&defaulted).unwrap(); + for field in ["imageFit", "imagePlacement", "imageRect"] { + assert!(serialized.get(field).is_none()); + } + for field in ["contactPoint", "imagePivot"] { + assert!(serialized["poses"][0].get(field).is_none()); + } + assert!(serialized["referenceNaturalSize"].is_null()); + assert!(serialized["poses"][0]["imageOverrideMetrics"].is_null()); + let null_round_trip: ReactiveSpritePosition = + serde_json::from_value(serialized.clone()).unwrap(); + assert_eq!(null_round_trip.reference_natural_size, None); + assert_eq!(null_round_trip.poses[0].image_override_metrics, None); + + let mut valued_raw = serde_json::to_value(ReactiveSpritePosition::default()).unwrap(); + let valued_object = valued_raw.as_object_mut().unwrap(); + valued_object.insert( + "referenceNaturalSize".to_string(), + serde_json::json!({ "source": null, "width": 1920, "height": 1080 }), + ); + valued_object.insert( + "poses".to_string(), + serde_json::json!([{ + "poseId": "pose-a", + "triggers": [], + "transform": SpriteTransform::default(), + "imageOverride": "/images/pose.png", + "imageOverrideMetrics": { + "source": "/images/pose.png", + "width": 640, + "height": 480 + } + }]), + ); + let valued: ReactiveSpritePosition = serde_json::from_value(valued_raw).unwrap(); + assert_eq!(valued.reference_natural_size.unwrap().source, None); + assert_eq!( + valued.poses[0] + .image_override_metrics + .as_ref() + .map(|metrics| metrics.source.as_str()), + Some("/images/pose.png") + ); +} + #[test] fn legacy_700_weights_migrate_to_400_with_bold_modifier() { let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); @@ -177,7 +424,7 @@ fn position_wrappers_preserve_legacy_missing_field_defaults_and_round_trip() { fn position_serialization_field_order_and_related_defaults_are_stable() { let position = KeyPosition::default(); assert!(serde_json::to_string(&position).unwrap().starts_with( - r#"{"dx":0.0,"dy":0.0,"width":60.0,"height":60.0,"hidden":false,"activeImage":null"# + r#"{"dx":0.0,"dy":0.0,"width":60.0,"height":60.0,"rotation":0.0,"hidden":false,"activeImage":null"# )); assert!(serde_json::to_string(&StatPosition { stat_type: StatType::Kps, diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index e1b45f16a..f6cf5e9cd 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -71,6 +71,7 @@ const FORWARDED_EVENTS: &[&str] = &[ "statPositions:changed", "graphPositions:changed", "knobPositions:changed", + "spritePositions:changed", "layerGroups:changed", "overlay:visibility", "overlay:lock", diff --git a/src-tauri/src/services/obs_bridge/media.rs b/src-tauri/src/services/obs_bridge/media.rs index a826b2071..b1fe42035 100644 --- a/src-tauri/src/services/obs_bridge/media.rs +++ b/src-tauri/src/services/obs_bridge/media.rs @@ -5,6 +5,7 @@ use tokio::io::AsyncWriteExt; use tokio::net::TcpStream; use super::transport::write_empty_http_response; +use crate::state::assets::image_asset::SUPPORTED_IMAGE_EXTENSIONS; pub(super) async fn handle_media_request( stream: &mut TcpStream, @@ -101,22 +102,7 @@ pub(super) async fn handle_media_request( .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); - if !matches!( - ext.as_str(), - "png" - | "jpg" - | "jpeg" - | "gif" - | "webp" - | "svg" - | "mp4" - | "webm" - | "ogg" - | "woff" - | "woff2" - | "ttf" - | "otf" - ) { + if !is_servable_media_extension(&ext) { let _ = stream .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") .await; @@ -144,6 +130,15 @@ pub(super) async fn handle_media_request( } } +// 이미지는 선택창 목록 그대로, 나머지는 영상·폰트만 +fn is_servable_media_extension(ext: &str) -> bool { + SUPPORTED_IMAGE_EXTENSIONS.contains(&ext) + || matches!( + ext, + "mp4" | "webm" | "ogg" | "woff" | "woff2" | "ttf" | "otf" + ) +} + /// 파일 확장자로 MIME 타입 추정 pub(super) fn guess_mime(path: &str) -> &'static str { match path @@ -162,6 +157,9 @@ pub(super) fn guess_mime(path: &str) -> &'static str { "gif" => "image/gif", "webp" => "image/webp", "svg" => "image/svg+xml", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "avif" => "image/avif", "mp4" => "video/mp4", "webm" => "video/webm", "ogg" => "video/ogg", diff --git a/src-tauri/src/services/obs_bridge/media/tests.rs b/src-tauri/src/services/obs_bridge/media/tests.rs index 932e79be1..63c9049da 100644 --- a/src-tauri/src/services/obs_bridge/media/tests.rs +++ b/src-tauri/src/services/obs_bridge/media/tests.rs @@ -6,28 +6,29 @@ use tokio::net::{TcpListener, TcpStream}; use super::*; +// 선택창이 받는 이미지는 전부 OBS에서도 서빙되고 image/* MIME으로 나가야 한다 #[test] -fn percent_decoding_preserves_malformed_sequences_and_unicode() { - for (input, expected) in [ - ("%가", "%가"), - ("%a가", "%a가"), - ("%😀", "%😀"), - ("%", "%"), - ("%2", "%2"), - ("%GG", "%GG"), - ("한글%2f이미지%2F", "한글/이미지/"), - ("%EA%B0%80", "가"), - ("%FF", "�"), - ] { - assert_eq!(percent_decode(input), expected, "input: {input}"); +fn media_whitelist_serves_every_picker_image_extension() { + for ext in SUPPORTED_IMAGE_EXTENSIONS { + assert!( + is_servable_media_extension(ext), + "{ext} 이미지가 OBS 서빙에서 빠짐" + ); + let mime = guess_mime(&format!("/app/images/sample.{ext}")); + assert!( + mime.starts_with("image/"), + "{ext} MIME이 image/*가 아님: {mime}" + ); } + assert_eq!(guess_mime("/app/images/a.BMP"), "image/bmp"); + assert_eq!(guess_mime("/app/images/a.ico"), "image/x-icon"); + assert_eq!(guess_mime("/app/images/a.avif"), "image/avif"); } -#[tokio::test] -async fn authenticated_malformed_media_path_returns_bad_request() { - for path in ["%가", "%a가", "%😀", "%", "%2", "%GG"] { - let response = media_response("secret", &format!("{path}?token=secret"), None).await; - assert_eq!(response, empty_response("400 Bad Request"), "path: {path}"); +#[test] +fn media_whitelist_still_rejects_non_media_files() { + for ext in ["json", "html", "js", "exe", "css", ""] { + assert!(!is_servable_media_extension(ext), "{ext} 서빙 허용됨"); } } @@ -276,3 +277,28 @@ async fn canonical_containment_follows_symlinks_before_authorization() { assert!(response.starts_with(b"HTTP/1.1 200 OK")); assert!(response.ends_with(b"inside")); } + +#[test] +fn percent_decoding_preserves_malformed_sequences_and_unicode() { + for (input, expected) in [ + ("%가", "%가"), + ("%a가", "%a가"), + ("%😀", "%😀"), + ("%", "%"), + ("%2", "%2"), + ("%GG", "%GG"), + ("한글%2f이미지%2F", "한글/이미지/"), + ("%EA%B0%80", "가"), + ("%FF", "�"), + ] { + assert_eq!(percent_decode(input), expected, "input: {input}"); + } +} + +#[tokio::test] +async fn authenticated_malformed_media_path_returns_bad_request() { + for path in ["%가", "%a가", "%😀", "%", "%2", "%GG"] { + let response = media_response("secret", &format!("{path}?token=secret"), None).await; + assert_eq!(response, empty_response("400 Bad Request"), "path: {path}"); + } +} diff --git a/src-tauri/src/services/obs_bridge/tests.rs b/src-tauri/src/services/obs_bridge/tests.rs index 5698b7d21..cf42aaa2c 100644 --- a/src-tauri/src/services/obs_bridge/tests.rs +++ b/src-tauri/src/services/obs_bridge/tests.rs @@ -252,12 +252,13 @@ fn bridge_messages_targeting_main_are_not_forwarded_to_obs() { #[test] fn public_overlay_events_are_forwarded_to_obs_clients() { - assert_eq!(FORWARDED_EVENTS.len(), 32); + assert_eq!(FORWARDED_EVENTS.len(), 33); for event in [ "customTabs:changed", "overlay:resized", "counterAnimation:changed", "preset:snapshot", + "spritePositions:changed", ] { assert!(FORWARDED_EVENTS.contains(&event), "missing event: {event}"); } diff --git a/src-tauri/src/services/overlay_hit.rs b/src-tauri/src/services/overlay_hit.rs index 82f70a893..9bd7d9719 100644 --- a/src-tauri/src/services/overlay_hit.rs +++ b/src-tauri/src/services/overlay_hit.rs @@ -346,6 +346,12 @@ impl OverlayHitService { .context("failed to dispatch overlay hit reconciliation") } + #[cfg(target_os = "macos")] + pub(crate) fn reconcile_after_parent_order(&self, app: &AppHandle) -> Result<()> { + platform::request_reorder(&mut self.inner.native.lock()); + self.reconcile(app) + } + fn reconcile_on_main(&self, app: &AppHandle) -> Result<()> { let overlay = app.get_webview_window(OVERLAY_LABEL); let parent = platform::parent_identity(overlay.as_ref())?; @@ -359,6 +365,8 @@ impl OverlayHitService { (desired.clone(), parent_changed) }; let mut native = self.inner.native.lock(); + #[cfg(target_os = "macos")] + platform::set_owner(&mut native, Arc::downgrade(&self.inner)); let status = platform::reconcile(app, overlay.as_ref(), &desired, &mut native)?; drop(native); diff --git a/src-tauri/src/services/overlay_hit/platform/macos.rs b/src-tauri/src/services/overlay_hit/platform/macos.rs index 698ac8078..9b7eaca22 100644 --- a/src-tauri/src/services/overlay_hit/platform/macos.rs +++ b/src-tauri/src/services/overlay_hit/platform/macos.rs @@ -1,13 +1,17 @@ use std::{ os::raw::c_void, panic::{catch_unwind, AssertUnwindSafe}, + sync::{ + atomic::{AtomicBool, Ordering}, + Weak, + }, }; use anyhow::{anyhow, Context, Result}; use cocoa::{ appkit::{NSBackingStoreBuffered, NSEvent, NSEventType}, base::{id, nil, BOOL, NO, YES}, - foundation::{NSInteger, NSPoint, NSRect, NSSize}, + foundation::{NSInteger, NSPoint, NSRect, NSSize, NSString}, }; use objc::{ class, @@ -21,22 +25,43 @@ use tauri::{AppHandle, Emitter, WebviewWindow}; use super::{ clip_hit_rect_to_bounds, hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload, - OverlayHitDesiredState, OverlayHitRect, OVERLAY_LABEL, + OverlayHitDesiredState, OverlayHitRect, OverlayHitService, OverlayHitServiceInner, + OVERLAY_LABEL, }; const HIT_CONTEXT_IVAR: &str = "dmNoteOverlayHitContext"; +const PARENT_OBSERVER_IVAR: &str = "dmNoteOverlayParentObserverContext"; const NS_NONACTIVATING_PANEL_MASK: u64 = 1 << 7; const NS_WINDOW_ABOVE: i64 = 1; +const NS_WINDOW_ANIMATION_NONE: NSInteger = 2; #[derive(Default)] pub(super) struct NativeState { panels: Vec, + owner: Weak, + observer: Option, + needs_reorder: bool, } struct HitPanel { panel: usize, view: usize, context: Box, + frame: NSRect, + level: Option, + collection_behavior: Option, +} + +struct ParentObserver { + object: usize, + parent: usize, + _context: Box, +} + +struct ParentObserverContext { + app: AppHandle, + owner: Weak, + queued: AtomicBool, } struct HitContext { @@ -100,6 +125,135 @@ static HIT_VIEW_CLASS: Lazy = Lazy::new(|| unsafe { ObjectiveCClass(declaration.register()) }); +static PARENT_OBSERVER_CLASS: Lazy = Lazy::new(|| unsafe { + if let Some(class) = Class::get("DmNoteOverlayParentObserver") { + return ObjectiveCClass(class); + } + let mut declaration = ClassDecl::new("DmNoteOverlayParentObserver", class!(NSObject)) + .expect("overlay parent observer class"); + declaration.add_method( + sel!(parentStateChanged:), + parent_state_changed as extern "C" fn(&Object, Sel, id), + ); + declaration.add_method( + sel!(reconcileParent:), + reconcile_parent as extern "C" fn(&Object, Sel, id), + ); + declaration.add_ivar::<*mut c_void>(PARENT_OBSERVER_IVAR); + ObjectiveCClass(declaration.register()) +}); + +extern "C" fn parent_state_changed(this: &Object, _selector: Sel, _notification: id) { + let _ = catch_unwind(AssertUnwindSafe(|| unsafe { + let pointer: *mut c_void = *this.get_ivar(PARENT_OBSERVER_IVAR); + let Some(context) = pointer.cast::().as_ref() else { + return; + }; + if context.queued.swap(true, Ordering::AcqRel) { + return; + } + // AppKit 알림은 창 정산 중에도 발생하므로 다음 공통 실행 루프에서 처리 + let _: () = msg_send![this, + performSelectorOnMainThread: sel!(reconcileParent:) + withObject: nil + waitUntilDone: NO + ]; + })); +} + +extern "C" fn reconcile_parent(this: &Object, _selector: Sel, _argument: id) { + let _ = catch_unwind(AssertUnwindSafe(|| { + let (owner, app) = unsafe { + let pointer: *mut c_void = *this.get_ivar(PARENT_OBSERVER_IVAR); + let Some(context) = pointer.cast::().as_ref() else { + return; + }; + context.queued.store(false, Ordering::Release); + (context.owner.upgrade(), context.app.clone()) + }; + if let Some(inner) = owner { + if let Err(error) = (OverlayHitService { inner }).reconcile_after_parent_order(&app) { + log::warn!("failed to follow macOS overlay parent state: {error:#}"); + } + } + })); +} + +impl ParentObserver { + fn new(app: &AppHandle, parent: id, owner: Weak) -> Result { + let mut context = Box::new(ParentObserverContext { + app: app.clone(), + owner, + queued: AtomicBool::new(false), + }); + unsafe { + let object: id = msg_send![PARENT_OBSERVER_CLASS.0, new]; + if object.is_null() { + return Err(anyhow!("failed to create overlay parent observer")); + } + (*object).set_ivar( + PARENT_OBSERVER_IVAR, + (&mut *context as *mut ParentObserverContext).cast::(), + ); + let center: id = msg_send![class!(NSNotificationCenter), defaultCenter]; + add_observer( + center, + object, + "NSWindowDidChangeOcclusionStateNotification", + parent, + ); + let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; + let center: id = msg_send![workspace, notificationCenter]; + for name in [ + "NSWorkspaceActiveSpaceDidChangeNotification", + "NSWorkspaceDidActivateApplicationNotification", + ] { + add_observer(center, object, name, nil); + } + Ok(Self { + object: object as usize, + parent: parent as usize, + _context: context, + }) + } + } +} + +impl Drop for ParentObserver { + fn drop(&mut self) { + unsafe { + let object = self.object as id; + let center: id = msg_send![class!(NSNotificationCenter), defaultCenter]; + let _: () = msg_send![center, removeObserver: object]; + let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; + let center: id = msg_send![workspace, notificationCenter]; + let _: () = msg_send![center, removeObserver: object]; + // 이미 예약된 선택자는 객체를 유지하므로 컨텍스트만 먼저 해제 + (*object).set_ivar(PARENT_OBSERVER_IVAR, std::ptr::null_mut::()); + let _: () = msg_send![object, release]; + } + } +} + +unsafe fn add_observer(center: id, observer: id, name: &str, object: id) { + let name = NSString::alloc(nil).init_str(name); + let _: () = msg_send![center, + addObserver: observer + selector: sel!(parentStateChanged:) + name: name + object: object + ]; + let _: () = msg_send![name, release]; +} + +pub(super) fn set_owner(native: &mut NativeState, owner: Weak) { + native.owner = owner; +} + +pub(super) fn request_reorder(native: &mut NativeState) { + native.needs_reorder = true; +} + extern "C" fn never_becomes_key(_this: &Object, _selector: Sel) -> BOOL { NO } @@ -223,6 +377,7 @@ pub(super) fn reconcile( native: &mut NativeState, ) -> Result { let Some(overlay) = overlay else { + native.observer = None; hide_panels(native); return Ok(HitRegionStatus::Applied); }; @@ -230,11 +385,17 @@ pub(super) fn reconcile( .ns_window() .context("failed to get overlay NSWindow")? as id; if parent.is_null() { + native.observer = None; hide_panels(native); return Ok(HitRegionStatus::Applied); } + if native.observer.as_ref().map(|observer| observer.parent) != Some(parent as usize) { + native.observer = Some(ParentObserver::new(app, parent, native.owner.clone())?); + native.needs_reorder = true; + } let parent_visible: BOOL = unsafe { msg_send![parent, isVisible] }; + let parent_on_space: BOOL = unsafe { msg_send![parent, isOnActiveSpace] }; // 프론트가 "키 0개"를 확정 보고했으면 콘텐츠 전체를 잡는다 - 모든 키를 숨기거나 // 커스텀 CSS로 지웠을 때 창을 옮길 수도 우클릭할 수도 없게 되는 것을 막는다. // 첫 측정 전(last_revision == None)에는 그대로 클릭 통과 @@ -258,8 +419,9 @@ pub(super) fn reconcile( .filter_map(|rect| clip_hit_rect_to_bounds(*rect, content_width, content_height)) .collect::>() }; + let parent_available = parent_visible != NO && parent_on_space != NO; let active = - desired.visible && !desired.locked && !clipped_rects.is_empty() && parent_visible != NO; + desired.visible && !desired.locked && !clipped_rects.is_empty() && parent_available; if !active { hide_panels(native); if clipped_rects.is_empty() { @@ -274,6 +436,14 @@ pub(super) fn reconcile( return Ok(HitRegionStatus::Applied); } resize_panel_pool(app, parent, &clipped_rects, native)?; + let (parent_level, parent_behavior, parent_number): (NSInteger, u64, NSInteger) = unsafe { + ( + msg_send![parent, level], + msg_send![parent, collectionBehavior], + msg_send![parent, windowNumber], + ) + }; + let collection_behavior = hit_panel_collection_behavior(parent_behavior); for (panel, rect) in native.panels.iter_mut().zip(&clipped_rects) { panel.context.parent = parent as usize; @@ -282,25 +452,49 @@ pub(super) fn reconcile( unsafe { let panel_id = panel.panel as id; let view_id = panel.view as id; - let current_parent: id = msg_send![panel_id, parentWindow]; - if current_parent != parent { - if !current_parent.is_null() { - let _: () = msg_send![current_parent, removeChildWindow: panel_id]; - } - let _: () = msg_send![parent, addChildWindow: panel_id ordered: NS_WINDOW_ABOVE]; + let reorder = native.needs_reorder + || !panel.context.active + || panel.level != Some(parent_level) + || panel.collection_behavior != Some(collection_behavior); + if panel.level != Some(parent_level) { + let _: () = msg_send![panel_id, setLevel: parent_level]; + panel.level = Some(parent_level); + } + if panel.collection_behavior != Some(collection_behavior) { + let _: () = msg_send![panel_id, setCollectionBehavior: collection_behavior]; + panel.collection_behavior = Some(collection_behavior); + } + if panel.frame.origin.x != frame.origin.x + || panel.frame.origin.y != frame.origin.y + || panel.frame.size.width != frame.size.width + || panel.frame.size.height != frame.size.height + || !panel.context.active + { + let _: () = msg_send![panel_id, setFrame: frame display: NO]; + let _: () = msg_send![panel_id, invalidateCursorRectsForView: view_id]; + panel.frame = frame; + } + if !panel.context.active { + let _: () = msg_send![panel_id, setIgnoresMouseEvents: NO]; } - let parent_level: NSInteger = msg_send![parent, level]; - let _: () = msg_send![panel_id, setLevel: parent_level]; - let _: () = msg_send![panel_id, setFrame: frame display: NO]; - let _: () = msg_send![panel_id, setIgnoresMouseEvents: NO]; - let _: () = msg_send![panel_id, invalidateCursorRectsForView: view_id]; panel.context.active = true; - let _: () = msg_send![panel_id, orderFront: nil]; + if reorder { + // 자식 창 그룹과 중복 정렬은 회전 띠의 대량 정리 비용을 키움 + let _: () = + msg_send![panel_id, orderWindow: NS_WINDOW_ABOVE relativeTo: parent_number]; + } } } + native.needs_reorder = false; Ok(HitRegionStatus::Applied) } +fn hit_panel_collection_behavior(parent_behavior: u64) -> u64 { + let managed_stationary_cycle = (1 << 2) | (1 << 4) | (1 << 5); + let transient_ignores_cycle = (1 << 3) | (1 << 6); + (parent_behavior & !managed_stationary_cycle) | transient_ignores_cycle +} + fn content_size(parent: id) -> Result<(f64, f64)> { unsafe { let content_view: id = msg_send![parent, contentView]; @@ -364,18 +558,20 @@ fn create_panel(app: &AppHandle, parent: id, rect: OverlayHitRect) -> Result Result { fn hide_panels(native: &mut NativeState) { for panel in &mut native.panels { + if !panel.context.active { + continue; + } panel.context.parent = 0; panel.context.active = false; unsafe { @@ -414,10 +613,6 @@ fn destroy_panel(panel: HitPanel) { let panel_id = panel.panel as id; let view_id = panel.view as id; (*view_id).set_ivar(HIT_CONTEXT_IVAR, std::ptr::null_mut::()); - let parent: id = msg_send![panel_id, parentWindow]; - if !parent.is_null() { - let _: () = msg_send![parent, removeChildWindow: panel_id]; - } let _: () = msg_send![panel_id, orderOut: nil]; let _: () = msg_send![panel_id, close]; let _: () = msg_send![panel_id, release]; @@ -428,4 +623,26 @@ fn destroy_panel(panel: HitPanel) { pub(super) fn register_classes_for_test() { Lazy::force(&HIT_PANEL_CLASS); Lazy::force(&HIT_VIEW_CLASS); + Lazy::force(&PARENT_OBSERVER_CLASS); +} + +#[cfg(test)] +mod tests { + use super::hit_panel_collection_behavior; + + #[test] + fn hit_panels_follow_parent_spaces_without_joining_window_cycles() { + let spaces_and_fullscreen = (1 << 0) | (1 << 8); + let managed_stationary_cycle = (1 << 2) | (1 << 4) | (1 << 5); + let transient_ignores_cycle = (1 << 3) | (1 << 6); + for parent in [ + spaces_and_fullscreen, + spaces_and_fullscreen | managed_stationary_cycle, + ] { + let behavior = hit_panel_collection_behavior(parent); + assert_eq!(behavior & spaces_and_fullscreen, spaces_and_fullscreen); + assert_eq!(behavior & managed_stationary_cycle, 0); + assert_eq!(behavior & transient_ignores_cycle, transient_ignores_cycle); + } + } } diff --git a/src-tauri/src/services/preview_broker.rs b/src-tauri/src/services/preview_broker.rs index 057e64197..762713aef 100644 --- a/src-tauri/src/services/preview_broker.rs +++ b/src-tauri/src/services/preview_broker.rs @@ -24,6 +24,7 @@ const KEY_POSITION_PATCH_FIELDS: &[&str] = &[ "dy", "width", "height", + "rotation", "hidden", "activeImage", "inactiveImage", @@ -89,6 +90,22 @@ const KEY_POSITION_PATCH_FIELDS: &[&str] = &[ "fontStrikethrough", ]; +const SPRITE_POSITION_PATCH_FIELDS: &[&str] = &[ + "dx", + "dy", + "width", + "height", + "rotation", + "pivot", + "idleTransform", + "poses", + "pressDurationMs", + "transitionMs", + "transitionEasing", + "baseImage", + "referenceNaturalSize", +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PreviewKind { @@ -104,6 +121,7 @@ pub enum PreviewDomain { StatPosition, GraphPosition, KnobPosition, + SpritePosition, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -425,10 +443,14 @@ fn validate_publish_request(request: &PreviewPublishRequest) -> Result<(), Strin "preview target count exceeds {MAX_PREVIEW_TARGETS}" )); } + let allowed_fields = match request.domain { + PreviewDomain::SpritePosition => SPRITE_POSITION_PATCH_FIELDS, + _ => KEY_POSITION_PATCH_FIELDS, + }; if let Some(field) = request .patch .keys() - .find(|field| !KEY_POSITION_PATCH_FIELDS.contains(&field.as_str())) + .find(|field| !allowed_fields.contains(&field.as_str())) { return Err(format!("preview patch field '{field}' is not allowed")); } diff --git a/src-tauri/src/services/preview_broker/tests.rs b/src-tauri/src/services/preview_broker/tests.rs index 76affd3be..51de0ebf2 100644 --- a/src-tauri/src/services/preview_broker/tests.rs +++ b/src-tauri/src/services/preview_broker/tests.rs @@ -114,6 +114,32 @@ fn publish_injects_source_label_and_defaults_to_patch() { assert_eq!(messages[0].kind, PreviewKind::Patch); } +#[test] +fn element_rotation_preview_round_trips_for_native_position_domains() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .unwrap(); + for domain in [ + PreviewDomain::KeyPosition, + PreviewDomain::StatPosition, + PreviewDomain::GraphPosition, + PreviewDomain::KnobPosition, + PreviewDomain::SpritePosition, + ] { + let mut request = request_for_domain(&session_id(), 1, domain); + request.patch = Map::from_iter([("rotation".to_string(), Value::from(45.5))]); + broker.publish("owner", request).unwrap(); + let messages = messages.lock(); + let message = messages.last().unwrap(); + assert_eq!(message.domain, domain); + assert_eq!(message.patch["rotation"], 45.5); + } + assert_eq!(messages.lock().len(), 5); +} + #[test] fn image_transform_preview_fields_round_trip_through_channel() { let broker = PreviewBroker::default(); @@ -272,6 +298,7 @@ fn forwards_every_position_preview_domain() { PreviewDomain::StatPosition, PreviewDomain::GraphPosition, PreviewDomain::KnobPosition, + PreviewDomain::SpritePosition, ]; for (index, domain) in domains.iter().copied().enumerate() { @@ -291,6 +318,127 @@ fn forwards_every_position_preview_domain() { assert_eq!(forwarded, domains); } +#[test] +fn sprite_position_domain_validates_and_round_trips_through_subscribers() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let patch = serde_json::json!({ + "dx": 24, + "dy": -8, + "width": 320, + "height": 180, + "rotation": -45.5, + "pivot": { "x": 0.5, "y": 0.75 }, + "idleTransform": { "x": 0, "y": 0, "rotation": 0, "scale": 1 }, + "poses": [{ "poseId": "pose-id", "triggers": [] }], + "pressDurationMs": 300, + "transitionMs": 120, + "transitionEasing": "ease-out", + "baseImage": "/images/base.png", + "referenceNaturalSize": { + "source": "/images/base.png", + "width": 640, + "height": 360 + } + }); + let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ + "schemaVersion": PREVIEW_SCHEMA_VERSION, + "sessionId": session_id, + "seq": 1, + "domain": "spritePosition", + "mode": "4key", + "targets": [0], + "patch": patch + })) + .expect("spritePosition domain is accepted"); + + broker + .publish("owner", request) + .expect("sprite preview publishes"); + + let messages = messages.lock(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].domain, PreviewDomain::SpritePosition); + assert_eq!(messages[0].mode, "4key"); + assert_eq!(Value::Object(messages[0].patch.clone()), patch); +} + +#[test] +fn sprite_position_preview_allowlist_matches_supported_contract() { + assert_eq!( + SPRITE_POSITION_PATCH_FIELDS, + [ + "dx", + "dy", + "width", + "height", + "rotation", + "pivot", + "idleTransform", + "poses", + "pressDurationMs", + "transitionMs", + "transitionEasing", + "baseImage", + "referenceNaturalSize", + ] + ); +} + +#[test] +fn preview_patch_allowlist_rejects_unknown_and_cross_domain_fields() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + let mut unknown_sprite = request_for_domain(&session_id, 1, PreviewDomain::SpritePosition); + unknown_sprite.patch = Map::from_iter([("unknownSpriteField".to_string(), Value::Bool(true))]); + + let unknown_error = broker.publish("owner", unknown_sprite).unwrap_err(); + + assert!(unknown_error.contains("unknownSpriteField")); + + for (seq, field) in ["x", "y", "scale"].into_iter().enumerate() { + let mut removed_field = + request_for_domain(&session_id, seq as u64 + 2, PreviewDomain::SpritePosition); + removed_field.patch = Map::from_iter([(field.to_string(), serde_json::json!(1.0))]); + + let removed_error = broker.publish("owner", removed_field).unwrap_err(); + + assert!(removed_error.contains(field)); + } + + let mut sprite_field_on_key = request_for_domain(&session_id, 6, PreviewDomain::KeyPosition); + sprite_field_on_key.patch = Map::from_iter([( + "pivot".to_string(), + serde_json::json!({ "x": 0.5, "y": 0.5 }), + )]); + + let cross_domain_error = broker.publish("owner", sprite_field_on_key).unwrap_err(); + + assert!(cross_domain_error.contains("pivot")); +} + +#[test] +fn unknown_preview_domain_is_rejected_during_wire_validation() { + let error = serde_json::from_value::(serde_json::json!({ + "schemaVersion": PREVIEW_SCHEMA_VERSION, + "sessionId": session_id(), + "seq": 1, + "domain": "spritePose", + "mode": "4key", + "targets": [0], + "patch": { "dx": 24 } + })) + .expect_err("unknown preview domains stay closed"); + + assert!(error.to_string().contains("unknown variant")); +} + #[test] fn rejects_non_monotonic_sequence() { let broker = PreviewBroker::default(); diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 7af2ceb68..3be9c97e5 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -86,14 +86,15 @@ use window_geometry::convert_physical_bounds_to_logical; #[cfg(all(target_os = "windows", not(test)))] use window_geometry::OverlayRestoreSource; use window_geometry::{ - applied_overlay_frame_from_placement, complete_overlay_scale_resolution, - flush_deferred_overlay_bounds, logical_position_to_native, main_window_content_origin, - main_window_logical_rect, main_window_native_rect, native_placement_from_window, - overlay_restore_window_scale, panel_bounds_sample_from_window, panel_client_to_outer_position, - persist_overlay_placement, persist_overlay_placement_from_window, resolve_overlay_placement, - resolve_panel_window_layout, AppliedOverlayFrame, MonitorData, NativePlacement, NativeRect, - OverlayPersistenceAuthority, OverlayPlacementTrust, OverlayPosition, PanelBoundsChange, - PanelBoundsPersistenceController, PanelWindowLayout, ResolvedOverlayPlacement, + adjust_overlay_resize_position, applied_overlay_frame_from_placement, + complete_overlay_scale_resolution, content_offset_change, flush_deferred_overlay_bounds, + logical_position_to_native, main_window_content_origin, main_window_logical_rect, + main_window_native_rect, native_placement_from_window, overlay_restore_window_scale, + panel_bounds_sample_from_window, panel_client_to_outer_position, persist_overlay_placement, + persist_overlay_placement_from_window, resolve_overlay_placement, resolve_panel_window_layout, + AppliedOverlayFrame, MonitorData, NativePlacement, NativeRect, OverlayPersistenceAuthority, + OverlayPlacementTrust, OverlayPosition, PanelBoundsChange, PanelBoundsPersistenceController, + PanelWindowLayout, ResolvedOverlayPlacement, }; #[cfg(test)] use window_geometry::{ @@ -141,6 +142,11 @@ use crate::{ const OVERLAY_LABEL: &str = "overlay"; pub(crate) const PANEL_LABEL: &str = "panel"; +// Windows 창 클래스. OBS 윈도우 캡처는 기본 우선순위에서 같은 클래스의 창을 대체 후보로 +// 잡으므로 창마다 다른 이름을 준다(메인은 tauri.conf.json). OBS 씬에 저장되는 값이라 +// 출시 후 바꾸지 않는다 +const OVERLAY_WINDOW_CLASS: &str = "DmNoteOverlayWindow"; +const PANEL_WINDOW_CLASS: &str = "DmNotePanelWindow"; const FRONTEND_LIFECYCLE_WINDOW_LABELS: [&str; 2] = ["main", OVERLAY_LABEL]; // 메인이 window.open을 부르기 직전 arm하고, 이 시간 안에 온 요청만 패널 창으로 인정 const PANEL_OPEN_ARM_TIMEOUT: Duration = Duration::from_secs(2); @@ -1081,6 +1087,7 @@ impl AppState { stat_positions: state.stat_positions.clone(), graph_positions: state.graph_positions.clone(), knob_positions: state.knob_positions.clone(), + sprite_positions: state.sprite_positions.clone(), custom_tabs: state.custom_tabs.clone(), tab_order: state.tab_order.clone(), bar_count: state.bar_count, diff --git a/src-tauri/src/state/app_state/native_window.rs b/src-tauri/src/state/app_state/native_window.rs index 442e52487..8409af6f3 100644 --- a/src-tauri/src/state/app_state/native_window.rs +++ b/src-tauri/src/state/app_state/native_window.rs @@ -262,6 +262,12 @@ pub(super) fn apply_macos_overlay_fullscreen_behavior(window: &WebviewWindow, al let window = window.clone(); let _ = app.run_on_main_thread(move || { apply_macos_overlay_fullscreen_behavior_inner(&window, always_on_top); + let app = window.app_handle(); + if let Some(state) = app.try_state::() { + if let Err(error) = state.overlay_hit.reconcile_after_parent_order(app) { + log::warn!("failed to follow macOS overlay window ordering: {error:#}"); + } + } }); } @@ -490,6 +496,7 @@ unsafe extern "system" fn overlay_move_subclass_proc( &(*context).trust, frame, None, + None, OverlayPersistenceAuthority::NativeMoveEnded, ) }) { diff --git a/src-tauri/src/state/app_state/overlay_runtime.rs b/src-tauri/src/state/app_state/overlay_runtime.rs index 6cd3803f4..88e019a42 100644 --- a/src-tauri/src/state/app_state/overlay_runtime.rs +++ b/src-tauri/src/state/app_state/overlay_runtime.rs @@ -372,6 +372,7 @@ impl AppState { width: f64, height: f64, anchor: Option, + content_left_offset: Option, content_top_offset: Option, fixed_position_delta_x: Option, fixed_position_delta_y: Option, @@ -413,70 +414,31 @@ impl AppState { ..current } }; - let mut next_content_top_offset = None; + // 직전 오프셋 두 개만 읽는다, 리사이즈마다 store 전체를 복제하지 않도록 + let (last_content_left_offset, last_content_top_offset) = self.store.with_state(|state| { + ( + state.overlay_last_content_left_offset, + state.overlay_last_content_top_offset, + ) + }); + let content_left_change = + content_offset_change(content_left_offset, last_content_left_offset); + let content_top_change = content_offset_change(content_top_offset, last_content_top_offset); + let next_content_left_offset = content_left_change.map(|(offset, _)| offset); + let next_content_top_offset = content_top_change.map(|(offset, _)| offset); // 초기화 중(첫 resize)에는 anchor 기반 position 재계산을 건너뛰고 // 기동 시 한 번 해석한 배치를 사용 - if initializing { - // 초기화 중이라도 content_top_offset은 저장해야 다음 resize에서 delta 계산이 정확함 - if let Some(offset) = content_top_offset { - if offset.is_finite() { - next_content_top_offset = Some(offset); - } - } - } else { - let scale = placement.target_scale; - match anchor { - OverlayResizeAnchor::BottomLeft => { - placement.position.y += (current.height - height) * scale - } - OverlayResizeAnchor::TopRight => { - placement.position.x += (current.width - width) * scale - } - OverlayResizeAnchor::BottomRight => { - placement.position.x += (current.width - width) * scale; - placement.position.y += (current.height - height) * scale; - } - OverlayResizeAnchor::Center => { - placement.position.x += (current.width - width) * scale / 2.0; - placement.position.y += (current.height - height) * scale / 2.0; - } - OverlayResizeAnchor::FixedPosition => {} - OverlayResizeAnchor::TopLeft => {} - } - - if anchor == OverlayResizeAnchor::FixedPosition { - if let Some(delta_x) = fixed_position_delta_x.filter(|value| value.is_finite()) { - placement.position.x += delta_x * scale; - } - if let Some(delta_y) = fixed_position_delta_y.filter(|value| value.is_finite()) { - placement.position.y += delta_y * scale; - } - } - - if let Some(offset) = content_top_offset { - if offset.is_finite() { - let previous = self - .store - .snapshot() - .overlay_last_content_top_offset - .unwrap_or(offset); - let delta = offset - previous; - if delta != 0.0 { - match anchor { - OverlayResizeAnchor::Center => { - placement.position.y -= delta * scale / 2.0 - } - OverlayResizeAnchor::BottomLeft | OverlayResizeAnchor::BottomRight => {} - OverlayResizeAnchor::FixedPosition => { - placement.position.y -= delta * scale - } - _ => placement.position.y -= delta * scale, - } - } - next_content_top_offset = Some(offset); - } - } + if !initializing { + adjust_overlay_resize_position( + &mut placement, + current, + &anchor, + fixed_position_delta_x.filter(|value| value.is_finite()), + fixed_position_delta_y.filter(|value| value.is_finite()), + content_left_change.map(|(_, delta)| delta), + content_top_change.map(|(_, delta)| delta), + ); } // 크기·위치를 단일 네이티브 트랜잭션으로 적용 - 분리 호출은 창이 두 단계로 움직여 덜컥거림 유발 @@ -490,6 +452,7 @@ impl AppState { &self.overlay_bounds_generation, &self.overlay_placement_trust, applied.clone(), + next_content_left_offset, next_content_top_offset, OverlayPersistenceAuthority::General, )?; @@ -583,6 +546,7 @@ impl AppState { &self.overlay_placement_trust, applied.clone(), None, + None, OverlayPersistenceAuthority::Reset, )?; let bounds = applied.public_bounds; @@ -631,6 +595,7 @@ impl AppState { WebviewUrl::App("overlay/index.html".into()), ) .title("DM Note - Overlay") + .window_classname(OVERLAY_WINDOW_CLASS) .decorations(false) .resizable(false) .maximizable(false) @@ -786,6 +751,7 @@ impl AppState { &self.overlay_placement_trust, applied, None, + None, OverlayPersistenceAuthority::General, ) { log::warn!("failed to persist initial overlay bounds: {err}"); diff --git a/src-tauri/src/state/app_state/panel_runtime.rs b/src-tauri/src/state/app_state/panel_runtime.rs index 96b466a45..2dc36c4cb 100644 --- a/src-tauri/src/state/app_state/panel_runtime.rs +++ b/src-tauri/src/state/app_state/panel_runtime.rs @@ -452,6 +452,7 @@ impl AppState { ) .window_features(features) .title("DM Note - Panel") + .window_classname(PANEL_WINDOW_CLASS) // 메인·오버레이와 같은 프레임리스 크롬 - 드래그 영역은 패널 상단 스트립이 담당 .decorations(false) // Windows는 DWM이 실루엣을 소유하므로(windows_window_corners) 모서리 바깥을 비출 diff --git a/src-tauri/src/state/app_state/tests.rs b/src-tauri/src/state/app_state/tests.rs index 927b741de..d0ce22a62 100644 --- a/src-tauri/src/state/app_state/tests.rs +++ b/src-tauri/src/state/app_state/tests.rs @@ -12,18 +12,18 @@ use std::{ use super::normalize_stored_overlay_bounds; use super::{ acknowledge_editor_flush_handshake, acknowledge_panel_close_request, - applied_overlay_frame_from_native, apply_panel_bounds_change, begin_panel_close_request, - bootstrap_keyboard_state, canonical_hold_duration_ms, changed_panel_max_height, - clamp_overlay_dimension, collect_authorized_css_paths, collect_frontend_lifecycle_targets, - complete_overlay_scale_resolution, drop_panel_hidden_with_main, - frontend_history_mutation_blocked, frontend_lifecycle_restore_labels, global_css_watch_path, - hide_panel_with_main_transition, install_history_handshake, install_lifecycle_handshake, - is_panel_open_url, key_state_payload, main_window_starts_hidden, monitor_scale_is_usable, - next_keyboard_recovery_plan, next_overlay_placement_trust, overlay_close_action, - overlay_reset_fallback_rect, panel_bounds_from_sample, panel_height_bounds, - panel_position_beside_main, publish_panel_hidden_transition, - publish_panel_visibility_transition, resolve_event_age_ms, resolve_overlay_placement, - resolve_panel_window_layout, resolve_windows_overlay_placement, + adjust_overlay_resize_position, applied_overlay_frame_from_native, apply_panel_bounds_change, + begin_panel_close_request, bootstrap_keyboard_state, canonical_hold_duration_ms, + changed_panel_max_height, clamp_overlay_dimension, collect_authorized_css_paths, + collect_frontend_lifecycle_targets, complete_overlay_scale_resolution, content_offset_change, + drop_panel_hidden_with_main, frontend_history_mutation_blocked, + frontend_lifecycle_restore_labels, global_css_watch_path, hide_panel_with_main_transition, + install_history_handshake, install_lifecycle_handshake, is_panel_open_url, key_state_payload, + main_window_starts_hidden, monitor_scale_is_usable, next_keyboard_recovery_plan, + next_overlay_placement_trust, overlay_close_action, overlay_reset_fallback_rect, + panel_bounds_from_sample, panel_height_bounds, panel_position_beside_main, + publish_panel_hidden_transition, publish_panel_visibility_transition, resolve_event_age_ms, + resolve_overlay_placement, resolve_panel_window_layout, resolve_windows_overlay_placement, restore_panel_with_main_transition, run_panel_close_timeout, should_create_overlay_on_startup, should_recover_keyboard_daemon, should_restore_panel_on_startup, stored_overlay_bounds_for_persistence, take_cancelable_editor_flush_handshake, @@ -31,23 +31,24 @@ use super::{ EditorFlushCompletion, EditorFlushHandshake, EditorFlushRequest, FrontendFlushAction, FrontendHistoryFlushPhase, FrontendHistoryFlushReady, FrontendLifecycleAction, KeyCounterEventEmitter, LifecycleHandshakeInstall, MonitorData, MonitorSpec, - MutationPublicationSequencer, Mutex, NativeRect, NativeRejectReason, OverlayCloseAction, - OverlayPersistenceAuthority, OverlayPlacementTrust, OverlayRestoreSource, PanelBoundsChange, - PanelBoundsPersistenceController, PanelBoundsPersistenceState, PanelBoundsSample, - PanelCloseRequestState, PanelCloseRequestedPayload, PanelPresentSnapshot, - PanelVisibilityEventEmitter, PanelVisibilityPayload, PanelVisibilityReason, PhysicalPosition, - PhysicalSize, DEFAULT_OVERLAY_HEIGHT, DEFAULT_OVERLAY_WIDTH, - HISTORY_FRONTEND_FLUSH_INTERRUPTED, KEYBOARD_DAEMON_STABLE_RUNTIME, - KEYBOARD_RECOVERY_DELAYS_MS, OVERLAY_LABEL, PANEL_BESIDE_GAP, PANEL_INITIAL_HEIGHT, - PANEL_LABEL, PANEL_MIN_HEIGHT, PANEL_OPEN_ARM_TIMEOUT, PANEL_WIDTH, + MutationPublicationSequencer, Mutex, NativePlacement, NativeRect, NativeRejectReason, + OverlayCloseAction, OverlayPersistenceAuthority, OverlayPlacementTrust, OverlayPosition, + OverlayRestoreSource, PanelBoundsChange, PanelBoundsPersistenceController, + PanelBoundsPersistenceState, PanelBoundsSample, PanelCloseRequestState, + PanelCloseRequestedPayload, PanelPresentSnapshot, PanelVisibilityEventEmitter, + PanelVisibilityPayload, PanelVisibilityReason, PhysicalPosition, PhysicalSize, + DEFAULT_OVERLAY_HEIGHT, DEFAULT_OVERLAY_WIDTH, HISTORY_FRONTEND_FLUSH_INTERRUPTED, + KEYBOARD_DAEMON_STABLE_RUNTIME, KEYBOARD_RECOVERY_DELAYS_MS, OVERLAY_LABEL, PANEL_BESIDE_GAP, + PANEL_INITIAL_HEIGHT, PANEL_LABEL, PANEL_MIN_HEIGHT, PANEL_OPEN_ARM_TIMEOUT, PANEL_WIDTH, }; use crate::{ keyboard::KeyboardManager, models::{ AppStoreData, CustomCss, CustomTab, EditorCommitOrigin, EditorCommitRequest, EditorField, EditorFrozenKeySlotV1, EditorOpV1, GestureCommitRequest, GesturePluginInstancesChange, - KeyCounters, KeySlot, OverlayBounds, PanelBounds, PluginPoint, SavedPluginInstance, - StoredOverlayBounds, StoredOverlayNativePosition, TabCss, EDITOR_OPS_VERSION, + KeyCounters, KeySlot, OverlayBounds, OverlayResizeAnchor, PanelBounds, PluginPoint, + SavedPluginInstance, StoredOverlayBounds, StoredOverlayNativePosition, TabCss, + EDITOR_OPS_VERSION, }, state::{ assets::local_asset_path::path_identity_key, @@ -62,6 +63,123 @@ use std::path::Path; struct NoopCounterEmitter; +fn resize_placement(x: f64, y: f64) -> NativePlacement { + NativePlacement { + position: OverlayPosition { x, y }, + width: 100.0, + height: 100.0, + target_scale: 2.0, + } +} + +#[test] +fn unchanged_content_top_baseline_has_zero_first_delta() { + assert_eq!( + content_offset_change(Some(24.0), Some(24.0)), + Some((24.0, 0.0)) + ); + assert_eq!(content_offset_change(Some(24.0), None), Some((24.0, 0.0))); +} + +#[test] +fn top_left_resize_compensates_left_and_top_overhang() { + let current = resize_placement(100.0, 200.0); + let mut placement = NativePlacement { + width: 140.0, + height: 160.0, + ..current + }; + + adjust_overlay_resize_position( + &mut placement, + current, + &OverlayResizeAnchor::TopLeft, + None, + None, + Some(10.0), + Some(20.0), + ); + + assert_eq!(placement.position, OverlayPosition { x: 80.0, y: 160.0 }); +} + +#[test] +fn fixed_position_resize_applies_fixed_and_content_deltas_once() { + let current = resize_placement(100.0, 200.0); + let mut placement = current; + + adjust_overlay_resize_position( + &mut placement, + current, + &OverlayResizeAnchor::FixedPosition, + Some(3.0), + Some(4.0), + Some(10.0), + Some(20.0), + ); + + assert_eq!(placement.position, OverlayPosition { x: 86.0, y: 168.0 }); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_sprite_visibility_roundtrip_keeps_overlay_position() { + // AppKit의 원점 내림과 크기 올림을 실제 창 대조 실행과 같은 순서로 반영 + let apply_native = |mut placement: NativePlacement| { + let screen_height = 1440.0; + let bottom = (screen_height - placement.position.y - placement.height).floor(); + placement.position.x = placement.position.x.floor(); + placement.width = placement.width.ceil(); + placement.height = placement.height.ceil(); + placement.position.y = screen_height - bottom - placement.height; + placement + }; + for anchor in [ + OverlayResizeAnchor::TopLeft, + OverlayResizeAnchor::TopRight, + OverlayResizeAnchor::BottomLeft, + OverlayResizeAnchor::BottomRight, + OverlayResizeAnchor::Center, + OverlayResizeAnchor::FixedPosition, + ] { + for origin in [100.0, -100.0] { + for delta in [2.5776543848328117, 0.5, -0.5, 17.125] { + let original = NativePlacement { + position: OverlayPosition { + x: origin, + y: origin, + }, + width: 655.0, + height: 505.0, + target_scale: 1.0, + }; + let mut current = original; + for _ in 0..100 { + for visible in [true, false] { + let change = if visible { delta } else { -delta }; + let mut placement = NativePlacement { + width: if visible { 798.0 } else { 655.0 }, + height: if visible { 663.0 } else { 505.0 }, + ..current + }; + adjust_overlay_resize_position( + &mut placement, + current, + &anchor, + Some(change), + Some(-change), + Some(change), + Some(change), + ); + current = apply_native(placement); + } + assert_eq!(current, original, "anchor={anchor:?}, delta={delta}"); + } + } + } + } +} + impl KeyCounterEventEmitter for NoopCounterEmitter { fn emit_key_counters( &self, @@ -2241,6 +2359,129 @@ fn lifecycle_flush_interrupts_history_but_history_cannot_replace_lifecycle() { .is_some_and(|active| active.completion.is_lifecycle())); } +#[cfg(unix)] +#[test] +fn sigterm_preserves_pending_state_and_allows_retry_after_canceled_flush() { + use std::{os::unix::process::ExitStatusExt, process::Command}; + + const CHILD_MODE: &str = "DMNOTE_SIGTERM_REGRESSION_MODE"; + const CHILD_STORE: &str = "DMNOTE_SIGTERM_REGRESSION_STORE"; + if let Ok(mode) = std::env::var(CHILD_MODE) { + let directory = std::env::var_os(CHILD_STORE).unwrap(); + let state = Arc::new( + AppState::initialize(AppStore::initialize_for_test(Path::new(&directory)).unwrap()) + .unwrap(), + ); + state.key_counter_enabled.store(true, Ordering::SeqCst); + let snapshot = state.store.snapshot(); + let key_mode = snapshot.selected_key_type; + let key = snapshot.keys[&key_mode][0].canonical(); + for expected in 1..=9 { + assert_eq!( + state.increment_key_counter_and_emit(&NoopCounterEmitter, &key_mode, &key), + Some(expected) + ); + } + state.store.flush().unwrap(); + state + .store + .update_deferred(|store| { + store.overlay_bounds = Some(StoredOverlayBounds { + x: 417.0, + y: 19.0, + width: 500.0, + height: 400.0, + native_position: None, + }); + }) + .unwrap(); + + let (sender, receiver) = mpsc::channel(); + if mode == "protected" { + let state = Arc::clone(&state); + let requests = AtomicUsize::new(0); + crate::state::window::unix_termination::install(move || { + let request_number = requests.fetch_add(1, Ordering::SeqCst) + 1; + let mut active = state.editor_flush_handshake.lock(); + let outcome = install_lifecycle_handshake( + &mut active, + EditorFlushHandshake { + id: format!("sigterm-{request_number}"), + completion: EditorFlushCompletion::Lifecycle(FrontendLifecycleAction::Quit), + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }, + ); + let installed = matches!(outcome, LifecycleHandshakeInstall::Installed); + sender + .send((active.as_ref().unwrap().id.clone(), installed)) + .unwrap(); + }) + .unwrap(); + } + + assert_eq!(unsafe { libc::raise(libc::SIGTERM) }, 0); + assert_eq!( + receiver.recv_timeout(Duration::from_secs(3)).unwrap(), + ("sigterm-1".to_string(), true) + ); + assert_eq!(unsafe { libc::raise(libc::SIGTERM) }, 0); + assert_eq!( + receiver.recv_timeout(Duration::from_secs(3)).unwrap(), + ("sigterm-1".to_string(), false) + ); + assert!(take_cancelable_editor_flush_handshake( + &mut state.editor_flush_handshake.lock(), + "sigterm-1" + ) + .is_some()); + assert_eq!(unsafe { libc::raise(libc::SIGTERM) }, 0); + assert_eq!( + receiver.recv_timeout(Duration::from_secs(3)).unwrap(), + ("sigterm-3".to_string(), true) + ); + state.shutdown(); + return; + } + + for mode in ["default", "protected"] { + let directory = tempfile::tempdir().unwrap(); + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "state::app_state::tests::sigterm_preserves_pending_state_and_allows_retry_after_canceled_flush", + "--nocapture", + ]) + .env(CHILD_MODE, mode) + .env(CHILD_STORE, directory.path()) + .output() + .unwrap(); + if mode == "default" { + assert_eq!(output.status.signal(), Some(libc::SIGTERM)); + } else { + assert!( + output.status.success(), + "SIGTERM child failed: {:?}\n{}\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + let persisted: AppStoreData = + serde_json::from_slice(&std::fs::read(directory.path().join("store.json")).unwrap()) + .unwrap(); + let key = persisted.keys[&persisted.selected_key_type][0].canonical(); + let counter = persisted.key_counters[&persisted.selected_key_type][&key]; + let x = persisted.overlay_bounds.as_ref().map(|bounds| bounds.x); + eprintln!( + "SIGTERM {mode}: status={}, counter={counter}, bounds_x={x:?}", + output.status + ); + assert_eq!(counter, if mode == "protected" { 9 } else { 0 }); + assert_eq!(x, (mode == "protected").then_some(417.0)); + } +} + #[test] fn closing_history_can_time_out_and_reopen_the_gate() { let (sender, _receiver) = tokio::sync::oneshot::channel(); diff --git a/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs b/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs index 593c81a4c..4d6d2857d 100644 --- a/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs +++ b/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs @@ -120,6 +120,79 @@ impl NativePlacement { } } +pub(in crate::state::app_state) fn content_offset_change( + offset: Option, + previous: Option, +) -> Option<(f64, f64)> { + let offset = offset.filter(|value| value.is_finite())?; + Some((offset, offset - previous.unwrap_or(offset))) +} + +pub(in crate::state::app_state) fn adjust_overlay_resize_position( + placement: &mut NativePlacement, + current: NativePlacement, + anchor: &OverlayResizeAnchor, + fixed_position_delta_x: Option, + fixed_position_delta_y: Option, + content_left_delta: Option, + content_top_delta: Option, +) { + let scale = placement.target_scale; + match anchor { + OverlayResizeAnchor::BottomLeft => { + placement.position.y += (current.height - placement.height) * scale + } + OverlayResizeAnchor::TopRight => { + placement.position.x += (current.width - placement.width) * scale + } + OverlayResizeAnchor::BottomRight => { + placement.position.x += (current.width - placement.width) * scale; + placement.position.y += (current.height - placement.height) * scale; + } + OverlayResizeAnchor::Center => { + placement.position.x += (current.width - placement.width) * scale / 2.0; + placement.position.y += (current.height - placement.height) * scale / 2.0; + } + OverlayResizeAnchor::FixedPosition | OverlayResizeAnchor::TopLeft => {} + } + + if matches!(anchor, OverlayResizeAnchor::FixedPosition) { + if let Some(delta_x) = fixed_position_delta_x { + placement.position.x += delta_x * scale; + } + if let Some(delta_y) = fixed_position_delta_y { + placement.position.y += delta_y * scale; + } + } + + if let Some(delta) = content_left_delta.filter(|delta| *delta != 0.0) { + match anchor { + OverlayResizeAnchor::Center => placement.position.x -= delta * scale / 2.0, + OverlayResizeAnchor::TopRight | OverlayResizeAnchor::BottomRight => {} + OverlayResizeAnchor::FixedPosition => placement.position.x -= delta * scale, + _ => placement.position.x -= delta * scale, + } + } + + if let Some(delta) = content_top_delta.filter(|delta| *delta != 0.0) { + match anchor { + OverlayResizeAnchor::Center => placement.position.y -= delta * scale / 2.0, + OverlayResizeAnchor::BottomLeft | OverlayResizeAnchor::BottomRight => {} + OverlayResizeAnchor::FixedPosition => placement.position.y -= delta * scale, + _ => placement.position.y -= delta * scale, + } + } + + #[cfg(target_os = "macos")] + { + // AppKit의 원점 내림 전에 이동량을 대칭 반올림해 왕복 시 위치 누적 방지 + placement.position.x = + current.position.x + (placement.position.x - current.position.x).round(); + placement.position.y = + current.position.y + (placement.position.y - current.position.y).round(); + } +} + #[derive(Clone, Debug)] pub(in crate::state::app_state) enum PendingOverlayScaleResolution { #[cfg(any(target_os = "windows", test))] @@ -824,7 +897,7 @@ pub(in crate::state::app_state) fn persist_overlay_placement_from_window( authority: OverlayPersistenceAuthority, ) -> Result<()> { let frame = applied_overlay_frame_from_window(window)?; - persist_overlay_placement(store, generation, trust, frame, None, authority) + persist_overlay_placement(store, generation, trust, frame, None, None, authority) } pub(in crate::state::app_state) fn persist_overlay_placement( @@ -832,6 +905,7 @@ pub(in crate::state::app_state) fn persist_overlay_placement( generation: &Arc, trust: &Arc>, frame: AppliedOverlayFrame, + content_left_offset: Option, content_top_offset: Option, authority: OverlayPersistenceAuthority, ) -> Result<()> { @@ -846,6 +920,9 @@ pub(in crate::state::app_state) fn persist_overlay_placement( store.update_deferred(move |state| { state.overlay_bounds = Some(stored); state.overlay_bounds_are_logical = true; + if let Some(offset) = content_left_offset { + state.overlay_last_content_left_offset = Some(offset); + } if let Some(offset) = content_top_offset { state.overlay_last_content_top_offset = Some(offset); } diff --git a/src-tauri/src/state/assets/image_asset.rs b/src-tauri/src/state/assets/image_asset.rs index 80d527349..ef2f321ea 100644 --- a/src-tauri/src/state/assets/image_asset.rs +++ b/src-tauri/src/state/assets/image_asset.rs @@ -7,8 +7,47 @@ use std::{ use anyhow::{Context, Result}; use uuid::Uuid; +use super::local_asset_path::{file_url_to_path, FileUrlPath}; use crate::state::atomic_file::{atomic_replace, atomic_replace_from_temp, sync_file_contents}; +// 이미지 선택창이 받는 확장자. OBS 브릿지 서빙 허용 목록도 이 목록을 따라야 +// 편집기·네이티브 오버레이에서 보이는 그림이 OBS에서만 빠지지 않는다 +pub(crate) const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &[ + "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "ico", "avif", +]; + +pub(crate) fn probe_local_raster_size(image_ref: &str) -> Result> { + let trimmed = image_ref.trim(); + let path = match file_url_to_path(trimmed) { + FileUrlPath::Path(path) => path, + FileUrlPath::Invalid => return Ok(None), + FileUrlPath::NotFileUrl => PathBuf::from(trimmed), + }; + if !path.is_absolute() + || path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("svg")) + { + return Ok(None); + } + + let size = imagesize::size(&path) + .with_context(|| format!("failed to read raster image at {}", path.display()))?; + let Some(width) = u32::try_from(size.width).ok() else { + return Ok(None); + }; + let Some(height) = u32::try_from(size.height).ok() else { + return Ok(None); + }; + let is_supported = (crate::models::SPRITE_IMAGE_DIMENSION_MIN + ..=crate::models::SPRITE_IMAGE_DIMENSION_MAX) + .contains(&width) + && (crate::models::SPRITE_IMAGE_DIMENSION_MIN..=crate::models::SPRITE_IMAGE_DIMENSION_MAX) + .contains(&height); + Ok(is_supported.then_some((width, height))) +} + #[derive(Debug)] pub(crate) struct ImportedImage { pub path: PathBuf, @@ -134,6 +173,73 @@ mod tests { std::env::temp_dir().join(format!("dmnote-{label}-{}", Uuid::new_v4())) } + fn png_header(width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes + } + + #[test] + fn local_raster_probe_reads_absolute_path_and_file_url() { + let directory = test_directory("sprite-image-size"); + let path = directory.join("sprite.png"); + fs::create_dir_all(&directory).unwrap(); + fs::write(&path, png_header(321, 123)).unwrap(); + + assert_eq!( + probe_local_raster_size(path.to_str().unwrap()).unwrap(), + Some((321, 123)) + ); + assert_eq!( + probe_local_raster_size(url::Url::from_file_path(&path).unwrap().as_str()).unwrap(), + Some((321, 123)) + ); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn local_raster_probe_ignores_non_local_and_unsupported_sources() { + let directory = test_directory("sprite-image-size-ignored"); + let svg = directory.join("sprite.svg"); + fs::create_dir_all(&directory).unwrap(); + fs::write(&svg, b"").unwrap(); + + assert_eq!( + probe_local_raster_size("https://example.com/sprite.png").unwrap(), + None + ); + assert_eq!( + probe_local_raster_size("data:image/png;base64,AAAA").unwrap(), + None + ); + assert_eq!( + probe_local_raster_size("relative/sprite.png").unwrap(), + None + ); + assert_eq!( + probe_local_raster_size(svg.to_str().unwrap()).unwrap(), + None + ); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn local_raster_probe_reports_read_failures() { + let directory = test_directory("sprite-image-size-failure"); + let corrupt = directory.join("corrupt.png"); + let missing = directory.join("missing.png"); + fs::create_dir_all(&directory).unwrap(); + fs::write(&corrupt, b"not-an-image").unwrap(); + + assert!(probe_local_raster_size(corrupt.to_str().unwrap()).is_err()); + assert!(probe_local_raster_size(missing.to_str().unwrap()).is_err()); + + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn gif_file_import_keeps_original_bytes_and_extension() { let directory = test_directory("gif-original-file"); diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index fde228f49..e95fb1a2d 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -6,9 +6,14 @@ use crate::{ defaults::default_keys, errors::EditorCommitError, models::{ - AppStoreData, EditorBoundsV1, EditorDocumentV1, EditorElementTypeV1, EditorField, - ElementShadowSpec, GraphPosition, KeyCounters, KeyMappings, KeyPosition, KeySlot, - KnobPosition, StatPosition, EDITOR_SCHEMA_VERSION, + is_renderable_image_ref, AppStoreData, EditorBoundsV1, EditorDocumentV1, + EditorElementTypeV1, EditorField, ElementShadowSpec, GraphPosition, KeyCounters, + KeyMappings, KeyPosition, KeySlot, KnobPosition, ReactiveSpritePosition, SpriteTransform, + StatPosition, EDITOR_SCHEMA_VERSION, MAX_SPRITE_POSES, MAX_SPRITE_POSE_TRIGGERS, + SPRITE_IMAGE_DIMENSION_MAX, SPRITE_IMAGE_DIMENSION_MIN, SPRITE_PRESS_DURATION_MS_MAX, + SPRITE_PRESS_DURATION_MS_MIN, SPRITE_TRANSFORM_OFFSET_MAX, SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_ROTATION_MAX, SPRITE_TRANSFORM_ROTATION_MIN, SPRITE_TRANSFORM_SCALE_MAX, + SPRITE_TRANSFORM_SCALE_MIN, SPRITE_TRANSITION_MS_MAX, }, }; @@ -25,7 +30,7 @@ use limits::validate_metric_limits; pub(crate) use limits::{validate_editor_op_bounds, validate_editor_op_target_type}; pub(crate) use request::{ - canonical_request_fingerprint, decode_editor_commit_request, decode_exact_frozen_insert, + decode_editor_commit_request, decode_exact_frozen_insert, gesture_request_fingerprint, next_revision, request_fingerprint, request_payload_size, validate_history_restore_metadata, validate_request_envelope, validate_revision, RequestFingerprint, }; @@ -35,7 +40,8 @@ pub(crate) use violations::{ #[cfg(test)] use violations::{ - is_grandfathered, InvalidValueSignature, ValidationViolation, ViolationKey, ViolationOwner, + allowed_modes, collect_sprite_violations, collect_violations, is_grandfathered, + InvalidValueSignature, NativeIdAliases, ValidationViolation, ViolationKey, ViolationOwner, ViolationPropertyPath, }; @@ -59,8 +65,8 @@ const MAX_KEY_LABEL_BYTES: usize = 1_024; // plugin group_id 검증(state/plugin.rs)도 이 상한을 공유 - 레이어 그룹 id 참조라 동일 규칙 pub(crate) 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; +pub(crate) const MAX_ABS_COORDINATE: f64 = 32_768.0; +pub(crate) 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, diff --git a/src-tauri/src/state/editor/limits.rs b/src-tauri/src/state/editor/limits.rs index aa3a346aa..e0c52ff27 100644 --- a/src-tauri/src/state/editor/limits.rs +++ b/src-tauri/src/state/editor/limits.rs @@ -44,6 +44,11 @@ fn validate_aggregate_metric_limits( ¤t.knob_positions, &candidate.knob_positions, )?; + validate_collection_limits( + "spritePositions", + ¤t.sprite_positions, + &candidate.sprite_positions, + )?; validate_collection_limits( "layerGroups", ¤t.layer_groups, @@ -291,9 +296,56 @@ fn validate_per_owner_metric_limits( } } + let current_sprite_positions = current + .sprite_positions + .values() + .flatten() + .map(|position| (position.id.as_str(), position)) + .collect::>(); + for (mode, sprites) in &candidate.sprite_positions { + for (index, sprite) in sprites.iter().enumerate() { + let current_sprite = match keying { + GrandfatherKeying::StableId => { + current_sprite_positions.get(sprite.id.as_str()).copied() + } + #[cfg(test)] + GrandfatherKeying::ModeIndex => current + .sprite_positions + .get(mode) + .and_then(|sprites| sprites.get(index)), + GrandfatherKeying::LegacyPresetModeIndex => current + .sprite_positions + .get(mode) + .and_then(|sprites| sprites.get(index)), + }; + validate_bounds_metrics( + &format!("spritePositions {mode}[{index}]"), + current_sprite.map(sprite_bounds), + sprite_bounds(sprite), + keying == GrandfatherKeying::LegacyPresetModeIndex, + )?; + validate_count_limit( + "COLLECTION_TOO_LARGE", + &format!("spritePositions {mode}[{index}] pose count"), + current_sprite.map_or(0, |sprite| sprite.poses.len()), + sprite.poses.len(), + MAX_SPRITE_POSES, + )?; + } + } + Ok(()) } +fn sprite_bounds(sprite: &ReactiveSpritePosition) -> EditorBoundsV1 { + EditorBoundsV1 { + dx: sprite.dx, + dy: sprite.dy, + width: sprite.width, + height: sprite.height, + } +} + fn validate_key_slot_label_limits( mode: &str, slot_index: usize, @@ -337,6 +389,7 @@ fn editor_modes(document: &EditorDocumentV1) -> BTreeSet { .chain(document.stat_positions.keys()) .chain(document.graph_positions.keys()) .chain(document.knob_positions.keys()) + .chain(document.sprite_positions.keys()) .chain(document.layer_groups.keys()) .cloned() .collect() @@ -359,6 +412,11 @@ fn render_item_count(document: &EditorDocumentV1) -> usize { .values() .map(Vec::len) .sum::() + + document + .sprite_positions + .values() + .map(Vec::len) + .sum::() } fn validate_collection_limits( @@ -455,12 +513,12 @@ pub(super) fn position_bounds(position: &KeyPosition) -> EditorBoundsV1 { pub(crate) fn validate_editor_op_bounds( op_index: usize, - current: Option<&KeyPosition>, + current: Option, bounds: EditorBoundsV1, ) -> Result<(), EditorCommitError> { validate_bounds_metrics( &format!("editor op {op_index}.bounds"), - current.map(position_bounds), + current, bounds, false, ) diff --git a/src-tauri/src/state/editor/request.rs b/src-tauri/src/state/editor/request.rs index 08e9003ab..db54979e4 100644 --- a/src-tauri/src/state/editor/request.rs +++ b/src-tauri/src/state/editor/request.rs @@ -9,8 +9,8 @@ use crate::{ defaults::default_keys, errors::EditorCommitError, models::{ - CustomTab, EditorCommitRequest, EditorDocumentV1, EDITOR_COMMIT_SCHEMA_VERSION_V2, - EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, + CustomTab, EditorCommitRequest, EditorDocumentV1, GestureCommitRequest, + EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, }, state::tab_metadata::normalize_tab_order, }; @@ -787,15 +787,63 @@ pub(crate) fn validate_history_restore_metadata( pub(crate) fn request_fingerprint( request: &EditorCommitRequest, ) -> Result { - canonical_request_fingerprint(&FingerprintPayload { - base_revision: request.base_revision, - multi_key: request.multi_key, - gesture_id: request.gesture_id.as_deref(), - gesture_ids: &request.gesture_ids, - changes: &request.changes, - ops_version: &request.ops_version, - ops: &request.ops, - }) + canonical_request_fingerprint_with_presence( + &FingerprintPayload { + base_revision: request.base_revision, + multi_key: request.multi_key, + gesture_id: request.gesture_id.as_deref(), + gesture_ids: &request.gesture_ids, + changes: &request.changes, + ops_version: &request.ops_version, + ops: &request.ops, + }, + request + .changes + .as_ref() + .and_then(|changes| changes.sprite_positions_presence.as_ref()), + ) +} + +pub(crate) fn gesture_request_fingerprint( + request: &GestureCommitRequest, +) -> Result { + canonical_request_fingerprint_with_presence( + request, + request + .editor_changes + .as_ref() + .and_then(|changes| changes.sprite_positions_presence.as_ref()), + ) +} + +fn canonical_request_fingerprint_with_presence( + payload: &impl Serialize, + presence: Option<&crate::models::editor::SpritePositionsPatchPresence>, +) -> Result { + let mut value = serde_json::to_value(payload).map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to serialize editor request: {error}"), + ) + })?; + if let Some(presence) = presence { + let object = value.as_object_mut().ok_or_else(|| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + "editor request fingerprint payload must be an object", + ) + })?; + object.insert( + "__spritePositionsPresence".to_string(), + serde_json::to_value(presence).map_err(|error| { + EditorCommitError::validation( + "INVALID_REQUEST_PAYLOAD", + format!("failed to serialize sprite patch presence: {error}"), + ) + })?, + ); + } + canonical_request_fingerprint(&value) } pub(crate) fn canonical_request_fingerprint( diff --git a/src-tauri/src/state/editor/tests.rs b/src-tauri/src/state/editor/tests.rs index e6d4bacce..17422365f 100644 --- a/src-tauri/src/state/editor/tests.rs +++ b/src-tauri/src/state/editor/tests.rs @@ -5,11 +5,86 @@ use crate::models::{ EditorElementPropertyPatchV1, EditorElementTypeV1, EditorFrozenKeySlotV1, EditorGroupUpdateV1, EditorOpResultStatusV1, EditorOpResultV1, EditorOpV1, EditorPatchV1, EditorTargetGroupV1, EditorZUpdateV1, ElementShadowSpec, GraphPosition, GraphStatType, GraphType, KeyPosition, - KnobPosition, LayerGroupDef, StatPosition, StatType, + KnobPosition, LayerGroupDef, ReactiveSpritePosition, SpriteActivation, SpriteImageMetrics, + SpritePose, SpriteReferenceNaturalSize, StatPosition, StatType, }; use super::*; +const SPRITE_CONSTRAINT_FIXTURE: &str = + include_str!("../../../../tests/fixtures/sprite-constraint-parity.json"); + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SpriteConstraintFixture { + offset: NumericRangeFixture, + rotation: NumericRangeFixture, + scale: NumericRangeFixture, + anchor: NumericRangeFixture, + transition_ms: IntegerRangeFixture, + press_duration_ms: IntegerRangeFixture, + image_metrics: ImageMetricsRangeFixture, + max_poses: usize, + max_triggers_per_pose: usize, + default_transition_ms: u32, + default_transition_easing: String, + default_activation: SpriteActivation, + default_press_duration_ms: u32, +} + +#[derive(serde::Deserialize)] +struct NumericRangeFixture { + min: f64, + max: f64, +} + +#[derive(serde::Deserialize)] +struct IntegerRangeFixture { + min: u32, + max: u32, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImageMetricsRangeFixture { + dimension_min: u32, + dimension_max: u32, +} + +fn sprite_constraint_fixture() -> SpriteConstraintFixture { + serde_json::from_str(SPRITE_CONSTRAINT_FIXTURE).unwrap() +} + +fn validate_new_sprite( + sprite: ReactiveSpritePosition, +) -> Result<(), crate::errors::EditorCommitError> { + let current_store = AppStoreData::default(); + let mut candidate_store = current_store.clone(); + candidate_store + .sprite_positions + .insert("4key".to_string(), vec![sprite]); + validate_document_transition( + &EditorDocumentV1::from_store(¤t_store), + &EditorDocumentV1::from_store(&candidate_store), + ¤t_store, + &candidate_store, + ) +} + +fn sprite_with_pose_count(count: usize) -> ReactiveSpritePosition { + ReactiveSpritePosition { + id: Uuid::from_u128(1).to_string(), + poses: (0..count) + .map(|index| SpritePose { + pose_id: Uuid::from_u128(1_000 + index as u128).to_string(), + triggers: vec![Uuid::from_u128(10_000 + index as u128).to_string()], + ..SpritePose::default() + }) + .collect(), + ..ReactiveSpritePosition::default() + } +} + fn request(keys: KeyMappings) -> EditorCommitRequest { EditorCommitRequest { base_revision: 0, @@ -1105,6 +1180,40 @@ fn property_and_key_slot_wires_are_exact_and_canonical() { ); } +#[test] +fn sprite_patch_element_wire_accepts_generic_and_rejects_dedicated_fields() { + let base = serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + "opsVersion": EDITOR_OPS_VERSION, + "ops": [{ + "kind": "patchElement", + "elementType": "sprite", + "id": Uuid::new_v4().to_string(), + "patch": { "property": "hidden", "value": true }, + }], + }); + decode_editor_commit_request(base.clone()).unwrap(); + + for (property, value) in [ + ("baseImage", serde_json::json!("sprite.png")), + ("poses", serde_json::json!([])), + ("pivot", serde_json::json!({ "x": 0.5, "y": 0.5 })), + ] { + let mut wire = base.clone(); + wire["ops"][0]["patch"] = serde_json::json!({ + "property": property, + "value": value, + }); + let error = decode_editor_commit_request(wire).unwrap_err(); + assert_eq!( + validation_code(&error), + Some("INVALID_REQUEST_PAYLOAD"), + "{property} must not be a patchElement property" + ); + } +} + #[test] fn frozen_insert_wire_is_exact_through_nested_full_records() { let request = ops_request(vec![frozen_insert_op(Uuid::new_v4().to_string())]); @@ -1722,8 +1831,8 @@ fn frozen_insert_is_a_bounded_sole_op_with_unique_disjoint_ids() { #[test] fn editor_ops_enforce_version_count_ids_and_global_target_uniqueness() { let id = Uuid::new_v4().to_string(); - // 구버전 1과 미래 버전 3 모두 거부, v1 이중 수용 없음 - for unsupported_version in [1, 3] { + // 구버전과 미래 버전 모두 거부, 다중 버전 수용 없음 + for unsupported_version in (1..EDITOR_OPS_VERSION).chain([EDITOR_OPS_VERSION + 1]) { let mut unsupported = ops_request(vec![set_bounds_op(&id, EditorElementTypeV1::Key)]); unsupported.ops_version = Some(unsupported_version); assert_eq!( @@ -1769,6 +1878,23 @@ fn editor_ops_enforce_version_count_ids_and_global_target_uniqueness() { Some("DUPLICATE_EDITOR_OP_TARGET") ); + let duplicate_resize = ops_request(vec![ + set_bounds_op(&id, EditorElementTypeV1::Key), + EditorOpV1::ResizeSprite { + id: id.clone(), + bounds: EditorBoundsV1 { + dx: 10.0, + dy: 20.0, + width: 200.0, + height: 100.0, + }, + }, + ]); + assert_eq!( + validation_code(&validate_request_envelope(&duplicate_resize).unwrap_err()), + Some("DUPLICATE_EDITOR_OP_TARGET") + ); + let nil = ops_request(vec![set_bounds_op( Uuid::nil().to_string(), EditorElementTypeV1::Key, @@ -1816,7 +1942,7 @@ fn editor_op_bounds_reuse_position_numeric_limits() { }; validate_editor_op_bounds( 0, - Some(&grandfathered), + Some(position_bounds(&grandfathered)), EditorBoundsV1 { width: MAX_DIMENSION + 1.0, ..position_bounds(&grandfathered) @@ -2303,6 +2429,74 @@ fn editor_rejects_new_shadow_violations_in_every_position_collection() { } } +#[test] +fn element_rotation_document_validation_checks_all_position_types() { + let store = store_with_each_position_collection(); + let current = EditorDocumentV1::from_store(&store); + for collection in crate::models::POSITION_COLLECTION_FIELDS { + for rotation in [-180.0, -45.5, 0.0, 180.0] { + let mut candidate = current.clone(); + position_mut(&mut candidate, collection).rotation = rotation; + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); + } + for rotation in [-180.1, 180.1, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut candidate = current.clone(); + position_mut(&mut candidate, collection).rotation = rotation; + 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!(validation_code(&error), Some("INVALID_ROTATION")); + assert_eq!( + error.message, + format!("{collection} 4key[0].rotation must be finite within -180..=180") + ); + } + } +} + +#[test] +fn element_rotation_grandfathers_only_unchanged_value_and_identity() { + let base = store_with_each_position_collection(); + for collection in crate::models::POSITION_COLLECTION_FIELDS { + for rotation in [-999.0, 999.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut current = EditorDocumentV1::from_store(&base); + position_mut(&mut current, collection).rotation = rotation; + let mut store = base.clone(); + current.apply_to_store(&mut store); + + let mut unrelated = current.clone(); + position_mut(&mut unrelated, collection).font_size = Some(18.0); + let mut candidate_store = store.clone(); + unrelated.apply_to_store(&mut candidate_store); + validate_document_transition(¤t, &unrelated, &store, &candidate_store).unwrap(); + + for change_identity in [false, true] { + let mut candidate = unrelated.clone(); + let position = position_mut(&mut candidate, collection); + if change_identity { + position.id = uuid::Uuid::new_v4().to_string(); + } else { + position.rotation = 998.0; + } + candidate.apply_to_store(&mut candidate_store); + let error = + validate_document_transition(¤t, &candidate, &store, &candidate_store) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("INVALID_ROTATION")); + } + + let mut repaired = current.clone(); + position_mut(&mut repaired, collection).rotation = 0.0; + repaired.apply_to_store(&mut candidate_store); + validate_document_transition(¤t, &repaired, &store, &candidate_store).unwrap(); + } + } +} + #[test] fn existing_shadow_violations_are_grandfathered_only_when_unchanged() { let mut store = default_editor_store(); @@ -2392,10 +2586,173 @@ fn stage_four_grandfathering_ignores_diagnostic_message_changes() { assert!(is_grandfathered( ¤t, &ValidationViolation::new(key, "different diagnostic message"), - &HashMap::new() + &NativeIdAliases::default() )); } +fn sprite_pose_with_triggers(triggers: Vec) -> SpritePose { + SpritePose { + pose_id: Uuid::new_v4().to_string(), + triggers, + ..SpritePose::default() + } +} + +fn store_with_grandfathered_sprite_poses(poses: Vec) -> AppStoreData { + let mut store = default_editor_store(); + store.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: Uuid::new_v4().to_string(), + poses, + ..ReactiveSpritePosition::default() + }], + ); + store +} + +fn empty_trigger_violation_key( + document: &EditorDocumentV1, + store: &AppStoreData, + pose_id: &str, +) -> ViolationKey { + collect_violations(document, &allowed_modes(store)) + .into_iter() + .find(|violation| { + violation.code() == "EMPTY_SPRITE_POSE_TRIGGERS" + && matches!( + &violation.key.property_path, + ViolationPropertyPath::SpritePoseProperty { + pose_id: violation_pose_id, + .. + } if violation_pose_id == pose_id + ) + }) + .expect("empty trigger violation exists") + .key +} + +#[test] +fn sprite_pose_violation_survives_deleting_an_earlier_pose() { + let normal = sprite_pose_with_triggers(vec![Uuid::new_v4().to_string()]); + let invalid = sprite_pose_with_triggers(Vec::new()); + let invalid_pose_id = invalid.pose_id.clone(); + let store = store_with_grandfathered_sprite_poses(vec![normal, invalid]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .remove(0); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); + assert_eq!( + candidate.sprite_positions["4key"][0].poses[0].pose_id, + invalid_pose_id + ); +} + +#[test] +fn sprite_pose_violation_follows_pose_id_across_reorder() { + let normal = sprite_pose_with_triggers(vec![Uuid::new_v4().to_string()]); + let invalid = sprite_pose_with_triggers(Vec::new()); + let invalid_pose_id = invalid.pose_id.clone(); + let store = store_with_grandfathered_sprite_poses(vec![normal, invalid]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .swap(0, 1); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); + assert_eq!( + candidate.sprite_positions["4key"][0].poses[0].pose_id, + invalid_pose_id + ); +} + +#[test] +fn same_sprite_pose_violation_on_a_new_pose_id_is_rejected() { + let invalid = sprite_pose_with_triggers(Vec::new()); + let store = store_with_grandfathered_sprite_poses(vec![invalid]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .push(sprite_pose_with_triggers(Vec::new())); + 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!(validation_code(&error), Some("EMPTY_SPRITE_POSE_TRIGGERS")); +} + +#[test] +fn empty_sprite_pose_trigger_signature_is_index_independent() { + let normal = sprite_pose_with_triggers(vec![Uuid::new_v4().to_string()]); + let invalid = sprite_pose_with_triggers(Vec::new()); + let invalid_pose_id = invalid.pose_id.clone(); + let store = store_with_grandfathered_sprite_poses(vec![normal, invalid]); + let current = EditorDocumentV1::from_store(&store); + let current_key = empty_trigger_violation_key(¤t, &store, &invalid_pose_id); + let mut reordered = current.clone(); + reordered.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .swap(0, 1); + let reordered_key = empty_trigger_violation_key(&reordered, &store, &invalid_pose_id); + + assert_eq!(current_key, reordered_key); + assert_eq!(current_key.invalid_value, InvalidValueSignature::Empty); +} + +#[test] +fn preset_pose_alias_grandfathers_rekeyed_existing_violation() { + let invalid = sprite_pose_with_triggers(Vec::new()); + let store = store_with_grandfathered_sprite_poses(vec![invalid]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::LegacyPresetModeIndex, + ) + .unwrap(); +} + +#[test] +fn preset_pose_alias_rejects_new_violation() { + let normal = sprite_pose_with_triggers(vec![Uuid::new_v4().to_string()]); + let store = store_with_grandfathered_sprite_poses(vec![normal]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + candidate_store.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .push(sprite_pose_with_triggers(Vec::new())); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + let error = validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::LegacyPresetModeIndex, + ) + .unwrap_err(); + + assert_eq!(validation_code(&error), Some("EMPTY_SPRITE_POSE_TRIGGERS")); +} + #[test] fn stage_four_stable_id_grandfathers_violation_after_reorder() { let mut store = default_editor_store(); @@ -3411,3 +3768,442 @@ fn compact_request_size_boundaries_are_exact() { Some("REQUEST_TOO_LARGE") ); } + +fn sprite_violation_codes(sprite: ReactiveSpritePosition) -> BTreeSet<&'static str> { + let mut document = EditorDocumentV1::from_store(&AppStoreData::default()); + document + .sprite_positions + .insert("4key".to_string(), vec![sprite]); + let mut violations = BTreeSet::new(); + collect_sprite_violations(&document, &mut violations); + violations.iter().map(ValidationViolation::code).collect() +} + +fn sprite_with_triggers(triggers: Vec) -> ReactiveSpritePosition { + ReactiveSpritePosition { + id: Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: Uuid::new_v4().to_string(), + triggers, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + } +} + +#[test] +fn sprite_transform_constraints_follow_shared_fixture() { + let fixture = sprite_constraint_fixture(); + assert_eq!(fixture.offset.min, SPRITE_TRANSFORM_OFFSET_MIN); + assert_eq!(fixture.offset.max, SPRITE_TRANSFORM_OFFSET_MAX); + assert_eq!(fixture.rotation.min, SPRITE_TRANSFORM_ROTATION_MIN); + assert_eq!(fixture.rotation.max, SPRITE_TRANSFORM_ROTATION_MAX); + assert_eq!(fixture.scale.min, SPRITE_TRANSFORM_SCALE_MIN); + assert_eq!(fixture.scale.max, SPRITE_TRANSFORM_SCALE_MAX); + + for transform in [ + SpriteTransform { + x: fixture.offset.min, + y: fixture.offset.max, + rotation: fixture.rotation.min, + scale: fixture.scale.min, + }, + SpriteTransform { + x: fixture.offset.max, + y: fixture.offset.min, + rotation: fixture.rotation.max, + scale: fixture.scale.max, + }, + ] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + idle_transform: transform, + ..ReactiveSpritePosition::default() + }); + assert!(!violations.contains("INVALID_SPRITE_TRANSFORM")); + } + + for transform in [ + SpriteTransform { + x: fixture.offset.min - 0.000_001, + ..SpriteTransform::default() + }, + SpriteTransform { + y: fixture.offset.max + 0.000_001, + ..SpriteTransform::default() + }, + SpriteTransform { + rotation: fixture.rotation.max + 0.000_001, + ..SpriteTransform::default() + }, + SpriteTransform { + rotation: fixture.rotation.min - 0.000_001, + ..SpriteTransform::default() + }, + SpriteTransform { + scale: fixture.scale.min - 0.000_001, + ..SpriteTransform::default() + }, + SpriteTransform { + scale: fixture.scale.max + 0.000_001, + ..SpriteTransform::default() + }, + ] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + idle_transform: transform, + ..ReactiveSpritePosition::default() + }); + assert!(violations.contains("INVALID_SPRITE_TRANSFORM")); + } +} + +#[test] +fn sprite_geometry_constraints_follow_shared_fixture() { + let fixture = sprite_constraint_fixture(); + assert_eq!( + fixture.image_metrics.dimension_min, + SPRITE_IMAGE_DIMENSION_MIN + ); + assert_eq!( + fixture.image_metrics.dimension_max, + SPRITE_IMAGE_DIMENSION_MAX + ); + + for pivot in [ + crate::models::SpriteAnchor { + x: fixture.anchor.min, + y: fixture.anchor.max, + }, + crate::models::SpriteAnchor { + x: fixture.anchor.max, + y: fixture.anchor.min, + }, + ] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + pivot, + ..ReactiveSpritePosition::default() + }); + assert!(!violations.contains("INVALID_SPRITE_PIVOT")); + } + for pivot in [ + crate::models::SpriteAnchor { + x: fixture.anchor.min - 0.000_001, + y: fixture.anchor.min, + }, + crate::models::SpriteAnchor { + x: fixture.anchor.max, + y: fixture.anchor.max + 0.000_001, + }, + ] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + pivot, + ..ReactiveSpritePosition::default() + }); + assert!(violations.contains("INVALID_SPRITE_PIVOT")); + } + + for pivot in [ + crate::models::SpriteAnchor { + x: fixture.anchor.min, + y: fixture.anchor.max, + }, + crate::models::SpriteAnchor { + x: fixture.anchor.max, + y: fixture.anchor.min, + }, + ] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + poses: vec![SpritePose { + pivot: Some(pivot), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }); + assert!(!violations.contains("INVALID_SPRITE_POSE_PIVOT")); + } + for pivot in [ + crate::models::SpriteAnchor { + x: fixture.anchor.min - 0.000_001, + y: fixture.anchor.min, + }, + crate::models::SpriteAnchor { + x: fixture.anchor.max, + y: fixture.anchor.max + 0.000_001, + }, + ] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + poses: vec![SpritePose { + pivot: Some(pivot), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }); + assert!(violations.contains("INVALID_SPRITE_POSE_PIVOT")); + } +} + +#[test] +fn sprite_image_metrics_are_optional_but_invalid_or_stale_values_are_rejected() { + for sprite in [ + ReactiveSpritePosition { + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: None, + width: 0, + height: SPRITE_IMAGE_DIMENSION_MAX, + }), + ..ReactiveSpritePosition::default() + }, + ReactiveSpritePosition { + poses: vec![SpritePose { + image_override: Some("/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/pose.png".to_string(), + width: 0, + height: SPRITE_IMAGE_DIMENSION_MAX, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }, + ] { + assert!(sprite_violation_codes(sprite).contains("INVALID_SPRITE_IMAGE_METRICS")); + } + + let without_metrics = ReactiveSpritePosition { + base_image: Some("/images/base.png".to_string()), + poses: vec![SpritePose { + image_override: Some("/images/pose.png".to_string()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + assert!(!sprite_violation_codes(without_metrics).contains("STALE_SPRITE_IMAGE_METRICS")); + + let stale_codes = sprite_violation_codes(ReactiveSpritePosition { + base_image: Some("/images/base.png".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some("/images/old-base.png".to_string()), + width: 100, + height: 200, + }), + poses: vec![SpritePose { + image_override: Some("/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/old-pose.png".to_string(), + width: 50, + height: 75, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }); + assert!(stale_codes.contains("STALE_SPRITE_IMAGE_METRICS")); + + let valid_codes = sprite_violation_codes(ReactiveSpritePosition { + base_image: Some("/images/base.png".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some("/images/base.png".to_string()), + width: SPRITE_IMAGE_DIMENSION_MAX, + height: SPRITE_IMAGE_DIMENSION_MIN, + }), + poses: vec![SpritePose { + image_override: Some("/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/pose.png".to_string(), + width: SPRITE_IMAGE_DIMENSION_MIN, + height: SPRITE_IMAGE_DIMENSION_MAX, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }); + assert!(!valid_codes.contains("INVALID_SPRITE_IMAGE_METRICS")); + assert!(!valid_codes.contains("STALE_SPRITE_IMAGE_METRICS")); +} + +#[test] +fn whitespace_sprite_image_refs_follow_renderable_validation_contract() { + let whitespace_only = sprite_violation_codes(ReactiveSpritePosition { + base_image: Some(" ".to_string()), + poses: vec![SpritePose { + image_override: Some("\t \n".to_string()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }); + assert!(!whitespace_only.contains("STALE_SPRITE_IMAGE_METRICS")); + + let stale_reference = sprite_violation_codes(ReactiveSpritePosition { + base_image: Some(" ".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some(" ".to_string()), + width: 100, + height: 100, + }), + ..ReactiveSpritePosition::default() + }); + assert!(stale_reference.contains("STALE_SPRITE_IMAGE_METRICS")); + + let renderable_override = sprite_violation_codes(ReactiveSpritePosition { + base_image: Some(" ".to_string()), + poses: vec![SpritePose { + image_override: Some("/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/pose.png".to_string(), + width: 100, + height: 100, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }); + assert!(!renderable_override.contains("STALE_SPRITE_IMAGE_METRICS")); +} + +#[test] +fn sprite_image_validation_uses_existing_validation_failed_details_codes() { + let cases = [ + ( + ReactiveSpritePosition { + id: Uuid::new_v4().to_string(), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: None, + width: 0, + height: 100, + }), + ..ReactiveSpritePosition::default() + }, + "INVALID_SPRITE_IMAGE_METRICS", + ), + ( + ReactiveSpritePosition { + id: Uuid::new_v4().to_string(), + base_image: Some("/images/base.png".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some("/images/old.png".to_string()), + width: 100, + height: 100, + }), + ..ReactiveSpritePosition::default() + }, + "STALE_SPRITE_IMAGE_METRICS", + ), + ]; + + for (sprite, expected_code) in cases { + let error = validate_new_sprite(sprite).unwrap_err(); + assert_eq!( + error.error_code, + crate::errors::EditorCommitErrorCode::ValidationFailed + ); + assert_eq!( + error + .details + .and_then(|details| details.validation_code) + .as_deref(), + Some(expected_code) + ); + } +} + +#[test] +fn sprite_collection_constraints_follow_shared_fixture() { + let fixture = sprite_constraint_fixture(); + assert_eq!(fixture.max_poses, MAX_SPRITE_POSES); + assert_eq!(fixture.max_triggers_per_pose, MAX_SPRITE_POSE_TRIGGERS); + + validate_new_sprite(sprite_with_pose_count(fixture.max_poses)).unwrap(); + let error = validate_new_sprite(sprite_with_pose_count(fixture.max_poses + 1)).unwrap_err(); + assert_eq!( + error + .details + .as_ref() + .and_then(|details| details.validation_code.as_deref()), + Some("COLLECTION_TOO_LARGE") + ); + + let triggers = (0..fixture.max_triggers_per_pose) + .map(|index| Uuid::from_u128(20_000 + index as u128).to_string()) + .collect::>(); + let at_limit = sprite_violation_codes(sprite_with_triggers(triggers.clone())); + assert!(!at_limit.contains("TOO_MANY_SPRITE_POSE_TRIGGERS")); + let mut over_limit = triggers; + over_limit.push(Uuid::from_u128(20_000 + fixture.max_triggers_per_pose as u128).to_string()); + assert!(sprite_violation_codes(sprite_with_triggers(over_limit)) + .contains("TOO_MANY_SPRITE_POSE_TRIGGERS")); +} + +#[test] +fn sprite_transition_constraints_and_defaults_follow_shared_fixture() { + let fixture = sprite_constraint_fixture(); + assert_eq!(fixture.transition_ms.min, 0); + assert_eq!(fixture.transition_ms.max, SPRITE_TRANSITION_MS_MAX); + for transition_ms in [fixture.transition_ms.min, fixture.transition_ms.max] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + transition_ms, + ..ReactiveSpritePosition::default() + }); + assert!(!violations.contains("INVALID_SPRITE_TRANSITION")); + } + assert!(sprite_violation_codes(ReactiveSpritePosition { + transition_ms: fixture.transition_ms.max + 1, + ..ReactiveSpritePosition::default() + }) + .contains("INVALID_SPRITE_TRANSITION")); + + assert_eq!(fixture.press_duration_ms.min, SPRITE_PRESS_DURATION_MS_MIN); + assert_eq!(fixture.press_duration_ms.max, SPRITE_PRESS_DURATION_MS_MAX); + for press_duration_ms in [fixture.press_duration_ms.min, fixture.press_duration_ms.max] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + press_duration_ms, + ..ReactiveSpritePosition::default() + }); + assert!(!violations.contains("INVALID_SPRITE_PRESS_DURATION")); + } + for press_duration_ms in [ + fixture.press_duration_ms.min - 1, + fixture.press_duration_ms.max + 1, + ] { + assert!(sprite_violation_codes(ReactiveSpritePosition { + press_duration_ms, + ..ReactiveSpritePosition::default() + }) + .contains("INVALID_SPRITE_PRESS_DURATION")); + } + + assert_eq!( + crate::models::default_sprite_transition_ms(), + fixture.default_transition_ms + ); + assert_eq!( + crate::models::default_sprite_transition_easing(), + fixture.default_transition_easing + ); + let default_sprite = ReactiveSpritePosition::default(); + assert_eq!(default_sprite.activation, fixture.default_activation); + assert_eq!( + default_sprite.press_duration_ms, + fixture.default_press_duration_ms + ); +} + +#[test] +fn sprite_pose_trigger_count_accepts_512_and_rejects_513() { + let triggers = (1..=MAX_SPRITE_POSE_TRIGGERS) + .map(|index| Uuid::from_u128(index as u128).to_string()) + .collect::>(); + let at_limit = sprite_violation_codes(sprite_with_triggers(triggers.clone())); + assert!(!at_limit.contains("TOO_MANY_SPRITE_POSE_TRIGGERS")); + assert!(!at_limit.contains("INVALID_SPRITE_TRIGGER")); + + let mut over_limit = triggers; + over_limit.push(Uuid::from_u128(MAX_SPRITE_POSE_TRIGGERS as u128 + 1).to_string()); + let over_limit = sprite_violation_codes(sprite_with_triggers(over_limit)); + assert!(over_limit.contains("TOO_MANY_SPRITE_POSE_TRIGGERS")); +} + +#[test] +fn sprite_pose_trigger_rejects_invalid_element_id() { + let violations = + sprite_violation_codes(sprite_with_triggers(vec!["not-an-element-id".to_string()])); + assert!(violations.contains("INVALID_SPRITE_TRIGGER")); +} diff --git a/src-tauri/src/state/editor/violations.rs b/src-tauri/src/state/editor/violations.rs index 5e0e73e04..aa0b23af1 100644 --- a/src-tauri/src/state/editor/violations.rs +++ b/src-tauri/src/state/editor/violations.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::{ELEMENT_ROTATION_MAX, ELEMENT_ROTATION_MIN}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum NativeElementKind { @@ -6,6 +7,7 @@ pub(super) enum NativeElementKind { Stat, Graph, Knob, + Sprite, } #[derive(Debug, Clone, Copy)] @@ -34,6 +36,7 @@ pub(super) enum ViolationPropertyPath { GroupId, GroupReference, KnobSensitivity, + Rotation, Shadow { name: &'static str, property: &'static str, @@ -42,6 +45,14 @@ pub(super) enum ViolationPropertyPath { name: &'static str, property: &'static str, }, + SpriteProperty { + section: &'static str, + property: &'static str, + }, + SpritePoseProperty { + pose_id: String, + property: &'static str, + }, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -77,7 +88,7 @@ impl ValidationViolation { } } - fn code(&self) -> &'static str { + pub(super) fn code(&self) -> &'static str { self.key.code } } @@ -113,6 +124,18 @@ pub(crate) enum GrandfatherKeying { LegacyPresetModeIndex, } +#[derive(Default)] +pub(super) struct NativeIdAliases { + elements: HashMap, + sprite_poses: HashMap, +} + +impl NativeIdAliases { + fn is_empty(&self) -> bool { + self.elements.is_empty() && self.sprite_poses.is_empty() + } +} + /// 기존 store에 있던 손실 없는 비정상 데이터는 유지하되 새 비정상 상태는 만들지 않음 pub(crate) fn validate_document_transition( current: &EditorDocumentV1, @@ -155,7 +178,7 @@ pub(crate) fn validate_document_transition_with_keying( validate_metric_limits(current, candidate, keying)?; let native_id_alias = match keying { - GrandfatherKeying::StableId => HashMap::new(), + GrandfatherKeying::StableId => NativeIdAliases::default(), #[cfg(test)] GrandfatherKeying::ModeIndex => native_id_alias_by_slot(current, candidate), GrandfatherKeying::LegacyPresetModeIndex => native_id_alias_by_slot(current, candidate), @@ -173,45 +196,93 @@ pub(crate) fn validate_document_transition_with_keying( Ok(()) } -// 후보 요소 id → 같은 (모드, 자리)의 현재 요소 id. 프리셋 트랜잭션은 커밋 -// 직전 id를 재발급하므로 신원으로는 관용 상대를 찾을 수 없다 +// 프리셋 후보 요소와 자세 id를 같은 모드와 자리의 현재 id로 연결 fn native_id_alias_by_slot( current: &EditorDocumentV1, candidate: &EditorDocumentV1, -) -> HashMap { - let mut alias = HashMap::new(); - let mut collect = |current_ids: Vec<(&String, Vec<&str>)>, - candidate_ids: Vec<(&String, Vec<&str>)>| { - let current_by_mode = current_ids.into_iter().collect::>(); - for (mode, ids) in candidate_ids { - let Some(current_mode_ids) = current_by_mode.get(mode) else { - continue; - }; - for (index, id) in ids.into_iter().enumerate() { - if let Some(current_id) = current_mode_ids.get(index) { - alias.insert(id.to_string(), current_id.to_string()); +) -> NativeIdAliases { + let mut aliases = NativeIdAliases::default(); + { + let mut collect = |current_ids: Vec<(&String, Vec<&str>)>, + candidate_ids: Vec<(&String, Vec<&str>)>| { + let current_by_mode = current_ids.into_iter().collect::>(); + for (mode, ids) in candidate_ids { + let Some(current_mode_ids) = current_by_mode.get(mode) else { + continue; + }; + for (index, id) in ids.into_iter().enumerate() { + if let Some(current_id) = current_mode_ids.get(index) { + aliases + .elements + .insert(id.to_string(), current_id.to_string()); + } } } - } - }; + }; - collect( - key_position_ids(¤t.key_positions), - key_position_ids(&candidate.key_positions), - ); - collect( - nested_position_ids(¤t.stat_positions), - nested_position_ids(&candidate.stat_positions), - ); - collect( - nested_position_ids(¤t.graph_positions), - nested_position_ids(&candidate.graph_positions), - ); - collect( - nested_position_ids(¤t.knob_positions), - nested_position_ids(&candidate.knob_positions), + collect( + key_position_ids(¤t.key_positions), + key_position_ids(&candidate.key_positions), + ); + collect( + nested_position_ids(¤t.stat_positions), + nested_position_ids(&candidate.stat_positions), + ); + collect( + nested_position_ids(¤t.graph_positions), + nested_position_ids(&candidate.graph_positions), + ); + collect( + nested_position_ids(¤t.knob_positions), + nested_position_ids(&candidate.knob_positions), + ); + collect( + sprite_position_ids(¤t.sprite_positions), + sprite_position_ids(&candidate.sprite_positions), + ); + } + collect_sprite_pose_id_aliases( + ¤t.sprite_positions, + &candidate.sprite_positions, + &mut aliases.sprite_poses, ); - alias + aliases +} + +fn collect_sprite_pose_id_aliases( + current: &HashMap>, + candidate: &HashMap>, + aliases: &mut HashMap, +) { + for (mode, candidate_sprites) in candidate { + let Some(current_sprites) = current.get(mode) else { + continue; + }; + for (candidate_sprite, current_sprite) in candidate_sprites.iter().zip(current_sprites) { + for (candidate_pose, current_pose) in + candidate_sprite.poses.iter().zip(¤t_sprite.poses) + { + aliases.insert(candidate_pose.pose_id.clone(), current_pose.pose_id.clone()); + } + } + } +} + +fn sprite_position_ids( + collection: &HashMap>, +) -> Vec<(&String, Vec<&str>)> { + collection + .iter() + .map(|(mode, positions)| { + ( + mode, + positions + .iter() + .map(|position| position.id.as_str()) + .collect(), + ) + }) + .collect() } fn key_position_ids(collection: &HashMap>) -> Vec<(&String, Vec<&str>)> { @@ -271,7 +342,7 @@ impl HasKeyPosition for KnobPosition { pub(super) fn is_grandfathered( current_violation_keys: &BTreeSet, candidate: &ValidationViolation, - native_id_alias: &HashMap, + native_id_alias: &NativeIdAliases, ) -> bool { if current_violation_keys.contains(&candidate.key) { return true; @@ -283,16 +354,28 @@ pub(super) fn is_grandfathered( let ViolationOwner::NativeElement { kind, id } = &candidate.key.owner else { return false; }; - let Some(current_id) = native_id_alias.get(id) else { + let Some(current_id) = native_id_alias.elements.get(id) else { return false; }; + let property_path = match &candidate.key.property_path { + ViolationPropertyPath::SpritePoseProperty { pose_id, property } => { + let Some(current_pose_id) = native_id_alias.sprite_poses.get(pose_id) else { + return false; + }; + ViolationPropertyPath::SpritePoseProperty { + pose_id: current_pose_id.clone(), + property, + } + } + property_path => property_path.clone(), + }; let aliased = ViolationKey { owner: ViolationOwner::NativeElement { kind: *kind, id: current_id.clone(), }, code: candidate.key.code, - property_path: candidate.key.property_path.clone(), + property_path, invalid_value: candidate.key.invalid_value.clone(), }; current_violation_keys.contains(&aliased) @@ -305,7 +388,7 @@ fn is_unconditional_structural_violation(code: &str) -> bool { ) } -fn allowed_modes(store: &AppStoreData) -> HashSet { +pub(super) fn allowed_modes(store: &AppStoreData) -> HashSet { default_keys() .keys() .cloned() @@ -331,7 +414,7 @@ fn native_violation_key( } } -fn collect_violations( +pub(super) fn collect_violations( document: &EditorDocumentV1, allowed_modes: &HashSet, ) -> BTreeSet { @@ -343,6 +426,7 @@ fn collect_violations( .chain(document.stat_positions.keys()) .chain(document.graph_positions.keys()) .chain(document.knob_positions.keys()) + .chain(document.sprite_positions.keys()) .chain(document.layer_groups.keys()) .cloned() .collect::>(); @@ -386,6 +470,12 @@ fn collect_violations( allowed_modes, &mut violations, ); + collect_collection_violations( + "spritePositions", + &document.sprite_positions, + allowed_modes, + &mut violations, + ); collect_collection_violations( "layerGroups", &document.layer_groups, @@ -447,6 +537,7 @@ fn collect_violations( } collect_position_style_violations(document, &mut violations); + collect_sprite_violations(document, &mut violations); let group_ids = collect_group_violations(document, &mut violations); collect_group_reference_violations(document, &group_ids, &mut violations); violations @@ -519,6 +610,22 @@ fn collect_position_style_violations( }), ) { + if !position.rotation.is_finite() + || !(ELEMENT_ROTATION_MIN..=ELEMENT_ROTATION_MAX).contains(&position.rotation) + { + violations.insert(ValidationViolation::new( + native_violation_key( + kind, + &position.id, + "INVALID_ROTATION", + ViolationPropertyPath::Rotation, + InvalidValueSignature::FloatBits(position.rotation.to_bits()), + ), + format!( + "{field} {mode}[{index}].rotation must be finite within {ELEMENT_ROTATION_MIN}..={ELEMENT_ROTATION_MAX}" + ), + )); + } for (name, shadow) in [ ("shadow", position.shadow.as_ref()), ("activeShadow", position.active_shadow.as_ref()), @@ -565,6 +672,396 @@ fn collect_position_style_violations( } } +pub(super) fn collect_sprite_violations( + document: &EditorDocumentV1, + violations: &mut BTreeSet, +) { + for (mode, sprites) in &document.sprite_positions { + for (sprite_index, sprite) in sprites.iter().enumerate() { + if !sprite.rotation.is_finite() + || !(ELEMENT_ROTATION_MIN..=ELEMENT_ROTATION_MAX).contains(&sprite.rotation) + { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_ROTATION", + ViolationPropertyPath::Rotation, + InvalidValueSignature::FloatBits(sprite.rotation.to_bits()), + ), + format!( + "spritePositions {mode}[{sprite_index}].rotation must be finite within {ELEMENT_ROTATION_MIN}..={ELEMENT_ROTATION_MAX}" + ), + )); + } + collect_sprite_transform_violations( + sprite, + mode, + sprite_index, + "idleTransform", + None, + &sprite.idle_transform, + violations, + ); + + for (property, value) in [("x", sprite.pivot.x), ("y", sprite.pivot.y)] { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_PIVOT", + ViolationPropertyPath::SpriteProperty { + section: "pivot", + property, + }, + InvalidValueSignature::FloatBits(value.to_bits()), + ), + format!( + "spritePositions {mode}[{sprite_index}].pivot.{property} must be between 0 and 1" + ), + )); + } + } + + if sprite.transition_ms > SPRITE_TRANSITION_MS_MAX { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_TRANSITION", + ViolationPropertyPath::SpriteProperty { + section: "transition", + property: "transitionMs", + }, + InvalidValueSignature::Count(sprite.transition_ms as usize), + ), + format!( + "spritePositions {mode}[{sprite_index}].transitionMs exceeds {SPRITE_TRANSITION_MS_MAX}" + ), + )); + } + + if !(SPRITE_PRESS_DURATION_MS_MIN..=SPRITE_PRESS_DURATION_MS_MAX) + .contains(&sprite.press_duration_ms) + { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_PRESS_DURATION", + ViolationPropertyPath::SpriteProperty { + section: "activation", + property: "pressDurationMs", + }, + InvalidValueSignature::Count(sprite.press_duration_ms as usize), + ), + format!( + "spritePositions {mode}[{sprite_index}].pressDurationMs must be between {SPRITE_PRESS_DURATION_MS_MIN} and {SPRITE_PRESS_DURATION_MS_MAX}" + ), + )); + } + + let mut trigger_sets = HashSet::new(); + for (pose_index, pose) in sprite.poses.iter().enumerate() { + collect_sprite_transform_violations( + sprite, + mode, + sprite_index, + "poseTransform", + Some((pose_index, &pose.pose_id)), + &pose.transform, + violations, + ); + if let Some(pivot) = pose.pivot.as_ref() { + for (property, value) in [("x", pivot.x), ("y", pivot.y)] { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_POSE_PIVOT", + ViolationPropertyPath::SpritePoseProperty { + pose_id: pose.pose_id.clone(), + property: if property == "x" { + "pivot.x" + } else { + "pivot.y" + }, + }, + InvalidValueSignature::FloatBits(value.to_bits()), + ), + format!( + "spritePositions {mode}[{sprite_index}].poses[{pose_index}].pivot.{property} must be between 0 and 1" + ), + )); + } + } + } + if let Some(metrics) = pose.image_override_metrics.as_ref() { + for (property, value) in [("width", metrics.width), ("height", metrics.height)] + { + if !(SPRITE_IMAGE_DIMENSION_MIN..=SPRITE_IMAGE_DIMENSION_MAX) + .contains(&value) + { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_IMAGE_METRICS", + ViolationPropertyPath::SpritePoseProperty { + pose_id: pose.pose_id.clone(), + property: if property == "width" { + "imageOverrideMetrics.width" + } else { + "imageOverrideMetrics.height" + }, + }, + InvalidValueSignature::Count(value as usize), + ), + format!( + "spritePositions {mode}[{sprite_index}].poses[{pose_index}].imageOverrideMetrics.{property} must be between {SPRITE_IMAGE_DIMENSION_MIN} and {SPRITE_IMAGE_DIMENSION_MAX}" + ), + )); + } + } + } + if pose.triggers.len() > MAX_SPRITE_POSE_TRIGGERS { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "TOO_MANY_SPRITE_POSE_TRIGGERS", + ViolationPropertyPath::SpritePoseProperty { + pose_id: pose.pose_id.clone(), + property: "triggers", + }, + InvalidValueSignature::Count(pose.triggers.len()), + ), + format!( + "spritePositions {mode}[{sprite_index}].poses[{pose_index}].triggers exceeds {MAX_SPRITE_POSE_TRIGGERS}" + ), + )); + } + for trigger in &pose.triggers { + if crate::state::native_element_id::is_valid_element_id(trigger) { + continue; + } + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_TRIGGER", + ViolationPropertyPath::SpritePoseProperty { + pose_id: pose.pose_id.clone(), + property: "triggers", + }, + InvalidValueSignature::Text(trigger.clone()), + ), + format!( + "spritePositions {mode}[{sprite_index}].poses[{pose_index}].triggers contains an invalid element ID" + ), + )); + } + if pose.triggers.is_empty() { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "EMPTY_SPRITE_POSE_TRIGGERS", + ViolationPropertyPath::SpritePoseProperty { + pose_id: pose.pose_id.clone(), + property: "triggers", + }, + InvalidValueSignature::Empty, + ), + format!( + "spritePositions {mode}[{sprite_index}].poses[{pose_index}].triggers must not be empty" + ), + )); + } else if !trigger_sets.insert(pose.triggers.clone()) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "DUPLICATE_SPRITE_POSE_TRIGGERS", + ViolationPropertyPath::SpritePoseProperty { + pose_id: pose.pose_id.clone(), + property: "triggers", + }, + InvalidValueSignature::Text(format!("{:?}", pose.triggers)), + ), + format!( + "spritePositions {mode}[{sprite_index}] contains duplicate pose trigger sets" + ), + )); + } + } + + if let Some(reference) = sprite.reference_natural_size.as_ref() { + for (property, value) in [("width", reference.width), ("height", reference.height)] + { + if !(SPRITE_IMAGE_DIMENSION_MIN..=SPRITE_IMAGE_DIMENSION_MAX).contains(&value) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_IMAGE_METRICS", + ViolationPropertyPath::SpriteProperty { + section: "referenceNaturalSize", + property, + }, + InvalidValueSignature::Count(value as usize), + ), + format!( + "spritePositions {mode}[{sprite_index}].referenceNaturalSize.{property} must be between {SPRITE_IMAGE_DIMENSION_MIN} and {SPRITE_IMAGE_DIMENSION_MAX}" + ), + )); + } + } + } + + let base_image = sprite + .base_image + .as_deref() + .filter(|image_ref| is_renderable_image_ref(Some(image_ref))); + match (base_image, &sprite.reference_natural_size) { + (Some(base_image), Some(reference)) + if reference.source.as_deref() != Some(base_image) => + { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "STALE_SPRITE_IMAGE_METRICS", + ViolationPropertyPath::SpriteProperty { + section: "referenceNaturalSize", + property: "source", + }, + reference.source.as_ref().map_or( + InvalidValueSignature::None, + |source| InvalidValueSignature::Text(source.clone()), + ), + ), + format!( + "spritePositions {mode}[{sprite_index}].referenceNaturalSize.source must match baseImage" + ), + )); + } + (None, Some(reference)) if reference.source.is_some() => { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "STALE_SPRITE_IMAGE_METRICS", + ViolationPropertyPath::SpriteProperty { + section: "referenceNaturalSize", + property: "source", + }, + InvalidValueSignature::Text( + reference.source.clone().unwrap_or_default(), + ), + ), + format!( + "spritePositions {mode}[{sprite_index}].referenceNaturalSize.source must be null without baseImage" + ), + )); + } + _ => {} + } + + for (pose_index, pose) in sprite.poses.iter().enumerate() { + let Some(image_override) = pose + .image_override + .as_deref() + .filter(|image_ref| is_renderable_image_ref(Some(image_ref))) + else { + continue; + }; + if let Some(metrics) = pose + .image_override_metrics + .as_ref() + .filter(|metrics| metrics.source != image_override) + { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "STALE_SPRITE_IMAGE_METRICS", + ViolationPropertyPath::SpritePoseProperty { + pose_id: pose.pose_id.clone(), + property: "imageOverrideMetrics.source", + }, + InvalidValueSignature::Text(metrics.source.clone()), + ), + format!( + "spritePositions {mode}[{sprite_index}].poses[{pose_index}].imageOverrideMetrics.source must match imageOverride" + ), + )); + } + } + } + } +} + +fn collect_sprite_transform_violations( + sprite: &ReactiveSpritePosition, + mode: &str, + sprite_index: usize, + section: &'static str, + pose_identity: Option<(usize, &str)>, + transform: &SpriteTransform, + violations: &mut BTreeSet, +) { + for (property, value, minimum, maximum) in [ + ( + "x", + transform.x, + SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_OFFSET_MAX, + ), + ( + "y", + transform.y, + SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_OFFSET_MAX, + ), + ( + "rotation", + transform.rotation, + SPRITE_TRANSFORM_ROTATION_MIN, + SPRITE_TRANSFORM_ROTATION_MAX, + ), + ( + "scale", + transform.scale, + SPRITE_TRANSFORM_SCALE_MIN, + SPRITE_TRANSFORM_SCALE_MAX, + ), + ] { + if !value.is_finite() || !(minimum..=maximum).contains(&value) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_TRANSFORM", + pose_identity.map_or( + ViolationPropertyPath::SpriteProperty { section, property }, + |(_, pose_id)| ViolationPropertyPath::SpritePoseProperty { + pose_id: pose_id.to_string(), + property, + }, + ), + InvalidValueSignature::FloatBits(value.to_bits()), + ), + format!( + "spritePositions {mode}[{sprite_index}].{section}.{property} must be between {minimum} and {maximum}" + ), + )); + } + } +} + fn collect_image_transform_violations( element: NativeElementDiagnostic<'_>, name: &'static str, @@ -842,4 +1339,28 @@ fn collect_group_reference_violations( )); } } + for (mode, sprites) in &document.sprite_positions { + for (index, sprite) in sprites.iter().enumerate() { + let Some(group_id) = sprite.group_id.as_deref() else { + continue; + }; + if !group_ids + .get(mode) + .is_some_and(|ids| ids.contains(group_id)) + { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "UNKNOWN_GROUP_ID", + ViolationPropertyPath::GroupReference, + InvalidValueSignature::Text(group_id.to_string()), + ), + format!( + "spritePositions {mode}[{index}] references unknown group '{group_id}'" + ), + )); + } + } + } } diff --git a/src-tauri/src/state/editor_ops.rs b/src-tauri/src/state/editor_ops.rs index be170a694..8431a4117 100644 --- a/src-tauri/src/state/editor_ops.rs +++ b/src-tauri/src/state/editor_ops.rs @@ -12,17 +12,18 @@ use crate::{ EditorOpResultStatusV1, EditorOpResultV1, EditorOpV1, EditorPaintDescriptorV1, EditorPaintGradientV1, EditorShadowLeafPatchV1, EditorTargetGroupV1, EditorZUpdateV1, ElementShadowSpec, GradientSpec, ImageMode, ImageTransform, ImageTransformLeafPatchV1, - KeyPosition, LayerGroupDef, NoteColor, NoteGradientShadow, IMAGE_TRANSFORM_OFFSET_MAX, - IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, - IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, - SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, + KeyPosition, LayerGroupDef, NoteColor, NoteGradientShadow, ReactiveSpritePosition, + SpriteTransform, IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, + IMAGE_TRANSFORM_SCALE_MIN, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, + SHADOW_OFFSET_MIN, SPRITE_TRANSFORM_OFFSET_MAX, SPRITE_TRANSFORM_OFFSET_MIN, }, }; use super::editor::{ validate_document_transition, validate_editor_op_bounds, validate_editor_op_target_type, }; -use super::native_element_id::DUPLICATE_ELEMENT_ID; +use super::native_element_id::{validate_document_element_ids, DUPLICATE_ELEMENT_ID}; use super::plugin::{plugin_group_refs_from_store, PluginGroupRefs}; mod property_patch; diff --git a/src-tauri/src/state/editor_ops/structural_ops.rs b/src-tauri/src/state/editor_ops/structural_ops.rs index 645858e31..3158483c5 100644 --- a/src-tauri/src/state/editor_ops/structural_ops.rs +++ b/src-tauri/src/state/editor_ops/structural_ops.rs @@ -7,16 +7,30 @@ pub(super) struct ElementLocation { pub(super) index: usize, } +// key 계열 위치와 스프라이트가 공유하는 공통 필드의 가변 참조 묶음 +pub(super) struct ElementCommonMut<'a> { + pub(super) dx: &'a mut f64, + pub(super) dy: &'a mut f64, + pub(super) width: &'a mut f64, + pub(super) height: &'a mut f64, + pub(super) hidden: &'a mut bool, + pub(super) z_index: &'a mut Option, + pub(super) layer_name: &'a mut Option, + pub(super) group_id: &'a mut Option, + pub(super) class_name: &'a mut Option, + pub(super) use_inline_styles: &'a mut Option, +} + fn insert_location( locations: &mut HashMap, element_type: EditorElementTypeV1, mode: &str, index: usize, - position: &KeyPosition, + id: &str, ) -> Result<(), EditorCommitError> { if locations .insert( - position.id.clone(), + id.to_string(), ElementLocation { element_type, mode: mode.to_string(), @@ -27,7 +41,7 @@ fn insert_location( { return Err(EditorCommitError::validation( DUPLICATE_ELEMENT_ID, - format!("native element ID {} is not globally unique", position.id), + format!("native element ID {id} is not globally unique"), )); } Ok(()) @@ -51,6 +65,11 @@ pub(super) fn build_element_locator( .knob_positions .values() .map(Vec::len) + .sum::() + + document + .sprite_positions + .values() + .map(Vec::len) .sum::(); let mut locations = HashMap::with_capacity(element_count); @@ -61,7 +80,7 @@ pub(super) fn build_element_locator( EditorElementTypeV1::Key, mode, index, - position, + &position.id, )?; } } @@ -72,7 +91,7 @@ pub(super) fn build_element_locator( EditorElementTypeV1::Stat, mode, index, - &position.position, + &position.position.id, )?; } } @@ -83,7 +102,7 @@ pub(super) fn build_element_locator( EditorElementTypeV1::Graph, mode, index, - &position.position, + &position.position.id, )?; } } @@ -94,7 +113,18 @@ pub(super) fn build_element_locator( EditorElementTypeV1::Knob, mode, index, - &position.position, + &position.position.id, + )?; + } + } + for (mode, positions) in &document.sprite_positions { + for (index, position) in positions.iter().enumerate() { + insert_location( + &mut locations, + EditorElementTypeV1::Sprite, + mode, + index, + &position.id, )?; } } @@ -102,6 +132,91 @@ pub(super) fn build_element_locator( Ok(locations) } +fn key_position_common_mut(position: &mut KeyPosition) -> ElementCommonMut<'_> { + ElementCommonMut { + dx: &mut position.dx, + dy: &mut position.dy, + width: &mut position.width, + height: &mut position.height, + hidden: &mut position.hidden, + z_index: &mut position.z_index, + layer_name: &mut position.layer_name, + group_id: &mut position.group_id, + class_name: &mut position.class_name, + use_inline_styles: &mut position.use_inline_styles, + } +} + +fn sprite_common_mut(position: &mut ReactiveSpritePosition) -> ElementCommonMut<'_> { + ElementCommonMut { + dx: &mut position.dx, + dy: &mut position.dy, + width: &mut position.width, + height: &mut position.height, + hidden: &mut position.hidden, + z_index: &mut position.z_index, + layer_name: &mut position.layer_name, + group_id: &mut position.group_id, + class_name: &mut position.class_name, + use_inline_styles: &mut position.use_inline_styles, + } +} + +pub(super) fn element_common_at_mut<'a>( + document: &'a mut EditorDocumentV1, + location: &ElementLocation, +) -> Result, EditorCommitError> { + let common = match location.element_type { + EditorElementTypeV1::Key => document + .key_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(key_position_common_mut), + EditorElementTypeV1::Stat => document + .stat_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(|position| key_position_common_mut(&mut position.position)), + EditorElementTypeV1::Graph => document + .graph_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(|position| key_position_common_mut(&mut position.position)), + EditorElementTypeV1::Knob => document + .knob_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(|position| key_position_common_mut(&mut position.position)), + EditorElementTypeV1::Sprite => document + .sprite_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(sprite_common_mut), + }; + common.ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "native element locator no longer matches the editor document", + ) + }) +} + +pub(super) fn sprite_at_mut<'a>( + document: &'a mut EditorDocumentV1, + location: &ElementLocation, +) -> Result<&'a mut ReactiveSpritePosition, EditorCommitError> { + document + .sprite_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "native element locator no longer matches the editor document", + ) + }) +} + pub(super) fn position_at_mut<'a>( document: &'a mut EditorDocumentV1, location: &ElementLocation, @@ -126,6 +241,7 @@ pub(super) fn position_at_mut<'a>( .get_mut(&location.mode) .and_then(|positions| positions.get_mut(location.index)) .map(|position| &mut position.position), + EditorElementTypeV1::Sprite => None, }; position.ok_or_else(|| { EditorCommitError::validation( @@ -144,12 +260,32 @@ pub(super) fn bounds_of(position: &KeyPosition) -> EditorBoundsV1 { } } +pub(super) fn element_bounds( + document: &EditorDocumentV1, + location: &ElementLocation, +) -> Option { + match location.element_type { + EditorElementTypeV1::Sprite => document + .sprite_positions + .get(&location.mode) + .and_then(|positions| positions.get(location.index)) + .map(|position| EditorBoundsV1 { + dx: position.dx, + dy: position.dy, + width: position.width, + height: position.height, + }), + _ => position_at(document, location).map(bounds_of), + } +} + fn frozen_element_type(element: &EditorFrozenElementV1) -> EditorElementTypeV1 { match element { EditorFrozenElementV1::Key { .. } => EditorElementTypeV1::Key, EditorFrozenElementV1::Stat { .. } => EditorElementTypeV1::Stat, EditorFrozenElementV1::Graph { .. } => EditorElementTypeV1::Graph, EditorFrozenElementV1::Knob { .. } => EditorElementTypeV1::Knob, + EditorFrozenElementV1::Sprite { .. } => EditorElementTypeV1::Sprite, } } @@ -159,6 +295,7 @@ fn frozen_element_group_id(element: &EditorFrozenElementV1) -> Option<&str> { EditorFrozenElementV1::Stat { position } => position.position.group_id.as_deref(), EditorFrozenElementV1::Graph { position } => position.position.group_id.as_deref(), EditorFrozenElementV1::Knob { position } => position.position.group_id.as_deref(), + EditorFrozenElementV1::Sprite { position } => position.group_id.as_deref(), } } @@ -204,6 +341,13 @@ fn frozen_element_matches( .and_then(|positions| positions.get(location.index)) == Some(position) } + EditorFrozenElementV1::Sprite { position } => { + document + .sprite_positions + .get(&location.mode) + .and_then(|positions| positions.get(location.index)) + == Some(position) + } } } @@ -218,8 +362,19 @@ fn z_update_matches( update: &EditorZUpdateV1, ) -> bool { location.element_type == update.element_type - && position_at(document, location) - .is_some_and(|position| position.z_index.unwrap_or_default() == update.z_index) + && element_z_index(document, location) + .is_some_and(|z_index| z_index.unwrap_or_default() == update.z_index) +} + +fn element_z_index(document: &EditorDocumentV1, location: &ElementLocation) -> Option> { + match location.element_type { + EditorElementTypeV1::Sprite => document + .sprite_positions + .get(&location.mode) + .and_then(|positions| positions.get(location.index)) + .map(|position| position.z_index), + _ => position_at(document, location).map(|position| position.z_index), + } } fn position_at<'a>( @@ -246,6 +401,7 @@ fn position_at<'a>( .get(&location.mode) .and_then(|positions| positions.get(location.index)) .map(|position| &position.position), + EditorElementTypeV1::Sprite => None, } } @@ -282,6 +438,11 @@ fn append_frozen_element( .entry(mode.to_string()) .or_default() .push(position.clone()), + EditorFrozenElementV1::Sprite { position } => document + .sprite_positions + .entry(mode.to_string()) + .or_default() + .push(position.clone()), } } @@ -301,8 +462,14 @@ pub(super) fn apply_frozen_insert( .iter() .cloned() .map(|mut element| { - element.position_mut().canonicalize_gradient_pairs(); - element.position_mut().canonicalize_image_mode(); + if let Some(position) = element.key_position_mut() { + position.canonicalize_gradient_pairs(); + position.canonicalize_image_mode(); + } else if let EditorFrozenElementV1::Sprite { position } = &mut element { + for pose in &mut position.poses { + pose.normalize_triggers(); + } + } element }) .collect(); @@ -412,9 +579,9 @@ pub(super) fn apply_frozen_insert( let location = locations .get(&update.id) .expect("z target was validated above"); - let position = position_at_mut(&mut candidate, location)?; - if position.z_index.unwrap_or_default() != update.z_index { - position.z_index = Some(update.z_index); + let common = element_common_at_mut(&mut candidate, location)?; + if common.z_index.unwrap_or_default() != update.z_index { + *common.z_index = Some(update.z_index); } } Ok((candidate, EditorOpResultStatusV1::Applied)) @@ -511,9 +678,9 @@ pub(super) fn apply_reorder( let location = locations .get(&update.id) .expect("reorder target was validated above"); - let position = position_at_mut(&mut candidate, location)?; - if position.z_index != Some(update.z_index) { - position.z_index = Some(update.z_index); + let common = element_common_at_mut(&mut candidate, location)?; + if *common.z_index != Some(update.z_index) { + *common.z_index = Some(update.z_index); changed = true; } } @@ -521,9 +688,9 @@ pub(super) fn apply_reorder( let location = locations .get(&update.id) .expect("reorder group target was validated above"); - let position = position_at_mut(&mut candidate, location)?; - if position.group_id != update.group_id { - position.group_id.clone_from(&update.group_id); + let common = element_common_at_mut(&mut candidate, location)?; + if *common.group_id != update.group_id { + common.group_id.clone_from(&update.group_id); changed = true; } } @@ -612,9 +779,9 @@ pub(super) fn apply_set_element_groups( let location = locations .get(&target.id) .expect("group target was validated above"); - let position = position_at_mut(&mut candidate, location)?; - if position.group_id.as_ref() != next_group_id { - position.group_id = next_group_id.cloned(); + let common = element_common_at_mut(&mut candidate, location)?; + if common.group_id.as_ref() != next_group_id { + *common.group_id = next_group_id.cloned(); } } remove_empty_layer_groups( @@ -660,11 +827,94 @@ pub(super) fn apply_rename_layer_group( (candidate, EditorOpResultStatusV1::Applied) } -pub(super) fn apply_bounds(position: &mut KeyPosition, bounds: &EditorBoundsV1) { - position.dx = bounds.dx; - position.dy = bounds.dy; - position.width = bounds.width; - position.height = bounds.height; +pub(super) fn apply_bounds(common: ElementCommonMut<'_>, bounds: &EditorBoundsV1) { + *common.dx = bounds.dx; + *common.dy = bounds.dy; + *common.width = bounds.width; + *common.height = bounds.height; +} + +#[allow(clippy::neg_cmp_op_on_partial_ord)] +pub(super) fn sprite_resize_ratio(prev: f64, next: f64) -> f64 { + if !(prev > 0.0) || !prev.is_finite() || !(next > 0.0) || !next.is_finite() { + return 1.0; + } + let ratio = next / prev; + if ratio.is_finite() { + ratio + } else { + 1.0 + } +} + +pub(super) fn scale_sprite_resize_value(value: f64, ratio: f64) -> f64 { + // 배율 1은 클램프 없는 완전 passthrough - 순수 이동이 검증상 유효한 + // 극소 치수(하한 미만)까지 비트 단위로 보존한다 + if ratio == 1.0 { + return value; + } + (value * ratio).clamp(SPRITE_TRANSFORM_OFFSET_MIN, SPRITE_TRANSFORM_OFFSET_MAX) +} + +fn scale_sprite_transform_offsets(transform: &mut SpriteTransform, sx: f64, sy: f64) { + transform.x = scale_sprite_resize_value(transform.x, sx); + transform.y = scale_sprite_resize_value(transform.y, sy); +} + +pub(super) fn apply_sprite_resize(sprite: &mut ReactiveSpritePosition, bounds: &EditorBoundsV1) { + let sx = sprite_resize_ratio(sprite.width, bounds.width); + let sy = sprite_resize_ratio(sprite.height, bounds.height); + + sprite.dx = bounds.dx; + sprite.dy = bounds.dy; + sprite.width = bounds.width; + sprite.height = bounds.height; + scale_sprite_transform_offsets(&mut sprite.idle_transform, sx, sy); + for pose in &mut sprite.poses { + scale_sprite_transform_offsets(&mut pose.transform, sx, sy); + } +} + +pub(super) fn apply_bounds_op( + candidate: &mut EditorDocumentV1, + locations: &HashMap, + op_index: usize, + id: &str, + bounds: EditorBoundsV1, + mutate: F, +) -> Result +where + F: FnOnce( + &mut EditorDocumentV1, + &ElementLocation, + &EditorBoundsV1, + ) -> Result<(), EditorCommitError>, +{ + let Some(location) = locations.get(id) else { + validate_editor_op_bounds(op_index, None, bounds)?; + return Ok(EditorOpResultV1 { + status: EditorOpResultStatusV1::TargetMissing, + bounds: None, + }); + }; + + let current_bounds = element_bounds(candidate, location).ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "native element locator no longer matches the editor document", + ) + })?; + validate_editor_op_bounds(op_index, Some(current_bounds), bounds)?; + let status = if current_bounds == bounds { + EditorOpResultStatusV1::NoChange + } else { + mutate(candidate, location, &bounds)?; + EditorOpResultStatusV1::Applied + }; + Ok(EditorOpResultV1 { + status, + bounds: element_bounds(candidate, location), + }) } pub(super) fn delete_elements( @@ -707,6 +957,11 @@ pub(super) fn delete_elements( positions.retain(|position| !ids.contains(&position.position.id)); } } + if let Some(ids) = delete_ids.get(&EditorElementTypeV1::Sprite) { + for positions in document.sprite_positions.values_mut() { + positions.retain(|position| !ids.contains(&position.id)); + } + } } pub(super) fn remove_empty_layer_groups( @@ -733,6 +988,9 @@ pub(super) fn remove_empty_layer_groups( for position in document.knob_positions.get(mode).into_iter().flatten() { collect(position.position.group_id.as_deref()); } + for position in document.sprite_positions.get(mode).into_iter().flatten() { + collect(position.group_id.as_deref()); + } // 플러그인 멤버만 남은 그룹도 생존 - 참조 집합은 커밋 후 상태 기준 // (gesture는 요청 동봉 plugin_changes, editor 단독은 store decode) let plugin_refs = plugin_group_refs.get(mode); diff --git a/src-tauri/src/state/editor_ops/tests.rs b/src-tauri/src/state/editor_ops/tests.rs index ff769fc77..6714b8d63 100644 --- a/src-tauri/src/state/editor_ops/tests.rs +++ b/src-tauri/src/state/editor_ops/tests.rs @@ -4,13 +4,107 @@ use crate::{ AppStoreData, EditorElementGroupTargetV1, EditorElementPropertyPatchV1, EditorFrozenElementV1, EditorFrozenGroupV1, EditorFrozenKeySlotV1, EditorGroupUpdateV1, EditorOpResultStatusV1, EditorOpV1, EditorTargetGroupV1, EditorZUpdateV1, GraphPosition, - GraphStatType, GraphType, KeyPosition, KnobPosition, StatPosition, StatType, + GraphStatType, GraphType, KeyPosition, KnobPosition, LayerGroupDef, ReactiveSpritePosition, + SpriteAnchor, SpriteImageMetrics, SpritePose, SpriteReferenceNaturalSize, SpriteTransform, + StatPosition, StatType, + }, + state::editor::{MAX_ABS_COORDINATE, MAX_DIMENSION}, + state::native_element_id::{ + backfill_store_element_ids, DUPLICATE_ELEMENT_ID, INVALID_ELEMENT_ID, }, - state::native_element_id::backfill_store_element_ids, }; use super::*; +const SPRITE_RESIZE_SCALE_PARITY_FIXTURE: &str = + include_str!("../../../../tests/fixtures/sprite-resize-scale-parity.json"); + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SpriteResizeScaleParityFixture { + version: u16, + comment: String, + ranges: SpriteResizeScaleParityRanges, + cases: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct SpriteResizeScaleParityRanges { + offset: [f64; 2], +} + +#[derive(Debug, Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +enum SpriteResizeScaleParityField { + Ratio, + Offset, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SpriteResizeScaleParityCase { + name: String, + field: SpriteResizeScaleParityField, + prev: f64, + next: f64, + value: Option, + #[serde(rename = "expected")] + _expected: f64, + expected_hex: String, +} + +fn resize_sprite_op(id: impl Into, bounds: EditorBoundsV1) -> EditorOpV1 { + EditorOpV1::ResizeSprite { + id: id.into(), + bounds, + } +} + +fn sprite_bounds(position: &ReactiveSpritePosition) -> EditorBoundsV1 { + EditorBoundsV1 { + dx: position.dx, + dy: position.dy, + width: position.width, + height: position.height, + } +} + +#[test] +fn sprite_resize_scale_math_matches_shared_fixture_bits() { + let fixture: SpriteResizeScaleParityFixture = + serde_json::from_str(SPRITE_RESIZE_SCALE_PARITY_FIXTURE).unwrap(); + assert_eq!(fixture.version, 1); + assert!(!fixture.comment.is_empty()); + assert_eq!( + fixture.ranges.offset, + [SPRITE_TRANSFORM_OFFSET_MIN, SPRITE_TRANSFORM_OFFSET_MAX] + ); + for case in fixture.cases { + let expected_bits = u64::from_str_radix( + case.expected_hex + .strip_prefix("0x") + .expect("expectedHex must use the 0x prefix"), + 16, + ) + .expect("expectedHex must contain IEEE754 f64 bits"); + let ratio = sprite_resize_ratio(case.prev, case.next); + let actual = match case.field { + SpriteResizeScaleParityField::Ratio => ratio, + SpriteResizeScaleParityField::Offset => scale_sprite_resize_value( + case.value.expect("scaled case must contain value"), + ratio, + ), + }; + assert_eq!( + actual.to_bits(), + expected_bits, + "case {} result bits", + case.name + ); + } +} + fn base_store() -> AppStoreData { let mut store = AppStoreData { keys: default_keys().clone(), @@ -24,6 +118,15 @@ fn base_store() -> AppStoreData { store } +fn store_with_sprite() -> AppStoreData { + let mut store = base_store(); + store + .sprite_positions + .insert("4key".to_string(), vec![ReactiveSpritePosition::default()]); + backfill_store_element_ids(&mut store); + store +} + fn insert_op(store: &AppStoreData) -> EditorOpV1 { let mut position = KeyPosition { id: uuid::Uuid::new_v4().to_string(), @@ -81,6 +184,9 @@ fn store_with_every_reorder_type() -> AppStoreData { position: KeyPosition::default(), }], ); + store + .sprite_positions + .insert("4key".to_string(), vec![ReactiveSpritePosition::default()]); backfill_store_element_ids(&mut store); store } @@ -116,6 +222,13 @@ fn complete_reorder_op(store: &AppStoreData) -> EditorOpV1 { .map(|position| position.position.id.clone()) .collect(), ), + ( + EditorElementTypeV1::Sprite, + store.sprite_positions["4key"] + .iter() + .map(|position| position.id.clone()) + .collect(), + ), ] { for id in ids { z_updates.push(EditorZUpdateV1 { @@ -416,9 +529,10 @@ fn patch_target_type_matrix() -> Vec { KeyCounterAlignMode, KeyCounterPlacement, NoteAlignment, StatType, }; use EditorElementPropertyPatchV1 as Patch; - use EditorElementTypeV1::{Graph, Key, Knob, Stat}; + use EditorElementTypeV1::{Graph, Key, Knob, Sprite, Stat}; - const ALL: &[EditorElementTypeV1] = &[Key, Stat, Graph, Knob]; + const ALL: &[EditorElementTypeV1] = &[Key, Stat, Graph, Knob, Sprite]; + const KEY_POSITION_TYPES: &[EditorElementTypeV1] = &[Key, Stat, Graph, Knob]; const GRAPH_ONLY: &[EditorElementTypeV1] = &[Graph]; const KNOB_ONLY: &[EditorElementTypeV1] = &[Knob]; const STAT_ONLY: &[EditorElementTypeV1] = &[Stat]; @@ -459,13 +573,29 @@ fn patch_target_type_matrix() -> Vec { row("sensitivity", KNOB_ONLY, Patch::Sensitivity(1.5)), row("axisId", KNOB_ONLY, Patch::AxisId("axis-2".to_string())), row("useInlineStyles", ALL, Patch::UseInlineStyles(true)), - row("fontWeight", ALL, Patch::FontWeight(700)), - row("fontBold", ALL, Patch::FontBold(true)), - row("fontItalic", ALL, Patch::FontItalic(true)), - row("fontUnderline", ALL, Patch::FontUnderline(true)), - row("fontStrikethrough", ALL, Patch::FontStrikethrough(true)), - row("fontFamily", ALL, Patch::FontFamily("Sans".to_string())), - row("displayText", ALL, Patch::DisplayText("A".to_string())), + row("fontWeight", KEY_POSITION_TYPES, Patch::FontWeight(700)), + row("fontBold", KEY_POSITION_TYPES, Patch::FontBold(true)), + row("fontItalic", KEY_POSITION_TYPES, Patch::FontItalic(true)), + row( + "fontUnderline", + KEY_POSITION_TYPES, + Patch::FontUnderline(true), + ), + row( + "fontStrikethrough", + KEY_POSITION_TYPES, + Patch::FontStrikethrough(true), + ), + row( + "fontFamily", + KEY_POSITION_TYPES, + Patch::FontFamily("Sans".to_string()), + ), + row( + "displayText", + KEY_POSITION_TYPES, + Patch::DisplayText("A".to_string()), + ), row("className", ALL, Patch::ClassName("custom".to_string())), row( "fontPaint", @@ -490,7 +620,7 @@ fn patch_target_type_matrix() -> Vec { row("shadowEnabled", KEY_STAT_KNOB, Patch::ShadowEnabled(true)), row( "backgroundPaint", - ALL, + KEY_POSITION_TYPES, Patch::BackgroundPaint(paint_descriptor("#112233", None)), ), row( @@ -500,7 +630,7 @@ fn patch_target_type_matrix() -> Vec { ), row( "borderPaint", - ALL, + KEY_POSITION_TYPES, Patch::BorderPaint(paint_descriptor("#112233", None)), ), row( @@ -508,12 +638,12 @@ fn patch_target_type_matrix() -> Vec { KEY_KNOB, Patch::ActiveBorderPaint(paint_descriptor("#112233", None)), ), - row("borderWidth", ALL, Patch::BorderWidth(2.0)), - row("borderRadius", ALL, Patch::BorderRadius(8.0)), - row("fontSize", ALL, Patch::FontSize(16.0)), + row("borderWidth", KEY_POSITION_TYPES, Patch::BorderWidth(2.0)), + row("borderRadius", KEY_POSITION_TYPES, Patch::BorderRadius(8.0)), + row("fontSize", KEY_POSITION_TYPES, Patch::FontSize(16.0)), row( "inactiveImage", - ALL, + KEY_POSITION_TYPES, Patch::InactiveImage("idle.png".to_string()), ), row( @@ -521,13 +651,21 @@ fn patch_target_type_matrix() -> Vec { KEY_KNOB, Patch::ActiveImage("active.png".to_string()), ), - row("idleTransparent", ALL, Patch::IdleTransparent(true)), + row( + "idleTransparent", + KEY_POSITION_TYPES, + Patch::IdleTransparent(true), + ), row( "activeTransparent", KEY_KNOB, Patch::ActiveTransparent(true), ), - row("idleImageFit", ALL, Patch::IdleImageFit(ImageFit::Contain)), + row( + "idleImageFit", + KEY_POSITION_TYPES, + Patch::IdleImageFit(ImageFit::Contain), + ), row( "activeImageFit", KEY_KNOB, @@ -665,6 +803,7 @@ fn patch_target_type_matrix() -> Vec { KEY_ONLY, Patch::NoteBorderSide(EditorNoteBorderSideV1::Vertical), ), + row("rotation", KEY_POSITION_TYPES, Patch::Rotation(45.5)), ] } @@ -676,6 +815,7 @@ fn patch_target_type_matrix_pins_all_variant_constraints() { EditorElementTypeV1::Stat => store.stat_positions["4key"][0].position.id.clone(), EditorElementTypeV1::Graph => store.graph_positions["4key"][0].position.id.clone(), EditorElementTypeV1::Knob => store.knob_positions["4key"][0].position.id.clone(), + EditorElementTypeV1::Sprite => store.sprite_positions["4key"][0].id.clone(), }; let rows = patch_target_type_matrix(); @@ -719,6 +859,7 @@ fn patch_target_type_matrix_pins_all_variant_constraints() { EditorElementTypeV1::Stat, EditorElementTypeV1::Graph, EditorElementTypeV1::Knob, + EditorElementTypeV1::Sprite, ] { // 선언 타입과 실제 요소 타입을 일치시켜 계열 제약만 판정되게 고정 let ops = [patch_property_op( @@ -750,6 +891,669 @@ fn patch_target_type_matrix_pins_all_variant_constraints() { } } +#[test] +fn sprite_patch_element_allows_only_generic_properties() { + let mut store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + for patch in [ + EditorElementPropertyPatchV1::Hidden(true), + EditorElementPropertyPatchV1::LayerName(Some("sprite layer".to_string())), + EditorElementPropertyPatchV1::ClassName("sprite-class".to_string()), + EditorElementPropertyPatchV1::UseInlineStyles(true), + ] { + let transition = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Sprite, + &sprite_id, + patch, + )], + ) + .unwrap(); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!(transition.changed_fields, [EditorField::SpritePositions]); + store = transition.scratch; + } + + let sprite = &store.sprite_positions["4key"][0]; + assert!(sprite.hidden); + assert_eq!(sprite.layer_name.as_deref(), Some("sprite layer")); + assert_eq!(sprite.class_name.as_deref(), Some("sprite-class")); + assert_eq!(sprite.use_inline_styles, Some(true)); + + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Sprite, + &sprite_id, + EditorElementPropertyPatchV1::InactiveImage("blocked.png".to_string()), + )], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); +} + +#[test] +fn resize_sprite_wire_uses_only_kind_id_and_bounds() { + let id = uuid::Uuid::new_v4().to_string(); + let bounds = EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 300.0, + height: 150.0, + }; + let op = resize_sprite_op(&id, bounds); + assert_eq!( + serde_json::to_value(&op).unwrap(), + serde_json::json!({ + "kind": "resizeSprite", + "id": id, + "bounds": { + "dx": 1.0, + "dy": 2.0, + "width": 300.0, + "height": 150.0, + }, + }) + ); + assert!(serde_json::from_value::(serde_json::json!({ + "kind": "resizeSprite", + "id": uuid::Uuid::new_v4().to_string(), + "bounds": { + "dx": 1.0, + "dy": 2.0, + "width": 300.0, + "height": 150.0, + }, + "elementType": "sprite", + })) + .is_err()); +} + +#[test] +fn resize_sprite_scales_uniform_and_nonuniform_content() { + let mut store = store_with_sprite(); + let position = &mut store.sprite_positions.get_mut("4key").unwrap()[0]; + position.dx = 10.0; + position.dy = 20.0; + position.width = 200.0; + position.height = 100.0; + position.pivot = SpriteAnchor { x: 0.25, y: 0.75 }; + position.idle_transform = SpriteTransform { + x: 12.0, + y: -6.0, + rotation: 15.0, + scale: 1.5, + }; + position.poses = vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + transform: SpriteTransform { + x: -30.0, + y: 44.0, + rotation: -90.0, + scale: 0.5, + }, + ..SpritePose::default() + }]; + let original = position.clone(); + let id = position.id.clone(); + + let nonuniform_bounds = EditorBoundsV1 { + dx: 5.0, + dy: 8.0, + width: 400.0, + height: 50.0, + }; + let nonuniform = + prepare_editor_ops_transition(&store, &[resize_sprite_op(&id, nonuniform_bounds)]).unwrap(); + let resized = &nonuniform.candidate.sprite_positions["4key"][0]; + assert_eq!(sprite_bounds(resized), nonuniform_bounds); + assert_eq!( + (resized.idle_transform.x, resized.idle_transform.y), + (24.0, -3.0) + ); + assert_eq!( + (resized.poses[0].transform.x, resized.poses[0].transform.y), + (-60.0, 22.0) + ); + assert_eq!( + resized.idle_transform.rotation, + original.idle_transform.rotation + ); + assert_eq!(resized.idle_transform.scale, original.idle_transform.scale); + assert_eq!( + resized.poses[0].transform.rotation, + original.poses[0].transform.rotation + ); + assert_eq!( + resized.poses[0].transform.scale, + original.poses[0].transform.scale + ); + assert_eq!(resized.pivot, original.pivot); + assert_eq!( + nonuniform.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!(nonuniform.op_results[0].bounds, Some(nonuniform_bounds)); + + let uniform_bounds = EditorBoundsV1 { + dx: -5.0, + dy: -8.0, + width: 400.0, + height: 200.0, + }; + let uniform = + prepare_editor_ops_transition(&store, &[resize_sprite_op(id, uniform_bounds)]).unwrap(); + let resized = &uniform.candidate.sprite_positions["4key"][0]; + assert_eq!( + (resized.idle_transform.x, resized.idle_transform.y), + (24.0, -12.0) + ); + assert_eq!( + (resized.poses[0].transform.x, resized.poses[0].transform.y), + (-60.0, 88.0) + ); +} + +#[test] +fn resize_sprite_clamps_scaled_offsets_at_contract_boundaries() { + let mut store = store_with_sprite(); + let position = &mut store.sprite_positions.get_mut("4key").unwrap()[0]; + position.width = 1.0; + position.height = 1.0; + position.idle_transform = SpriteTransform { + x: 1.0, + y: -1.0, + ..SpriteTransform::default() + }; + position.poses = vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + transform: SpriteTransform { + x: -1.0, + y: 1.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }]; + let id = position.id.clone(); + let bounds = EditorBoundsV1 { + dx: MAX_ABS_COORDINATE, + dy: -MAX_ABS_COORDINATE, + width: MAX_DIMENSION, + height: MAX_DIMENSION, + }; + + let transition = + prepare_editor_ops_transition(&store, &[resize_sprite_op(id, bounds)]).unwrap(); + let resized = &transition.candidate.sprite_positions["4key"][0]; + assert_eq!(resized.idle_transform.x, SPRITE_TRANSFORM_OFFSET_MAX); + assert_eq!(resized.idle_transform.y, SPRITE_TRANSFORM_OFFSET_MIN); + assert_eq!(resized.poses[0].transform.x, SPRITE_TRANSFORM_OFFSET_MIN); + assert_eq!(resized.poses[0].transform.y, SPRITE_TRANSFORM_OFFSET_MAX); +} + +#[test] +fn resize_sprite_defends_zero_previous_dimensions_with_identity_ratios() { + let mut position = ReactiveSpritePosition { + width: 0.0, + height: 0.0, + idle_transform: SpriteTransform { + x: 7.0, + y: -8.0, + ..SpriteTransform::default() + }, + poses: vec![SpritePose { + transform: SpriteTransform { + x: 9.0, + y: -10.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let content_before = (position.idle_transform, position.poses.clone()); + let bounds = EditorBoundsV1 { + dx: 11.0, + dy: 12.0, + width: 100.0, + height: 200.0, + }; + + apply_sprite_resize(&mut position, &bounds); + + assert_eq!(sprite_bounds(&position), bounds); + assert_eq!((position.idle_transform, position.poses,), content_before); +} + +#[test] +fn resize_sprite_preserves_pivot_image_metadata() { + let mut position = ReactiveSpritePosition { + width: 200.0, + height: 100.0, + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some("/images/base.png".to_string()), + width: 800, + height: 600, + }), + poses: vec![SpritePose { + image_override: Some("/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/pose.png".to_string(), + width: 320, + height: 240, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let metadata_before = ( + position.reference_natural_size.clone(), + position.pivot, + position.poses[0].image_override_metrics.clone(), + ); + + apply_sprite_resize( + &mut position, + &EditorBoundsV1 { + dx: 10.0, + dy: 20.0, + width: 600.0, + height: 250.0, + }, + ); + + assert_eq!( + ( + position.reference_natural_size, + position.pivot, + position.poses[0].image_override_metrics.clone(), + ), + metadata_before + ); +} + +#[test] +fn resize_sprite_reports_missing_and_no_change_with_bounds_contract() { + let store = store_with_sprite(); + let position = &store.sprite_positions["4key"][0]; + let current_bounds = sprite_bounds(position); + let current_document = EditorDocumentV1::from_store(&store); + + let no_change = + prepare_editor_ops_transition(&store, &[resize_sprite_op(&position.id, current_bounds)]) + .unwrap(); + assert_eq!(no_change.candidate, current_document); + assert_eq!( + no_change.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert_eq!(no_change.op_results[0].bounds, Some(current_bounds)); + + let missing = prepare_editor_ops_transition( + &store, + &[resize_sprite_op( + uuid::Uuid::new_v4().to_string(), + EditorBoundsV1 { + dx: 20.0, + dy: 30.0, + width: 400.0, + height: 250.0, + }, + )], + ) + .unwrap(); + assert_eq!(missing.candidate, current_document); + assert_eq!( + missing.op_results[0], + EditorOpResultV1 { + status: EditorOpResultStatusV1::TargetMissing, + bounds: None, + } + ); +} + +#[test] +fn missing_bounds_targets_still_reject_invalid_bounds() { + let store = store_with_sprite(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let invalid_bounds = EditorBoundsV1 { + dx: 20.0, + dy: 30.0, + width: 0.0, + height: 250.0, + }; + + for op in [ + EditorOpV1::SetBounds { + element_type: EditorElementTypeV1::Sprite, + id: missing_id.clone(), + bounds: invalid_bounds, + }, + resize_sprite_op(missing_id.clone(), invalid_bounds), + ] { + let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); + assert_eq!(validation_code(&error), Some("DIMENSION_OUT_OF_RANGE")); + } +} + +#[test] +fn sequential_sprite_resizes_use_candidate_bounds_for_each_ratio() { + let mut store = store_with_sprite(); + let position = &mut store.sprite_positions.get_mut("4key").unwrap()[0]; + position.width = 200.0; + position.height = 100.0; + position.idle_transform = SpriteTransform { + x: 10.0, + y: -4.0, + ..SpriteTransform::default() + }; + position.poses = vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + transform: SpriteTransform { + x: -6.0, + y: 8.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }]; + let id = position.id.clone(); + let first_bounds = EditorBoundsV1 { + dx: 10.0, + dy: 20.0, + width: 400.0, + height: 200.0, + }; + let second_bounds = EditorBoundsV1 { + dx: -10.0, + dy: -20.0, + width: 100.0, + height: 50.0, + }; + + let transition = prepare_editor_ops_transition( + &store, + &[ + resize_sprite_op(&id, first_bounds), + resize_sprite_op(id, second_bounds), + ], + ) + .unwrap(); + let resized = &transition.candidate.sprite_positions["4key"][0]; + + assert_eq!(sprite_bounds(resized), second_bounds); + assert_eq!( + (resized.idle_transform.x, resized.idle_transform.y), + (5.0, -2.0) + ); + assert_eq!( + (resized.poses[0].transform.x, resized.poses[0].transform.y,), + (-3.0, 4.0) + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + ] + ); + assert_eq!(transition.op_results[1].bounds, Some(second_bounds)); +} + +#[test] +fn resize_sprite_type_mismatch_rejects_the_whole_mixed_batch() { + let store = base_store(); + let before = store.clone(); + let first_id = store.key_positions["4key"][0].id.clone(); + let second_id = store.key_positions["4key"][1].id.clone(); + let error = prepare_editor_ops_transition( + &store, + &[ + EditorOpV1::SetBounds { + element_type: EditorElementTypeV1::Key, + id: first_id, + bounds: EditorBoundsV1 { + dx: 100.0, + dy: 200.0, + width: 60.0, + height: 60.0, + }, + }, + resize_sprite_op( + second_id, + EditorBoundsV1 { + dx: 10.0, + dy: 20.0, + width: 300.0, + height: 150.0, + }, + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store, before); +} + +#[test] +fn sprite_set_bounds_updates_activity_bounds() { + let store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + let bounds = EditorBoundsV1 { + dx: 24.0, + dy: -12.0, + width: 360.0, + height: 240.0, + }; + let transition = prepare_editor_ops_transition( + &store, + &[EditorOpV1::SetBounds { + element_type: EditorElementTypeV1::Sprite, + id: sprite_id, + bounds, + }], + ) + .unwrap(); + + let sprite = &transition.candidate.sprite_positions["4key"][0]; + assert_eq!((sprite.dx, sprite.dy), (bounds.dx, bounds.dy)); + assert_eq!((sprite.width, sprite.height), (bounds.width, bounds.height)); + assert_eq!(transition.op_results[0].bounds, Some(bounds)); + assert_eq!(transition.changed_fields, [EditorField::SpritePositions]); +} + +#[test] +fn sprite_delete_element_removes_sprite_and_its_empty_group() { + let mut store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + store.sprite_positions.get_mut("4key").unwrap()[0].group_id = Some("sprite-group".to_string()); + store.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "sprite-group".to_string(), + name: "Sprite Group".to_string(), + }], + ); + + let transition = prepare_editor_ops_transition( + &store, + &[EditorOpV1::DeleteElement { + element_type: EditorElementTypeV1::Sprite, + id: sprite_id, + }], + ) + .unwrap(); + + assert!(transition.candidate.sprite_positions["4key"].is_empty()); + assert!(transition.candidate.layer_groups["4key"].is_empty()); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); +} + +#[test] +fn sprite_reorder_elements_updates_z_index() { + let store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + let transition = prepare_editor_ops_transition( + &store, + &[EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: false, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Sprite, + id: sprite_id, + z_index: 42, + }], + group_updates: Vec::new(), + }], + ) + .unwrap(); + + assert_eq!( + transition.candidate.sprite_positions["4key"][0].z_index, + Some(42) + ); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); +} + +#[test] +fn sprite_set_element_groups_creates_and_assigns_group() { + let store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + let transition = prepare_editor_ops_transition( + &store, + &[EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Sprite, + id: sprite_id, + }], + target_group: Some(EditorTargetGroupV1::Create { + id: "sprite-group".to_string(), + name: "Sprite Group".to_string(), + }), + }], + ) + .unwrap(); + + assert_eq!( + transition.candidate.sprite_positions["4key"][0] + .group_id + .as_deref(), + Some("sprite-group") + ); + assert_eq!(transition.candidate.layer_groups["4key"].len(), 1); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); +} + +#[test] +fn sprite_insert_frozen_elements_inserts_and_replays_idempotently() { + let store = base_store(); + let sprite_id = uuid::Uuid::new_v4().to_string(); + let sprite = ReactiveSpritePosition { + id: sprite_id.clone(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![store.key_positions["4key"][0].id.clone()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let op = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![EditorFrozenElementV1::Sprite { position: sprite }], + groups: Vec::new(), + z_updates: Vec::new(), + }; + let wire = serde_json::to_value(&op).unwrap(); + assert_eq!(wire["elements"][0]["elementType"], "sprite"); + assert_eq!(serde_json::from_value::(wire).unwrap(), op); + + let inserted = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); + assert_eq!(inserted.candidate.sprite_positions["4key"][0].id, sprite_id); + assert_eq!( + inserted.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + + let replayed = + prepare_editor_ops_transition(&inserted.scratch, std::slice::from_ref(&op)).unwrap(); + assert_eq!( + replayed.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert_eq!(replayed.candidate, inserted.candidate); +} + +#[test] +fn sprite_insert_frozen_elements_rejects_invalid_pose_id() { + let store = base_store(); + let sprite = ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: "not-a-uuid".to_string(), + triggers: vec![store.key_positions["4key"][0].id.clone()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let op = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![EditorFrozenElementV1::Sprite { position: sprite }], + groups: Vec::new(), + z_updates: Vec::new(), + }; + + let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); + + assert_eq!(validation_code(&error), Some(INVALID_ELEMENT_ID)); +} + +#[test] +fn sprite_insert_frozen_elements_rejects_cross_mode_duplicate_pose_id() { + let mut store = store_with_sprite(); + let duplicate_pose_id = uuid::Uuid::new_v4().to_string(); + store.sprite_positions.get_mut("4key").unwrap()[0].poses = vec![SpritePose { + pose_id: duplicate_pose_id.clone(), + triggers: vec![store.key_positions["4key"][0].id.clone()], + ..SpritePose::default() + }]; + let sprite = ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: duplicate_pose_id, + triggers: vec![store.key_positions["5key"][0].id.clone()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let op = EditorOpV1::InsertFrozenElements { + mode: "5key".to_string(), + elements: vec![EditorFrozenElementV1::Sprite { position: sprite }], + groups: Vec::new(), + z_updates: Vec::new(), + }; + + let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); + + assert_eq!(validation_code(&error), Some(DUPLICATE_ELEMENT_ID)); +} + #[test] fn frozen_insert_appends_key_pair_group_and_existing_z_as_one_transition() { let store = base_store(); @@ -1075,6 +1879,7 @@ fn complete_reorder_updates_every_native_type_and_cleans_empty_groups() { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ] ); @@ -3536,6 +4341,85 @@ fn image_fit_patches_materialize_state_specific_values_and_preserve_siblings() { } } +#[test] +fn element_rotation_patches_apply_noop_reset_and_reject_invalid_values() { + let base = store_with_every_reorder_type(); + for (element_type, id, field) in [ + ( + EditorElementTypeV1::Key, + base.key_positions["4key"][0].id.clone(), + EditorField::KeyPositions, + ), + ( + EditorElementTypeV1::Stat, + base.stat_positions["4key"][0].position.id.clone(), + EditorField::StatPositions, + ), + ( + EditorElementTypeV1::Graph, + base.graph_positions["4key"][0].position.id.clone(), + EditorField::GraphPositions, + ), + ( + EditorElementTypeV1::Knob, + base.knob_positions["4key"][0].position.id.clone(), + EditorField::KnobPositions, + ), + ] { + let mut store = base.clone(); + for rotation in [-180.0, 45.5, 180.0, 0.0] { + let op = patch_property_op( + element_type, + &id, + EditorElementPropertyPatchV1::Rotation(rotation), + ); + let transition = + prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!(transition.changed_fields, [field]); + let mut expected = EditorDocumentV1::from_store(&store); + let position = match element_type { + EditorElementTypeV1::Key => &mut expected.key_positions.get_mut("4key").unwrap()[0], + EditorElementTypeV1::Stat => { + &mut expected.stat_positions.get_mut("4key").unwrap()[0].position + } + EditorElementTypeV1::Graph => { + &mut expected.graph_positions.get_mut("4key").unwrap()[0].position + } + EditorElementTypeV1::Knob => { + &mut expected.knob_positions.get_mut("4key").unwrap()[0].position + } + EditorElementTypeV1::Sprite => unreachable!(), + }; + position.rotation = rotation; + assert_eq!(transition.candidate, expected); + store = transition.scratch; + let replay = prepare_editor_ops_transition(&store, &[op]).unwrap(); + assert_eq!( + replay.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert!(replay.changed_fields.is_empty()); + assert_eq!(replay.candidate, expected); + } + for rotation in [-180.1, 180.1, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + element_type, + &id, + EditorElementPropertyPatchV1::Rotation(rotation), + )], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ROTATION_OUT_OF_RANGE")); + } + } +} + #[test] fn image_transform_patches_seed_noop_reset_and_store_sparse_mode() { let store = store_with_every_reorder_type(); diff --git a/src-tauri/src/state/editor_ops/transition.rs b/src-tauri/src/state/editor_ops/transition.rs index 4e2c937ea..64e9e15c5 100644 --- a/src-tauri/src/state/editor_ops/transition.rs +++ b/src-tauri/src/state/editor_ops/transition.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::{ELEMENT_ROTATION_MAX, ELEMENT_ROTATION_MIN}; mod element_patch; @@ -30,6 +31,7 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( | EditorOpV1::PatchElement { element_type, id, .. } => Some((*element_type, id)), + EditorOpV1::ResizeSprite { id, .. } => Some((EditorElementTypeV1::Sprite, id)), EditorOpV1::SetKeySlot { id, .. } => { if let Some(location) = locations.get(id) { validate_editor_op_target_type( @@ -56,6 +58,22 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( .. } = op { + if *element_type == EditorElementTypeV1::Sprite + && !matches!( + patch, + EditorElementPropertyPatchV1::Hidden(_) + | EditorElementPropertyPatchV1::LayerName(_) + | EditorElementPropertyPatchV1::ClassName(_) + | EditorElementPropertyPatchV1::UseInlineStyles(_) + ) + { + return Err(EditorCommitError::validation( + "ELEMENT_TYPE_MISMATCH", + format!( + "editor op {op_index} sprite patch only allows hidden, layerName, className, or useInlineStyles" + ), + )); + } // 속성 계열별 대상 타입 제약, variant 추가 시 컴파일 에러로 분류 강제 match patch { // 그래프 전용 @@ -404,6 +422,18 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( } } // 타입 무제약, 값 범위 검증만 + EditorElementPropertyPatchV1::Rotation(patch) => { + if !patch.is_finite() + || !(ELEMENT_ROTATION_MIN..=ELEMENT_ROTATION_MAX).contains(patch) + { + return Err(EditorCommitError::validation( + "ROTATION_OUT_OF_RANGE", + format!( + "editor op {op_index} rotation must be finite and between {ELEMENT_ROTATION_MIN} and {ELEMENT_ROTATION_MAX}" + ), + )); + } + } EditorElementPropertyPatchV1::BorderWidth(patch) => { if !patch.is_finite() || !(0.0..=20.0).contains(patch) { return Err(EditorCommitError::validation( @@ -465,27 +495,30 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( for (op_index, op) in ops.iter().enumerate() { match op { EditorOpV1::SetBounds { id, bounds, .. } => { - let Some(location) = locations.get(id) else { - validate_editor_op_bounds(op_index, None, *bounds)?; - op_results.push(EditorOpResultV1 { - status: EditorOpResultStatusV1::TargetMissing, - bounds: None, - }); - continue; - }; - - let position = position_at_mut(&mut candidate, location)?; - validate_editor_op_bounds(op_index, Some(position), *bounds)?; - let status = if bounds_of(position) == *bounds { - EditorOpResultStatusV1::NoChange - } else { - apply_bounds(position, bounds); - EditorOpResultStatusV1::Applied - }; - op_results.push(EditorOpResultV1 { - status, - bounds: Some(bounds_of(position)), - }); + op_results.push(apply_bounds_op( + &mut candidate, + &locations, + op_index, + id, + *bounds, + |candidate, location, bounds| { + apply_bounds(element_common_at_mut(candidate, location)?, bounds); + Ok(()) + }, + )?); + } + EditorOpV1::ResizeSprite { id, bounds } => { + op_results.push(apply_bounds_op( + &mut candidate, + &locations, + op_index, + id, + *bounds, + |candidate, location, bounds| { + apply_sprite_resize(sprite_at_mut(candidate, location)?, bounds); + Ok(()) + }, + )?); } EditorOpV1::DeleteElement { element_type, id } => { let Some(location) = locations.get(id) else { @@ -633,9 +666,12 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( } delete_elements(&mut candidate, &delete_ids); remove_empty_layer_groups(&mut candidate, &delete_modes, plugin_group_refs); + validate_document_element_ids(&candidate)?; let mut scratch = current_store.clone(); candidate.apply_to_store(&mut scratch); + crate::state::migration::normalize_sprite_triggers(&mut scratch); + candidate = EditorDocumentV1::from_store(&scratch); scratch.editor_revision = current_store.editor_revision; validate_document_transition(¤t, &candidate, current_store, &scratch)?; let changed_fields = current.changed_fields(&candidate); diff --git a/src-tauri/src/state/editor_ops/transition/element_patch.rs b/src-tauri/src/state/editor_ops/transition/element_patch.rs index bd1ee74d4..a19e3a49d 100644 --- a/src-tauri/src/state/editor_ops/transition/element_patch.rs +++ b/src-tauri/src/state/editor_ops/transition/element_patch.rs @@ -8,20 +8,20 @@ pub(super) fn apply_element_patch( ) -> Result { Ok(match patch { EditorElementPropertyPatchV1::Hidden(patch) => { - let position = position_at_mut(candidate, location)?; - if position.hidden == *patch { + let common = element_common_at_mut(candidate, location)?; + if *common.hidden == *patch { false } else { - position.hidden = *patch; + *common.hidden = *patch; true } } EditorElementPropertyPatchV1::LayerName(patch) => { - let position = position_at_mut(candidate, location)?; - if position.layer_name == *patch { + let common = element_common_at_mut(candidate, location)?; + if *common.layer_name == *patch { false } else { - position.layer_name.clone_from(patch); + common.layer_name.clone_from(patch); true } } @@ -170,11 +170,11 @@ pub(super) fn apply_element_patch( } } EditorElementPropertyPatchV1::UseInlineStyles(patch) => { - let position = position_at_mut(candidate, location)?; - if position.use_inline_styles == Some(*patch) { + let common = element_common_at_mut(candidate, location)?; + if *common.use_inline_styles == Some(*patch) { false } else { - position.use_inline_styles = Some(*patch); + *common.use_inline_styles = Some(*patch); true } } @@ -247,11 +247,11 @@ pub(super) fn apply_element_patch( } } EditorElementPropertyPatchV1::ClassName(patch) => { - let position = position_at_mut(candidate, location)?; - if position.class_name.as_deref() == Some(patch.as_str()) { + let common = element_common_at_mut(candidate, location)?; + if common.class_name.as_deref() == Some(patch.as_str()) { false } else { - position.class_name = Some(patch.clone()); + *common.class_name = Some(patch.clone()); true } } @@ -325,6 +325,15 @@ pub(super) fn apply_element_patch( patch, ) } + EditorElementPropertyPatchV1::Rotation(patch) => { + let position = position_at_mut(candidate, location)?; + if position.rotation == *patch { + false + } else { + position.rotation = *patch; + true + } + } EditorElementPropertyPatchV1::BorderWidth(patch) => { let position = position_at_mut(candidate, location)?; if position.border_width == Some(*patch) { diff --git a/src-tauri/src/state/editor_ops_parity.rs b/src-tauri/src/state/editor_ops_parity.rs index 71b149223..1c21f316e 100644 --- a/src-tauri/src/state/editor_ops_parity.rs +++ b/src-tauri/src/state/editor_ops_parity.rs @@ -41,8 +41,9 @@ fn document_json(document: &EditorDocumentV1) -> String { // EditorOpV1 wire kind 기대 목록 - op_kind가 serde 실태그를 돌려주므로 // 집합 동등 테스트(fixture_covers_every_op_kind)가 이 목록의 오타도 잡는다 -const ALL_OP_KINDS: [&str; 8] = [ +const ALL_OP_KINDS: [&str; 9] = [ "setBounds", + "resizeSprite", "deleteElement", "patchElement", "setKeySlot", @@ -58,6 +59,7 @@ fn op_kind(op: &EditorOpV1) -> String { // 단일 원천은 serde 태그라 여기서 문자열을 복제하지 않는다 match op { EditorOpV1::SetBounds { .. } => {} + EditorOpV1::ResizeSprite { .. } => {} EditorOpV1::DeleteElement { .. } => {} EditorOpV1::PatchElement { .. } => {} EditorOpV1::SetKeySlot { .. } => {} diff --git a/src-tauri/src/state/history/snapshot.rs b/src-tauri/src/state/history/snapshot.rs index 3c3879dac..48cf8e9d8 100644 --- a/src-tauri/src/state/history/snapshot.rs +++ b/src-tauri/src/state/history/snapshot.rs @@ -278,6 +278,9 @@ fn preserve_editor_before_values(merged: &mut EditorPatchV1, first: &EditorPatch if first.knob_positions.is_some() { merged.knob_positions.clone_from(&first.knob_positions); } + if first.sprite_positions.is_some() { + merged.sprite_positions.clone_from(&first.sprite_positions); + } if first.layer_groups.is_some() { merged.layer_groups.clone_from(&first.layer_groups); } diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 2173c51b2..3bec4ab03 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -17,16 +17,22 @@ use crate::{ }, defaults::{default_keys, default_positions}, models::{ - default_missing_note_gradient_multipliers, normalize_key_slot, + default_missing_note_gradient_multipliers, default_sprite_press_duration_ms, + default_sprite_transition_ms, is_renderable_image_ref, normalize_key_slot, scrub_removed_text_outline_fields, AppStoreData, CounterAnimationPreset, CustomCss, CustomCssHistoryEntry, CustomFont, CustomJs, CustomTab, FontType, FontWeightRange, GradientSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, GridSettings, ImageMode, ImageTransform, JsPlugin, KeyCounters, KeyMappings, KeyPosition, KeyPositions, KeySlot, KnobPosition, KnobPositions, LayerGroupDef, LayerGroups, NoteSettings, - ShortcutsState, SoundLibraryEntry, StatPosition, StatPositions, StatType, - StoredOverlayBounds, TabCss, TabNoteSettings, IMAGE_TRANSFORM_OFFSET_MAX, + ReactiveSpritePosition, ShortcutsState, SoundLibraryEntry, SpriteAnchor, SpritePositions, + SpriteTransform, StatPosition, StatPositions, StatType, StoredOverlayBounds, TabCss, + TabNoteSettings, ELEMENT_ROTATION_MAX, ELEMENT_ROTATION_MIN, IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, POSITION_COLLECTION_FIELDS, + SPRITE_IMAGE_DIMENSION_MAX, SPRITE_IMAGE_DIMENSION_MIN, SPRITE_PRESS_DURATION_MS_MAX, + SPRITE_PRESS_DURATION_MS_MIN, SPRITE_TRANSFORM_OFFSET_MAX, SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_ROTATION_MAX, SPRITE_TRANSFORM_ROTATION_MIN, SPRITE_TRANSFORM_SCALE_MAX, + SPRITE_TRANSFORM_SCALE_MIN, SPRITE_TRANSITION_MS_MAX, }, }; @@ -45,15 +51,16 @@ use assets::{ normalize_image_extension, }; pub(crate) use assets::{ - is_foreign_portable_asset_reference, migrate_key_images_to_app_data, - migrate_local_fonts_to_app_data, rehome_foreign_asset_references, + fill_missing_sprite_image_metrics, is_foreign_portable_asset_reference, + migrate_key_images_to_app_data, migrate_local_fonts_to_app_data, + rehome_foreign_asset_references, }; use assets::{parse_portable_asset_reference, AssetCategory}; #[cfg(test)] use normalization::rgba_to_hex; pub(crate) use normalization::{ canonicalize_gradient_pairs, canonicalize_image_modes, clear_dangling_group_ids, - normalize_state, + migrate_legacy_sprite_wire, normalize_sprite_triggers, normalize_state, }; #[allow(unused_imports)] pub(crate) use normalization::{ @@ -63,13 +70,15 @@ use normalization::{ current_unix_millis, default_store_note_gradient_multipliers, has_convertible_note_border_color, has_explicit_invalid_element_id, has_legacy_font_weight_state, has_valid_selected_key_type, key_position_lengths_mismatch, - migrate_legacy_knob_sensitivity, migrate_sound_library_enabled, normalize_blank_font_colors, - remove_legacy_panel_detach_setting, repair_editor_revision, repair_image_transforms, - repair_semantic_identities, + migrate_legacy_knob_sensitivity, migrate_legacy_sprite_positions_value, + migrate_sound_library_enabled, normalize_blank_font_colors, remove_legacy_panel_detach_setting, + repair_editor_revision, repair_native_position_ranges, repair_semantic_identities, + repair_sprite_numeric_ranges, }; #[cfg(test)] use recovery::{ - recover_key_mapping_entries, recover_local_font_enabled, recover_sound_library_entries, + recover_collection_field, recover_key_mapping_entries, recover_local_font_enabled, + recover_sound_library_entries, }; use recovery::{repair_custom_tab_key_layout_pairs, repair_legacy_state}; @@ -93,6 +102,7 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { Ok(mut value) => { let seed_active_css_history = value.get("customCssHistory").is_none(); let explicit_invalid_element_id = has_explicit_invalid_element_id(&value); + let sprite_wire_migrated = migrate_legacy_sprite_wire(&mut value); let has_tab_order = value.get("tabOrder").is_some(); let has_bar_count = value.get("barCount").is_some(); let sound_library_migrated = migrate_sound_library_enabled(&mut value); @@ -103,6 +113,7 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { match serde_json::from_value::(value.clone()) { Ok(mut data) => { let mut needs_persist = text_outline_scrubbed + || sprite_wire_migrated || sound_library_migrated || gradient_multipliers_defaulted || data.font_settings.custom_fonts.iter().any(|font| { @@ -121,8 +132,10 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { if migrate_legacy_knob_sensitivity(&mut data) { needs_persist = true; } - let image_transform_repaired = repair_image_transforms(&mut data); - needs_persist |= image_transform_repaired; + let position_range_repaired = repair_native_position_ranges(&mut data); + needs_persist |= position_range_repaired; + let sprite_numeric_repaired = repair_sprite_numeric_ranges(&mut data); + needs_persist |= sprite_numeric_repaired; needs_persist |= has_legacy_font_weight_state(&data); let editor_revision_repaired = repair_editor_revision(&mut data); needs_persist |= editor_revision_repaired; @@ -142,6 +155,7 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { needs_persist |= layout_repaired; needs_persist |= normalize_blank_font_colors(&mut data); needs_persist |= canonicalize_image_modes(&mut data); + needs_persist |= normalize_sprite_triggers(&mut data); let (gradient_changed, gradient_pair_repaired) = canonicalize_gradient_pairs(&mut data); needs_persist |= gradient_changed; @@ -162,7 +176,8 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { || (has_bar_count && bar_count_changed) || editor_revision_repaired || gradient_pair_repaired - || image_transform_repaired, + || position_range_repaired + || sprite_numeric_repaired, seed_active_css_history, explicit_invalid_element_id, ) diff --git a/src-tauri/src/state/migration/assets.rs b/src-tauri/src/state/migration/assets.rs index 509438d95..3c027aaac 100644 --- a/src-tauri/src/state/migration/assets.rs +++ b/src-tauri/src/state/migration/assets.rs @@ -10,11 +10,15 @@ use unicode_normalization::UnicodeNormalization; use uuid::Uuid; use super::super::{ - assets::image_asset::{import_image_bytes, import_image_file}, + assets::image_asset::{import_image_bytes, import_image_file, probe_local_raster_size}, assets::local_asset_path::{file_url_to_path, path_identity_key, FileUrlPath}, }; use crate::{ - models::{AppStoreData, FontType, FontWeightRange, KeyPosition, SoundLibraryEntry}, + models::{ + is_renderable_image_ref, rewrite_coupled_sprite_image_reference, AppStoreData, FontType, + FontWeightRange, KeyPosition, SoundLibraryEntry, SpriteImageMetrics, SpritePositions, + SpriteReferenceNaturalSize, + }, services::font_metadata::parse_font_metadata, }; @@ -205,6 +209,14 @@ pub(crate) fn migrate_key_images_to_app_data(app_data_dir: &Path, data: &mut App option_has_non_empty_text(&knob_position.position.active_image) || option_has_non_empty_text(&knob_position.position.inactive_image) }) + }) || data.sprite_positions.values().any(|sprites| { + sprites.iter().any(|sprite| { + option_has_non_empty_text(&sprite.base_image) + || sprite + .poses + .iter() + .any(|pose| option_has_non_empty_text(&pose.image_override)) + }) }); if !has_any_images { @@ -267,6 +279,19 @@ pub(crate) fn migrate_key_images_to_app_data(app_data_dir: &Path, data: &mut App } } + for sprites in data.sprite_positions.values_mut() { + for sprite in sprites { + changed |= rewrite_coupled_sprite_image_reference(sprite, |image_ref| { + migrate_image_reference_to_app_data(&images_dir, image_ref) + }); + for pose in &mut sprite.poses { + changed |= rewrite_coupled_sprite_image_reference(pose, |image_ref| { + migrate_image_reference_to_app_data(&images_dir, image_ref) + }); + } + } + } + changed } @@ -297,6 +322,18 @@ pub(crate) fn rehome_foreign_asset_references( changed |= rehome_position_asset_references(app_data_dir, &mut position.position); } } + for sprites in data.sprite_positions.values_mut() { + for sprite in sprites { + changed |= rewrite_coupled_sprite_image_reference(sprite, |image_ref| { + rehome_optional_asset_reference(app_data_dir, image_ref) + }); + for pose in &mut sprite.poses { + changed |= rewrite_coupled_sprite_image_reference(pose, |image_ref| { + rehome_optional_asset_reference(app_data_dir, image_ref) + }); + } + } + } for font in data .font_settings @@ -342,6 +379,67 @@ pub(crate) fn rehome_foreign_asset_references( changed } +pub(crate) fn fill_missing_sprite_image_metrics(sprite_positions: &mut SpritePositions) -> bool { + let mut changed = false; + for sprite in sprite_positions.values_mut().flatten() { + let sprite_id = if sprite.id.is_empty() { + "".to_string() + } else { + sprite.id.clone() + }; + if sprite.reference_natural_size.is_none() { + if let Some((source, (width, height))) = sprite + .base_image + .as_deref() + .filter(|source| is_renderable_image_ref(Some(source))) + .and_then(|source| { + probe_sprite_image_metrics(&sprite_id, source).map(|size| (source, size)) + }) + { + sprite.reference_natural_size = Some(SpriteReferenceNaturalSize { + source: Some(source.to_string()), + width, + height, + }); + changed = true; + } + } + for pose in &mut sprite.poses { + if pose.image_override_metrics.is_some() { + continue; + } + if let Some((source, (width, height))) = pose + .image_override + .as_deref() + .filter(|source| is_renderable_image_ref(Some(source))) + .and_then(|source| { + probe_sprite_image_metrics(&sprite_id, source).map(|size| (source, size)) + }) + { + pose.image_override_metrics = Some(SpriteImageMetrics { + source: source.to_string(), + width, + height, + }); + changed = true; + } + } + } + changed +} + +fn probe_sprite_image_metrics(sprite_id: &str, source: &str) -> Option<(u32, u32)> { + match probe_local_raster_size(source) { + Ok(size) => size, + Err(error) => { + log::warn!( + "[SpriteMigration] 로컬 래스터 크기 읽기 실패: sprite_id={sprite_id}, path={source}, error={error:#}" + ); + None + } + } +} + fn rehome_position_asset_references(app_data_dir: &Path, position: &mut KeyPosition) -> bool { rehome_optional_asset_reference(app_data_dir, &mut position.sound_path) | rehome_optional_asset_reference(app_data_dir, &mut position.active_image) diff --git a/src-tauri/src/state/migration/normalization.rs b/src-tauri/src/state/migration/normalization.rs index 209092956..d4be2a76d 100644 --- a/src-tauri/src/state/migration/normalization.rs +++ b/src-tauri/src/state/migration/normalization.rs @@ -56,6 +56,91 @@ pub(super) fn migrate_sound_library_enabled(value: &mut Value) -> bool { changed } +pub(crate) fn migrate_legacy_sprite_wire(value: &mut Value) -> bool { + let Some(sprite_positions) = value.get_mut("spritePositions") else { + return false; + }; + migrate_legacy_sprite_positions_value(sprite_positions) +} + +pub(super) fn migrate_legacy_sprite_positions_value(value: &mut Value) -> bool { + let Some(modes) = value.as_object_mut() else { + return false; + }; + let mut changed = false; + for sprite in modes.values_mut().filter_map(Value::as_array_mut).flatten() { + let Some(sprite) = sprite.as_object_mut() else { + continue; + }; + + let sprite_id = sprite + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .unwrap_or("") + .to_string(); + let has_image_rect = sprite.contains_key("imageRect"); + let image_rect = sprite.get("imageRect").and_then(Value::as_object); + let absorbed = image_rect.and_then(|rect| { + let x = rect.get("x")?.as_f64()?; + let y = rect.get("y")?.as_f64()?; + let width = rect.get("width")?.as_f64()?; + let height = rect.get("height")?.as_f64()?; + (x.is_finite() + && y.is_finite() + && width.is_finite() + && height.is_finite() + && width > 0.0 + && height > 0.0) + .then_some((x, y, width, height)) + }); + if let Some((x, y, width, height)) = absorbed { + let transformed = sprite + .get("dx") + .and_then(Value::as_f64) + .zip(sprite.get("dy").and_then(Value::as_f64)) + .and_then(|(dx, dy)| { + Some(( + serde_json::Number::from_f64(dx + x)?, + serde_json::Number::from_f64(dy + y)?, + serde_json::Number::from_f64(width)?, + serde_json::Number::from_f64(height)?, + )) + }); + if let Some((dx, dy, width, height)) = transformed { + sprite.insert("dx".to_string(), Value::Number(dx)); + sprite.insert("dy".to_string(), Value::Number(dy)); + sprite.insert("width".to_string(), Value::Number(width)); + sprite.insert("height".to_string(), Value::Number(height)); + changed = true; + } else { + log::warn!( + "[SpriteMigration] legacy imageRect 적용 건너뜀: sprite_id={sprite_id}, reason=invalid transformed geometry" + ); + } + } else if has_image_rect { + log::warn!( + "[SpriteMigration] legacy imageRect 적용 건너뜀: sprite_id={sprite_id}, reason=invalid imageRect" + ); + } + + for field in ["imageRect", "imageFit", "imagePlacement"] { + changed |= sprite.remove(field).is_some(); + } + if let Some(poses) = sprite.get_mut("poses").and_then(Value::as_array_mut) { + for pose in poses { + let Some(pose) = pose.as_object_mut() else { + continue; + }; + for field in ["contactPoint", "imagePivot"] { + changed |= pose.remove(field).is_some(); + } + } + } + } + changed +} + /// 레거시 노브 민감도 마이그레이션: 도수/카운트(구 기본 1.40625) → 순수 배율(1.0) /// 배율 의미 전환 이전에 저장된 기본값만 1로 재매핑 (사용자 지정값은 유지) pub(super) fn migrate_legacy_knob_sensitivity(data: &mut AppStoreData) -> bool { @@ -165,6 +250,7 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { repair_editor_revision(&mut data); normalize_blank_font_colors(&mut data); canonicalize_image_modes(&mut data); + normalize_sprite_triggers(&mut data); data.tab_order = normalize_tab_order(&data.tab_order, &data.custom_tabs); data.bar_count = normalize_bar_count(data.bar_count, &data.tab_order); @@ -254,7 +340,149 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { data } -pub(super) fn repair_image_transforms(data: &mut AppStoreData) -> bool { +pub(crate) fn normalize_sprite_triggers(data: &mut AppStoreData) -> bool { + let mut changed = false; + for sprite in data.sprite_positions.values_mut().flatten() { + if !is_renderable_image_ref(sprite.base_image.as_deref()) { + if let Some(reference) = sprite.reference_natural_size.as_mut() { + changed |= reference.source.take().is_some(); + } + } + for pose in &mut sprite.poses { + changed |= pose.normalize_triggers(); + if !is_renderable_image_ref(pose.image_override.as_deref()) { + changed |= pose.image_override_metrics.take().is_some(); + } + } + } + changed +} + +// 복구 경계는 커밋 검증과 같은 상수를 그대로 쓴다. contains는 NaN·무한도 거절한다 +fn repair_bounded(value: &mut T, fallback: T, minimum: T, maximum: T) -> bool +where + T: Copy + PartialOrd, +{ + if (minimum..=maximum).contains(value) { + return false; + } + *value = fallback; + true +} + +pub(super) fn repair_sprite_numeric_ranges(data: &mut AppStoreData) -> bool { + fn repair_finite(value: &mut f64, fallback: f64) -> bool { + if value.is_finite() { + return false; + } + *value = fallback; + true + } + + fn repair_positive(value: &mut f64, fallback: f64, maximum: Option) -> bool { + let valid = + value.is_finite() && *value > 0.0 && maximum.is_none_or(|maximum| *value <= maximum); + if valid { + return false; + } + *value = fallback; + true + } + + fn repair_transform(transform: &mut SpriteTransform) -> bool { + let fallback = SpriteTransform::default(); + repair_bounded( + &mut transform.x, + fallback.x, + SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_OFFSET_MAX, + ) | repair_bounded( + &mut transform.y, + fallback.y, + SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_OFFSET_MAX, + ) | repair_bounded( + &mut transform.rotation, + fallback.rotation, + SPRITE_TRANSFORM_ROTATION_MIN, + SPRITE_TRANSFORM_ROTATION_MAX, + ) | repair_bounded( + &mut transform.scale, + fallback.scale, + SPRITE_TRANSFORM_SCALE_MIN, + SPRITE_TRANSFORM_SCALE_MAX, + ) + } + + fn repair_anchor(anchor: &mut SpriteAnchor, fallback: SpriteAnchor) -> bool { + repair_bounded(&mut anchor.x, fallback.x, 0.0, 1.0) + | repair_bounded(&mut anchor.y, fallback.y, 0.0, 1.0) + } + + fn repair_position(position: &mut ReactiveSpritePosition) -> bool { + let fallback = ReactiveSpritePosition::default(); + let mut repaired = repair_finite(&mut position.dx, fallback.dx) + | repair_finite(&mut position.dy, fallback.dy) + | repair_positive(&mut position.width, fallback.width, None) + | repair_positive(&mut position.height, fallback.height, None) + | repair_bounded( + &mut position.rotation, + fallback.rotation, + ELEMENT_ROTATION_MIN, + ELEMENT_ROTATION_MAX, + ) + | repair_anchor(&mut position.pivot, SpriteAnchor::default()) + | repair_transform(&mut position.idle_transform) + | repair_bounded( + &mut position.press_duration_ms, + default_sprite_press_duration_ms(), + SPRITE_PRESS_DURATION_MS_MIN, + SPRITE_PRESS_DURATION_MS_MAX, + ); + if position.transition_ms > SPRITE_TRANSITION_MS_MAX { + position.transition_ms = default_sprite_transition_ms(); + repaired = true; + } + let pose_pivot_fallback = position.pivot; + for pose in &mut position.poses { + repaired |= repair_transform(&mut pose.transform); + if let Some(pivot) = pose.pivot.as_mut() { + repaired |= repair_anchor(pivot, pose_pivot_fallback); + } + if pose.image_override_metrics.as_ref().is_some_and(|metrics| { + !(SPRITE_IMAGE_DIMENSION_MIN..=SPRITE_IMAGE_DIMENSION_MAX).contains(&metrics.width) + || !(SPRITE_IMAGE_DIMENSION_MIN..=SPRITE_IMAGE_DIMENSION_MAX) + .contains(&metrics.height) + }) { + pose.image_override_metrics = None; + repaired = true; + } + } + if position + .reference_natural_size + .as_ref() + .is_some_and(|reference| { + !(SPRITE_IMAGE_DIMENSION_MIN..=SPRITE_IMAGE_DIMENSION_MAX) + .contains(&reference.width) + || !(SPRITE_IMAGE_DIMENSION_MIN..=SPRITE_IMAGE_DIMENSION_MAX) + .contains(&reference.height) + }) + { + position.reference_natural_size = None; + repaired = true; + } + repaired + } + + let mut repaired = false; + for position in data.sprite_positions.values_mut().flatten() { + repaired |= repair_position(position); + } + repaired +} + +// 네이티브 4종 공통 KeyPosition의 이미지 변환·요소 회전 범위 보정 +pub(super) fn repair_native_position_ranges(data: &mut AppStoreData) -> bool { fn repair_transform(transform: &mut Option) -> bool { let Some(transform) = transform else { return false; @@ -298,6 +526,12 @@ pub(super) fn repair_image_transforms(data: &mut AppStoreData) -> bool { fn repair_position(position: &mut KeyPosition) -> bool { repair_transform(&mut position.idle_image_transform) | repair_transform(&mut position.active_image_transform) + | repair_bounded( + &mut position.rotation, + 0.0, + ELEMENT_ROTATION_MIN, + ELEMENT_ROTATION_MAX, + ) } let mut repaired = false; @@ -481,6 +715,7 @@ pub(crate) fn clear_dangling_group_ids(data: &mut AppStoreData) -> bool { &mut data.stat_positions, &mut data.graph_positions, &mut data.knob_positions, + &mut data.sprite_positions, &data.layer_groups, ) } @@ -490,6 +725,7 @@ pub(crate) fn clear_dangling_group_ids_in( stat_positions: &mut StatPositions, graph_positions: &mut GraphPositions, knob_positions: &mut KnobPositions, + sprite_positions: &mut SpritePositions, layer_groups: &LayerGroups, ) -> bool { let valid_ids: HashMap<&str, HashSet<&str>> = layer_groups @@ -543,6 +779,14 @@ pub(crate) fn clear_dangling_group_ids_in( } } } + for (tab, sprites) in sprite_positions.iter_mut() { + for sprite in sprites { + if is_dangling(tab, &sprite.group_id) { + sprite.group_id = None; + changed = true; + } + } + } changed } diff --git a/src-tauri/src/state/migration/recovery.rs b/src-tauri/src/state/migration/recovery.rs index 1e7a2374d..1a7d60325 100644 --- a/src-tauri/src/state/migration/recovery.rs +++ b/src-tauri/src/state/migration/recovery.rs @@ -1,7 +1,8 @@ use super::*; /// 레거시/비정상 store 파일 필드별 복구 -pub(super) fn repair_legacy_state(value: Value) -> AppStoreData { +pub(super) fn repair_legacy_state(mut value: Value) -> AppStoreData { + migrate_legacy_sprite_wire(&mut value); let Value::Object(mut source) = value else { return normalize_state(AppStoreData::default()); }; @@ -45,7 +46,8 @@ pub(super) fn repair_legacy_state(value: Value) -> AppStoreData { prepare_tab_order_for_load(&mut data, has_tab_order); prepare_bar_count_for_load(&mut data, has_bar_count); migrate_legacy_knob_sensitivity(&mut data); - repair_image_transforms(&mut data); + repair_native_position_ranges(&mut data); + repair_sprite_numeric_ranges(&mut data); repair_semantic_identities(&mut data); repair_custom_tab_key_layout_pairs( &mut data, @@ -116,7 +118,7 @@ fn source_mode_array<'a>(collection: Option<&'a Value>, mode: &str) -> Option<&' .map(Vec::as_slice) } -fn recover_collection_field(field: &str, value: &Value) -> Option { +pub(super) fn recover_collection_field(field: &str, value: &Value) -> Option { match field { "noteSettings" => recover_object_fields::(field, value), "customTabs" => recover_array_entries::(field, value), @@ -139,6 +141,28 @@ fn recover_collection_field(field: &str, value: &Value) -> Option { value, has_valid_knob_identity, ), + "spritePositions" => { + let mut value = value.clone(); + migrate_legacy_sprite_positions_value(&mut value); + for sprite in value + .as_object_mut()? + .values_mut() + .filter_map(Value::as_array_mut) + .flatten() + .filter_map(Value::as_object_mut) + { + if sprite + .get("rotation") + .is_some_and(|rotation| rotation.as_f64().is_none()) + { + log::warn!( + "[Store] Resetting invalid spritePositions rotation to default during recovery" + ); + sprite.remove("rotation"); + } + } + recover_position_entries::(field, &value) + } "layerGroups" => recover_position_entries::(field, value), "keyCounters" => recover_key_counter_entries(value), "customCss" => recover_object_fields::(field, value), diff --git a/src-tauri/src/state/migration/tests.rs b/src-tauri/src/state/migration/tests.rs index ce0c04c11..f724d65ba 100644 --- a/src-tauri/src/state/migration/tests.rs +++ b/src-tauri/src/state/migration/tests.rs @@ -1,30 +1,266 @@ use super::{ - decode_font_data_url, decode_image_data_url, load_store_from_path, + decode_font_data_url, decode_image_data_url, fill_missing_sprite_image_metrics, + load_store_from_path, migrate_key_images_to_app_data, migrate_legacy_sprite_wire, migrate_local_fonts_to_app_data, migrate_sound_library_enabled, normalize_font_extension, - normalize_image_extension, normalize_state, parse_portable_asset_reference, - recover_key_mapping_entries, rehome_foreign_asset_references, repair_image_transforms, + normalize_image_extension, normalize_sprite_triggers, normalize_state, + parse_portable_asset_reference, recover_collection_field, recover_key_mapping_entries, + rehome_foreign_asset_references, repair_native_position_ranges, repair_sprite_numeric_ranges, rgba_to_hex, AssetCategory, LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, LEGACY_PANEL_DETACH_ENABLED_KEY, }; use crate::{ defaults::{default_keys, default_positions}, models::{ - AppStoreData, CustomCssHistoryEntry, CustomFont, CustomTab, FontType, GraphPosition, - GraphStatType, GraphType, ImageTransform, KeyCounterAlign, KeyCounterAlignMode, - KeyCounterColor, KeyCounterPlacement, KeyMappings, KeyPosition, KeySlot, KnobPosition, - LayerGroupDef, NoteColor, OverlayBounds, SlotMatch, SoundLibraryEntry, StatPosition, - StatType, StoredOverlayBounds, StoredOverlayNativePosition, TabCss, TabNoteSettings, - POSITION_COLLECTION_FIELDS, + AppStoreData, CustomCssHistoryEntry, CustomFont, CustomTab, EditorDocumentV1, FontType, + GraphPosition, GraphStatType, GraphType, ImageTransform, KeyCounterAlign, + KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyMappings, KeyPosition, + KeySlot, KnobPosition, LayerGroupDef, NoteColor, OverlayBounds, ReactiveSpritePosition, + SlotMatch, SoundLibraryEntry, SpriteAnchor, SpriteImageMetrics, SpritePose, + SpritePositions, SpriteReferenceNaturalSize, SpriteTransform, StatPosition, StatType, + StoredOverlayBounds, StoredOverlayNativePosition, TabCss, TabNoteSettings, + POSITION_COLLECTION_FIELDS, SPRITE_IMAGE_DIMENSION_MAX, SPRITE_IMAGE_DIMENSION_MIN, }, + state::editor::validate_document_transition, }; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; +const SPRITE_CONSTRAINT_FIXTURE: &str = + include_str!("../../../../tests/fixtures/sprite-constraint-parity.json"); + +#[derive(Clone, Copy, Deserialize)] +struct NumericRangeFixture { + min: f64, + max: f64, +} + +#[derive(Clone, Copy, Deserialize)] +struct IntegerRangeFixture { + min: u32, + max: u32, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImageMetricsRangeFixture { + dimension_min: u32, + dimension_max: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SpriteNumericConstraintFixture { + offset: NumericRangeFixture, + rotation: NumericRangeFixture, + scale: NumericRangeFixture, + anchor: NumericRangeFixture, + transition_ms: IntegerRangeFixture, + image_metrics: ImageMetricsRangeFixture, + press_duration_ms: IntegerRangeFixture, +} + fn rehome_test_directory(label: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("dmnote-rehome-{label}-{}", uuid::Uuid::new_v4())) } +fn png_header(width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes +} + +#[test] +fn legacy_sprite_wire_absorbs_image_rect_and_removes_retired_fields() { + let mut sprite = serde_json::to_value(ReactiveSpritePosition { + dx: 32_760.0, + dy: -40.0, + width: 500.0, + height: 400.0, + poses: vec![SpritePose::default()], + ..ReactiveSpritePosition::default() + }) + .unwrap(); + let object = sprite.as_object_mut().unwrap(); + object.insert( + "imageRect".to_string(), + serde_json::json!({ "x": 100.0, "y": 12.5, "width": 320.0, "height": 180.0 }), + ); + object.insert("imageFit".to_string(), serde_json::json!("contain")); + object.insert("imagePlacement".to_string(), serde_json::json!("pivot")); + object["poses"][0]["contactPoint"] = serde_json::json!({ "x": 0.5, "y": 1.0 }); + object["poses"][0]["imagePivot"] = serde_json::json!({ "x": 0.25, "y": 0.75 }); + let mut value = serde_json::json!({ "spritePositions": { "4key": [sprite] } }); + + assert!(migrate_legacy_sprite_wire(&mut value)); + let sprite = &value["spritePositions"]["4key"][0]; + assert_eq!(sprite["dx"], 32_860.0); + assert_eq!(sprite["dy"], -27.5); + assert_eq!(sprite["width"], 320.0); + assert_eq!(sprite["height"], 180.0); + for field in ["imageRect", "imageFit", "imagePlacement"] { + assert!(sprite.get(field).is_none()); + } + for field in ["contactPoint", "imagePivot"] { + assert!(sprite["poses"][0].get(field).is_none()); + } + assert!(!migrate_legacy_sprite_wire(&mut value)); +} + +#[test] +fn legacy_sprite_wire_keeps_outer_bounds_for_invalid_image_rect() { + let mut sprite = serde_json::to_value(ReactiveSpritePosition { + dx: 10.0, + dy: 20.0, + width: 500.0, + height: 400.0, + ..ReactiveSpritePosition::default() + }) + .unwrap(); + sprite["imageRect"] = serde_json::json!({ "x": 1.0, "y": 2.0, "width": 0.0, "height": 180.0 }); + let raw_positions = serde_json::json!({ "4key": [sprite] }); + + let recovered = recover_collection_field("spritePositions", &raw_positions).unwrap(); + let sprites: SpritePositions = serde_json::from_value(recovered).unwrap(); + let sprite = &sprites["4key"][0]; + assert_eq!((sprite.dx, sprite.dy), (10.0, 20.0)); + assert_eq!((sprite.width, sprite.height), (500.0, 400.0)); +} + +#[test] +fn legacy_sprite_wire_skips_image_rect_atomically_when_offset_overflows() { + let mut sprite = serde_json::to_value(ReactiveSpritePosition { + dx: f64::MAX, + dy: 20.0, + width: 500.0, + height: 400.0, + ..ReactiveSpritePosition::default() + }) + .unwrap(); + sprite["imageRect"] = serde_json::json!({ + "x": f64::MAX, + "y": 2.0, + "width": 320.0, + "height": 180.0 + }); + let mut value = serde_json::json!({ "spritePositions": { "4key": [sprite] } }); + + assert!(migrate_legacy_sprite_wire(&mut value)); + let sprite = &value["spritePositions"]["4key"][0]; + assert_eq!(sprite["dx"].as_f64(), Some(f64::MAX)); + assert_eq!(sprite["dy"], 20.0); + assert_eq!(sprite["width"], 500.0); + assert_eq!(sprite["height"], 400.0); + assert!(sprite.get("imageRect").is_none()); +} + +#[test] +fn legacy_sprite_recovery_absorbs_valid_image_rect() { + let mut sprite = serde_json::to_value(ReactiveSpritePosition { + dx: 10.0, + dy: 20.0, + width: 500.0, + height: 400.0, + ..ReactiveSpritePosition::default() + }) + .unwrap(); + sprite["imageRect"] = + serde_json::json!({ "x": 5.0, "y": -2.0, "width": 320.0, "height": 180.0 }); + let raw_positions = serde_json::json!({ "4key": [sprite] }); + + let recovered = recover_collection_field("spritePositions", &raw_positions).unwrap(); + let sprites: SpritePositions = serde_json::from_value(recovered).unwrap(); + let sprite = &sprites["4key"][0]; + assert_eq!((sprite.dx, sprite.dy), (15.0, 18.0)); + assert_eq!((sprite.width, sprite.height), (320.0, 180.0)); +} + +#[test] +fn store_load_migrates_legacy_sprite_wire_before_deserialization() { + let path = std::env::temp_dir().join(format!( + "dmnote-sprite-wire-load-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = AppStoreData::default(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + dx: 10.0, + dy: 20.0, + poses: vec![SpritePose::default()], + ..ReactiveSpritePosition::default() + }], + ); + let mut value = serde_json::to_value(data).unwrap(); + let sprite = value["spritePositions"]["4key"][0].as_object_mut().unwrap(); + sprite.remove("rotation"); + sprite.insert( + "imageRect".to_string(), + serde_json::json!({ "x": 5.0, "y": -2.0, "width": 320.0, "height": 180.0 }), + ); + sprite.insert("imageFit".to_string(), serde_json::json!("cover")); + sprite["poses"][0]["imagePivot"] = serde_json::json!({ "x": 0.25, "y": 0.75 }); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + let sprite = &loaded.data.sprite_positions["4key"][0]; + assert!(loaded.needs_persist); + assert_eq!((sprite.dx, sprite.dy), (15.0, 18.0)); + assert_eq!((sprite.width, sprite.height), (320.0, 180.0)); + assert_eq!(sprite.rotation, 0.0); + let wire = serde_json::to_value(sprite).unwrap(); + assert!(wire.get("imageRect").is_none()); + assert!(wire.get("imageFit").is_none()); + assert!(wire["poses"][0].get("imagePivot").is_none()); +} + +#[test] +fn missing_sprite_metrics_fill_from_local_raster_paths_only() { + let directory = rehome_test_directory("sprite-metrics"); + let base = directory.join("base.png"); + let pose = directory.join("pose.png"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(&base, png_header(640, 360)).unwrap(); + std::fs::write(&pose, png_header(320, 240)).unwrap(); + let base_url = url::Url::from_file_path(&base).unwrap().to_string(); + let pose_path = pose.to_string_lossy().into_owned(); + let mut sprites = SpritePositions::from([( + "4key".to_string(), + vec![ReactiveSpritePosition { + base_image: Some(base_url.clone()), + poses: vec![SpritePose { + image_override: Some(pose_path.clone()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + )]); + + assert!(fill_missing_sprite_image_metrics(&mut sprites)); + let sprite = &sprites["4key"][0]; + assert_eq!( + sprite.reference_natural_size, + Some(SpriteReferenceNaturalSize { + source: Some(base_url), + width: 640, + height: 360, + }) + ); + assert_eq!( + sprite.poses[0].image_override_metrics, + Some(SpriteImageMetrics { + source: pose_path, + width: 320, + height: 240, + }) + ); + assert!(!fill_missing_sprite_image_metrics(&mut sprites)); + + std::fs::remove_dir_all(directory).unwrap(); +} + // Windows에선 /tmp가 절대 경로가 아니므로 픽스처는 플랫폼별 절대 경로 사용 fn absolute_fixture_path(name: &str) -> String { if cfg!(target_os = "windows") { @@ -125,6 +361,198 @@ fn invalid_tab_order_entries_do_not_remove_custom_tabs_during_recovery() { ); } +#[test] +fn sprite_collection_recovery_keeps_valid_entries_and_drops_invalid_entries() { + let valid = serde_json::to_value(ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + ..ReactiveSpritePosition::default() + }) + .unwrap(); + let recovered = recover_collection_field( + "spritePositions", + &serde_json::json!({ "4key": [valid, { "width": "invalid" }] }), + ) + .unwrap(); + let sprites: SpritePositions = serde_json::from_value(recovered).unwrap(); + + assert_eq!(sprites["4key"].len(), 1); +} + +fn store_with_rotation_recovery_sprites() -> AppStoreData { + let mut data = store_with_each_native_collection(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ + ReactiveSpritePosition { + id: uuid::Uuid::from_u128(9_001).to_string(), + dx: 114.55722993194729, + dy: 209.77425768631718, + layer_name: Some("보존할 스프라이트".to_string()), + base_image: Some("preserved-base.png".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some("preserved-base.png".to_string()), + width: 640, + height: 480, + }), + pivot: SpriteAnchor { x: 0.25, y: 0.75 }, + idle_transform: SpriteTransform { + x: 12.5, + rotation: 45.0, + ..SpriteTransform::default() + }, + poses: vec![SpritePose { + pose_id: uuid::Uuid::from_u128(9_002).to_string(), + triggers: vec![data.key_positions["4key"][0].id.clone()], + transform: SpriteTransform { + y: -17.5, + rotation: -75.0, + scale: 1.25, + ..SpriteTransform::default() + }, + pivot: Some(SpriteAnchor { x: 0.75, y: 0.25 }), + image_override: Some("preserved-pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "preserved-pose.png".to_string(), + width: 320, + height: 180, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }, + ReactiveSpritePosition { + id: uuid::Uuid::from_u128(9_003).to_string(), + rotation: 30.0, + ..ReactiveSpritePosition::default() + }, + ], + ); + normalize_state(data) +} + +#[test] +fn malformed_sprite_rotation_recovers_only_that_field_and_converges() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("store.json"); + let expected = store_with_rotation_recovery_sprites(); + for malformed in [ + serde_json::Value::Null, + serde_json::json!("90"), + serde_json::json!(true), + serde_json::json!([90]), + serde_json::json!({ "angle": 90 }), + ] { + let mut fixture = serde_json::to_value(&expected).unwrap(); + fixture["spritePositions"]["4key"][0]["rotation"] = malformed; + assert!(serde_json::from_value::(fixture.clone()).is_err()); + std::fs::write(&path, serde_json::to_vec(&fixture).unwrap()).unwrap(); + // 저장소 복구의 관용 정책이 프리셋 입력 검증으로 번지지 않음 + assert!(crate::commands::preset::load::read_preset_file_for_simulation(&path).is_err()); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!(loaded.data.sprite_positions, expected.sprite_positions); + assert_eq!(loaded.data.keys, expected.keys); + assert_eq!(loaded.data.key_positions, expected.key_positions); + + std::fs::write(&path, serde_json::to_vec(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + assert!(!reloaded.repaired); + assert!(!reloaded.needs_persist); + assert_eq!(reloaded.data, loaded.data); + } +} + +#[test] +fn missing_or_valid_sprite_rotation_needs_no_repair() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("store.json"); + for rotation in [None, Some(-180.0), Some(0.0), Some(37.5), Some(180.0)] { + let mut expected = store_with_rotation_recovery_sprites(); + expected.sprite_positions.get_mut("4key").unwrap()[0].rotation = rotation.unwrap_or(0.0); + let mut fixture = serde_json::to_value(&expected).unwrap(); + if rotation.is_none() { + fixture["spritePositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("rotation"); + } + std::fs::write(&path, serde_json::to_vec(&fixture).unwrap()).unwrap(); + let loaded = load_store_from_path(&path).unwrap(); + assert!(!loaded.repaired); + assert!(!loaded.needs_persist); + assert_eq!(loaded.data.sprite_positions, expected.sprite_positions); + } +} + +#[test] +fn sprite_rotation_numeric_repair_runs_on_normal_and_partial_loads() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("store.json"); + for damage_other_field in [false, true] { + for rotation in [-999.0, -180.0, 37.5, 180.0, 999.0] { + let mut expected = store_with_rotation_recovery_sprites(); + let out_of_range = !(-180.0..=180.0).contains(&rotation); + expected.sprite_positions.get_mut("4key").unwrap()[0].rotation = + if out_of_range { 0.0 } else { rotation }; + let mut fixture = serde_json::to_value(&expected).unwrap(); + fixture["spritePositions"]["4key"][0]["rotation"] = serde_json::json!(rotation); + if damage_other_field { + fixture["language"] = serde_json::json!(42); + } + std::fs::write(&path, serde_json::to_vec(&fixture).unwrap()).unwrap(); + let loaded = load_store_from_path(&path).unwrap(); + assert_eq!(loaded.repaired, damage_other_field || out_of_range); + assert_eq!(loaded.needs_persist, damage_other_field || out_of_range); + assert_eq!(loaded.data.sprite_positions, expected.sprite_positions); + } + } +} + +#[test] +fn sprite_rotation_recovery_does_not_default_other_damaged_fields() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("store.json"); + let expected = store_with_rotation_recovery_sprites(); + for pointer in ["/width", "/poses", "/poses/0/transform/rotation"] { + let mut fixture = serde_json::to_value(&expected).unwrap(); + let sprite = &mut fixture["spritePositions"]["4key"][0]; + sprite["rotation"] = serde_json::Value::Null; + *sprite.pointer_mut(pointer).unwrap() = serde_json::json!("damaged"); + std::fs::write(&path, serde_json::to_vec(&fixture).unwrap()).unwrap(); + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.repaired); + assert!(loaded.needs_persist); + assert_eq!( + loaded.data.sprite_positions["4key"], + expected.sprite_positions["4key"][1..] + ); + } +} + +#[test] +fn sprite_trigger_sets_are_sorted_and_deduplicated_during_normalization() { + let mut data = AppStoreData::default(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + poses: vec![SpritePose { + triggers: vec!["b".to_string(), "a".to_string(), "a".to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + + let normalized = normalize_state(data); + + assert_eq!( + normalized.sprite_positions["4key"][0].poses[0].triggers, + ["a", "b"] + ); +} + #[test] fn missing_note_gradient_multipliers_default_to_one_hundred_on_all_collections() { let gradient = serde_json::json!({ @@ -2539,6 +2967,7 @@ fn tauri_1_3_store_preserves_custom_layout_without_repair_fallback() { "customCss": { "path": "/tmp/tauri-legacy.css", "content": ".tauri {}" }, "overlayResizeAnchor": "top-left", "overlayBounds": { "x": 31, "y": 47, "width": 911, "height": 333 }, + "overlayLastContentLeftOffset": 7.5, "overlayLastContentTopOffset": 12.5, "overlayBoundsAreLogical": false, "keyCounterEnabled": true @@ -2567,6 +2996,8 @@ fn tauri_1_3_store_preserves_custom_layout_without_repair_fallback() { assert_eq!(loaded.data.note_settings.speed, 456); assert_eq!(loaded.data.background_color, "#2468AC"); assert!(loaded.data.use_custom_css); + assert_eq!(loaded.data.overlay_last_content_left_offset, Some(7.5)); + assert_eq!(loaded.data.overlay_last_content_top_offset, Some(12.5)); } #[test] @@ -3635,6 +4066,709 @@ fn asset_position_collections_recover_valid_entries_only() { assert_eq!(loaded.data.font_settings.custom_fonts, vec![font]); } +fn sprite_for_numeric_constraint_parity() -> ReactiveSpritePosition { + ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + } +} + +fn sprite_passes_editor_validation(sprite: ReactiveSpritePosition) -> bool { + let current_store = AppStoreData::default(); + let mut candidate_store = current_store.clone(); + candidate_store + .sprite_positions + .insert("4key".to_string(), vec![sprite]); + validate_document_transition( + &EditorDocumentV1::from_store(¤t_store), + &EditorDocumentV1::from_store(&candidate_store), + ¤t_store, + &candidate_store, + ) + .is_ok() +} + +fn repair_single_sprite_numeric_ranges( + sprite: ReactiveSpritePosition, +) -> (bool, ReactiveSpritePosition) { + let mut data = AppStoreData::default(); + data.sprite_positions + .insert("4key".to_string(), vec![sprite]); + let repaired = repair_sprite_numeric_ranges(&mut data); + let sprite = data.sprite_positions.remove("4key").unwrap().remove(0); + (repaired, sprite) +} + +fn assert_valid_and_unrepaired(name: &str, sprite: ReactiveSpritePosition) { + assert!( + sprite_passes_editor_validation(sprite.clone()), + "validator rejected {name}" + ); + let original = sprite.clone(); + let (repaired, sprite) = repair_single_sprite_numeric_ranges(sprite); + assert!(!repaired, "repair changed valid {name}"); + assert_eq!(sprite, original, "repair mutated valid {name}"); +} + +fn assert_invalid_then_repaired(name: &str, sprite: ReactiveSpritePosition) { + assert!( + !sprite_passes_editor_validation(sprite.clone()), + "validator accepted invalid {name}" + ); + let (repaired, sprite) = repair_single_sprite_numeric_ranges(sprite); + assert!(repaired, "repair ignored invalid {name}"); + assert!( + sprite_passes_editor_validation(sprite), + "repair left {name} invalid" + ); +} + +#[test] +fn sprite_validator_and_repair_ranges_follow_shared_fixture_for_every_numeric_field() { + #[derive(Clone, Copy)] + struct BoundedFloatField { + name: &'static str, + minimum: f64, + maximum: f64, + outside_delta: f64, + set: fn(&mut ReactiveSpritePosition, f64), + } + + #[derive(Clone, Copy)] + struct IntegerField { + name: &'static str, + minimum: u32, + maximum: u32, + set: fn(&mut ReactiveSpritePosition, u32), + } + + let fixture: SpriteNumericConstraintFixture = + serde_json::from_str(SPRITE_CONSTRAINT_FIXTURE).unwrap(); + assert_eq!( + fixture.image_metrics.dimension_min, + SPRITE_IMAGE_DIMENSION_MIN + ); + assert_eq!( + fixture.image_metrics.dimension_max, + SPRITE_IMAGE_DIMENSION_MAX + ); + let bounded_fields: [BoundedFloatField; 11] = [ + BoundedFloatField { + name: "rotation", + minimum: fixture.rotation.min, + maximum: fixture.rotation.max, + outside_delta: 0.001, + set: |sprite, value| sprite.rotation = value, + }, + BoundedFloatField { + name: "idleTransform.x", + minimum: fixture.offset.min, + maximum: fixture.offset.max, + outside_delta: 0.001, + set: |sprite, value| sprite.idle_transform.x = value, + }, + BoundedFloatField { + name: "idleTransform.y", + minimum: fixture.offset.min, + maximum: fixture.offset.max, + outside_delta: 0.001, + set: |sprite, value| sprite.idle_transform.y = value, + }, + BoundedFloatField { + name: "idleTransform.rotation", + minimum: fixture.rotation.min, + maximum: fixture.rotation.max, + outside_delta: 0.001, + set: |sprite, value| sprite.idle_transform.rotation = value, + }, + BoundedFloatField { + name: "idleTransform.scale", + minimum: fixture.scale.min, + maximum: fixture.scale.max, + outside_delta: 0.001, + set: |sprite, value| sprite.idle_transform.scale = value, + }, + BoundedFloatField { + name: "poses[0].transform.x", + minimum: fixture.offset.min, + maximum: fixture.offset.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].transform.x = value, + }, + BoundedFloatField { + name: "poses[0].transform.y", + minimum: fixture.offset.min, + maximum: fixture.offset.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].transform.y = value, + }, + BoundedFloatField { + name: "poses[0].transform.rotation", + minimum: fixture.rotation.min, + maximum: fixture.rotation.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].transform.rotation = value, + }, + BoundedFloatField { + name: "poses[0].transform.scale", + minimum: fixture.scale.min, + maximum: fixture.scale.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].transform.scale = value, + }, + BoundedFloatField { + name: "pivot.x", + minimum: fixture.anchor.min, + maximum: fixture.anchor.max, + outside_delta: 0.001, + set: |sprite, value| sprite.pivot.x = value, + }, + BoundedFloatField { + name: "pivot.y", + minimum: fixture.anchor.min, + maximum: fixture.anchor.max, + outside_delta: 0.001, + set: |sprite, value| sprite.pivot.y = value, + }, + ]; + let integer_fields: [IntegerField; 2] = [ + IntegerField { + name: "transitionMs", + minimum: fixture.transition_ms.min, + maximum: fixture.transition_ms.max, + set: |sprite, value| sprite.transition_ms = value, + }, + IntegerField { + name: "pressDurationMs", + minimum: fixture.press_duration_ms.min, + maximum: fixture.press_duration_ms.max, + set: |sprite, value| sprite.press_duration_ms = value, + }, + ]; + + for field in bounded_fields { + for (edge, value) in [("minimum", field.minimum), ("maximum", field.maximum)] { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_valid_and_unrepaired(&format!("{} {edge}", field.name), sprite); + } + for (side, value) in [ + ("below minimum", field.minimum - field.outside_delta), + ("above maximum", field.maximum + field.outside_delta), + ("non-finite", f64::NAN), + ] { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_invalid_then_repaired(&format!("{} {side}", field.name), sprite); + } + } + + for field in integer_fields { + for (edge, value) in [("minimum", field.minimum), ("maximum", field.maximum)] { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_valid_and_unrepaired(&format!("{} {edge}", field.name), sprite); + } + if let Some(value) = field.minimum.checked_sub(1) { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_invalid_then_repaired(&format!("{} below minimum", field.name), sprite); + } + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, field.maximum.checked_add(1).unwrap()); + assert_invalid_then_repaired(&format!("{} above maximum", field.name), sprite); + } +} + +#[test] +fn sprite_image_normalization_clears_unrenderable_coupled_metadata() { + let mut data = AppStoreData::default(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + base_image: Some(" ".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some(" ".to_string()), + width: 400, + height: 300, + }), + poses: vec![ + SpritePose { + image_override: None, + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/removed.png".to_string(), + width: 100, + height: 200, + }), + ..SpritePose::default() + }, + SpritePose { + image_override: Some(" \t ".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: " \t ".to_string(), + width: 100, + height: 200, + }), + ..SpritePose::default() + }, + ], + ..ReactiveSpritePosition::default() + }], + ); + + assert!(normalize_sprite_triggers(&mut data)); + let sprite = &data.sprite_positions["4key"][0]; + assert_eq!(sprite.reference_natural_size.as_ref().unwrap().source, None); + let poses = &sprite.poses; + assert_eq!(poses[0].image_override_metrics, None); + assert_eq!(poses[1].image_override.as_deref(), Some(" \t ")); + assert_eq!(poses[1].image_override_metrics, None); + assert!(!normalize_sprite_triggers(&mut data)); +} + +#[test] +fn sprite_numeric_repair_drops_only_invalid_image_metrics() { + let sprite = ReactiveSpritePosition { + base_image: Some("/images/base.png".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some("/images/base.png".to_string()), + width: 800, + height: 600, + }), + poses: vec![SpritePose { + image_override: Some("/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/pose.png".to_string(), + width: 0, + height: 240, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let (repaired, sprite) = repair_single_sprite_numeric_ranges(sprite); + assert!(repaired); + assert!(sprite.reference_natural_size.is_some()); + assert_eq!(sprite.poses[0].image_override_metrics, None); + + let missing = ReactiveSpritePosition { + base_image: Some("/images/base.png".to_string()), + poses: vec![SpritePose { + image_override: Some("/images/pose.png".to_string()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let (repaired, missing) = repair_single_sprite_numeric_ranges(missing); + assert!(!repaired); + assert_eq!(missing.reference_natural_size, None); + assert_eq!(missing.poses[0].image_override_metrics, None); +} + +#[test] +fn sprite_numeric_repair_is_component_wise_idempotent_and_preserves_collections() { + let original_triggers = vec![ + "duplicate".to_string(), + String::new(), + "duplicate".to_string(), + ]; + let mut data = AppStoreData::default(); + data.sprite_positions.insert( + "repair-mode".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + dx: f64::NAN, + dy: f64::INFINITY, + width: f64::NEG_INFINITY, + height: 0.0, + pivot: SpriteAnchor { + x: f64::NAN, + y: 0.25, + }, + idle_transform: SpriteTransform { + x: f64::NAN, + y: 17.0, + rotation: 181.0, + scale: 0.0, + }, + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: original_triggers.clone(), + pivot: Some(SpriteAnchor { + x: f64::NAN, + y: 2.0, + }), + transform: SpriteTransform { + x: 2_001.0, + y: -18.0, + rotation: f64::NAN, + scale: 11.0, + }, + ..SpritePose::default() + }], + press_duration_ms: 5_001, + transition_ms: 1_001, + ..ReactiveSpritePosition::default() + }], + ); + + assert!(repair_sprite_numeric_ranges(&mut data)); + let sprite = &data.sprite_positions["repair-mode"][0]; + assert_eq!((sprite.dx, sprite.dy), (0.0, 0.0)); + assert_eq!((sprite.width, sprite.height), (200.0, 200.0)); + assert_eq!(sprite.pivot, SpriteAnchor { x: 0.5, y: 0.25 }); + assert_eq!( + sprite.idle_transform, + SpriteTransform { + x: 0.0, + y: 17.0, + rotation: 0.0, + scale: 1.0, + } + ); + assert_eq!(sprite.poses.len(), 1); + assert_eq!(sprite.poses[0].triggers, original_triggers); + assert_eq!( + sprite.poses[0].pivot, + Some(SpriteAnchor { x: 0.5, y: 0.25 }) + ); + assert_eq!( + sprite.poses[0].transform, + SpriteTransform { + x: 0.0, + y: -18.0, + rotation: 0.0, + scale: 1.0, + } + ); + assert_eq!(sprite.press_duration_ms, 300); + assert_eq!(sprite.transition_ms, 0); + assert!(!repair_sprite_numeric_ranges(&mut data)); +} + +#[test] +fn sprite_numeric_repair_marks_load_and_converges_without_collection_loss() { + let path = std::env::temp_dir().join(format!( + "dmnote-sprite-numeric-repair-{}.json", + uuid::Uuid::new_v4() + )); + let triggers = std::iter::once(String::new()) + .chain((1..=512).map(|index| uuid::Uuid::from_u128(index).to_string())) + .collect::>(); + let poses = (0..65) + .map(|index| SpritePose { + pose_id: uuid::Uuid::from_u128(10_000 + index).to_string(), + triggers: if index == 0 { + triggers.clone() + } else { + vec![uuid::Uuid::from_u128(1_000 + index).to_string()] + }, + transform: SpriteTransform { + x: 2_001.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }) + .collect::>(); + let original_pose_ids = poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(); + let mut data = store_with_each_native_collection(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::from_u128(9_999).to_string(), + width: 0.0, + height: -1.0, + pivot: SpriteAnchor { x: -0.1, y: 1.1 }, + idle_transform: SpriteTransform { + x: 2_001.0, + y: -2_001.0, + rotation: 181.0, + scale: 0.09, + }, + poses, + transition_ms: 1_001, + ..ReactiveSpritePosition::default() + }], + ); + std::fs::write(&path, serde_json::to_vec_pretty(&data).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + let sprite = &loaded.data.sprite_positions["4key"][0]; + assert_eq!(sprite.poses.len(), 65); + assert_eq!(sprite.poses[0].triggers, triggers); + assert_eq!( + sprite + .poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(), + original_pose_ids + ); + assert!(sprite.poses.iter().all(|pose| pose.transform.x == 0.0)); + + std::fs::write(&path, serde_json::to_vec_pretty(&loaded.data).unwrap()).unwrap(); + let reloaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + + assert!(!reloaded.needs_persist); + assert!(!reloaded.repaired); + assert_eq!(reloaded.data.sprite_positions["4key"][0].poses.len(), 65); + assert_eq!( + reloaded.data.sprite_positions["4key"][0].poses[0].triggers, + triggers + ); +} + +#[test] +fn sprite_numeric_repair_runs_after_field_level_recovery() { + let path = std::env::temp_dir().join(format!( + "dmnote-sprite-numeric-field-recovery-{}.json", + uuid::Uuid::new_v4() + )); + let pose_id = uuid::Uuid::new_v4().to_string(); + let triggers = vec![String::new(), uuid::Uuid::new_v4().to_string()]; + let mut data = store_with_each_native_collection(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + idle_transform: SpriteTransform { + scale: 11.0, + ..SpriteTransform::default() + }, + poses: vec![SpritePose { + pose_id: pose_id.clone(), + triggers: triggers.clone(), + transform: SpriteTransform { + rotation: 181.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + let mut value = serde_json::to_value(data).unwrap(); + value["language"] = serde_json::json!(42); + std::fs::write(&path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + let sprite = &loaded.data.sprite_positions["4key"][0]; + + assert!(loaded.needs_persist); + assert!(loaded.repaired); + assert_eq!(sprite.idle_transform.scale, 1.0); + assert_eq!(sprite.poses[0].pose_id, pose_id); + assert_eq!(sprite.poses[0].triggers, triggers); + assert_eq!(sprite.poses[0].transform.rotation, 0.0); +} + +#[test] +fn store_load_round_trips_sprite_activation_and_ignores_removed_match_mode() { + let path = std::env::temp_dir().join(format!( + "dmnote-stale-sprite-strategy-store-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = store_with_each_native_collection(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + let mut value = serde_json::to_value(data).unwrap(); + let sprite = value + .pointer_mut("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object_mut) + .unwrap(); + sprite.insert("activation".to_string(), serde_json::json!("onPress")); + sprite + .get_mut("poses") + .and_then(serde_json::Value::as_array_mut) + .and_then(|poses| poses.first_mut()) + .and_then(serde_json::Value::as_object_mut) + .unwrap() + .insert("matchMode".to_string(), serde_json::json!("exact")); + std::fs::write(&path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + let serialized = serde_json::to_value(loaded.data).unwrap(); + let sprite = serialized + .pointer("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object) + .unwrap(); + + assert_eq!(sprite["activation"], serde_json::json!("onPress")); + assert!(!sprite["poses"][0] + .as_object() + .unwrap() + .contains_key("matchMode")); +} + +#[test] +fn store_load_defaults_missing_sprite_activation_and_duration() { + let path = std::env::temp_dir().join(format!( + "dmnote-missing-sprite-oneshot-fields-store-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = store_with_each_native_collection(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + let mut value = serde_json::to_value(data).unwrap(); + let sprite = value + .pointer_mut("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object_mut) + .unwrap(); + sprite.remove("activation"); + sprite.remove("pressDurationMs"); + std::fs::write(&path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + let sprite = &loaded.data.sprite_positions["4key"][0]; + + assert_eq!( + serde_json::to_value(sprite.activation).unwrap(), + serde_json::json!("whileHeld") + ); + assert_eq!(sprite.press_duration_ms, 300); +} + +#[test] +fn missing_element_rotations_default_without_persist_or_repair() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("store.json"); + let expected = normalize_state(store_with_each_native_collection()); + let mut fixture = serde_json::to_value(&expected).unwrap(); + for field in POSITION_COLLECTION_FIELDS { + for position in fixture[field] + .as_object_mut() + .unwrap() + .values_mut() + .flat_map(|positions| positions.as_array_mut().unwrap()) + { + position.as_object_mut().unwrap().remove("rotation"); + } + } + std::fs::write(&path, serde_json::to_vec(&fixture).unwrap()).unwrap(); + let loaded = load_store_from_path(&path).unwrap(); + assert!(!loaded.needs_persist); + assert!(!loaded.repaired); + for field in POSITION_COLLECTION_FIELDS { + assert_eq!( + serde_json::to_value(&loaded.data).unwrap()[field], + serde_json::to_value(&expected).unwrap()[field] + ); + } +} + +fn check_element_rotation_load_repair(damage_other_field: bool) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("store.json"); + let expected = normalize_state(store_with_each_native_collection()); + let expected = serde_json::to_value(expected).unwrap(); + let mut fixture = expected.clone(); + for (field, index, rotation) in [ + ("keyPositions", 1, 999.0), + ("statPositions", 0, -999.0), + ("graphPositions", 0, 999.0), + ("knobPositions", 0, -999.0), + ] { + fixture[field]["4key"][index]["rotation"] = json!(rotation); + if damage_other_field { + fixture[field]["4key"][index]["backgroundColor"] = json!(true); + } + } + std::fs::write(&path, serde_json::to_vec(&fixture).unwrap()).unwrap(); + let loaded = load_store_from_path(&path).unwrap(); + assert!(loaded.needs_persist); + assert!(loaded.repaired); + let restored = serde_json::to_value(&loaded.data).unwrap(); + assert_eq!(restored["keys"], expected["keys"]); + for field in POSITION_COLLECTION_FIELDS { + assert_eq!( + restored[field], expected[field], + "{field} slots and identities must survive" + ); + } + std::fs::write(&path, serde_json::to_vec(&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); +} + +#[test] +fn out_of_range_element_rotations_repair_in_place_and_mark_both_flags() { + check_element_rotation_load_repair(false); +} + +#[test] +fn compound_element_rotation_recovery_preserves_ids_lengths_and_key_slots() { + check_element_rotation_load_repair(true); +} + +#[test] +fn element_rotation_repair_handles_nonfinite_values_and_preserves_boundaries() { + for rotation in [ + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + -180.1, + 180.1, + -180.0, + 180.0, + ] { + let mut data = normalize_state(store_with_each_native_collection()); + let expected = data.clone(); + for position in [ + &mut data.key_positions.get_mut("4key").unwrap()[0], + &mut data.stat_positions.get_mut("4key").unwrap()[0].position, + &mut data.graph_positions.get_mut("4key").unwrap()[0].position, + &mut data.knob_positions.get_mut("4key").unwrap()[0].position, + ] { + position.rotation = rotation; + } + if [-180.0, 180.0].contains(&rotation) { + let before = data.clone(); + assert!(!repair_native_position_ranges(&mut data)); + assert_eq!(data, before); + } else { + assert!(repair_native_position_ranges(&mut data)); + assert_eq!(data, expected); + } + assert!(!repair_native_position_ranges(&mut data)); + } +} + #[test] fn image_settings_recover_in_place_without_removing_positions() { let path = std::env::temp_dir().join(format!( @@ -3685,7 +4819,7 @@ fn image_settings_recover_in_place_without_removing_positions() { }); assert!(transform.scale.is_nan()); let original_len = data.key_positions["recovery-mode"].len(); - assert!(repair_image_transforms(&mut data)); + assert!(repair_native_position_ranges(&mut data)); assert_eq!(data.key_positions["recovery-mode"].len(), original_len); assert_eq!( data.key_positions["recovery-mode"][0].active_image_transform, @@ -3837,6 +4971,135 @@ fn portable_asset_reference_parser_accepts_cross_platform_absolute_forms() { } } +#[test] +fn sprite_image_migration_rewrites_coupled_metric_sources() { + let root = rehome_test_directory("sprite-image-migration"); + let source_dir = root.join("source"); + let app_data_dir = root.join("app-data"); + std::fs::create_dir_all(&source_dir).unwrap(); + let base_path = source_dir.join("base.png"); + let override_path = source_dir.join("override.png"); + std::fs::write(&base_path, b"base-image").unwrap(); + std::fs::write(&override_path, b"override-image").unwrap(); + let base_source = base_path.to_string_lossy().into_owned(); + let override_source = override_path.to_string_lossy().into_owned(); + let mut data = AppStoreData::default(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + base_image: Some(base_source.clone()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some(base_source.clone()), + width: 800, + height: 600, + }), + poses: vec![SpritePose { + image_override: Some(override_source.clone()), + image_override_metrics: Some(SpriteImageMetrics { + source: override_source.clone(), + width: 320, + height: 240, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + + assert!(migrate_key_images_to_app_data(&app_data_dir, &mut data)); + + let sprite = &data.sprite_positions["4key"][0]; + let migrated_base = sprite.base_image.as_deref().unwrap(); + let migrated_override = sprite.poses[0].image_override.as_deref().unwrap(); + assert_ne!(migrated_base, base_source); + assert_ne!(migrated_override, override_source); + assert!(std::path::Path::new(migrated_base).starts_with(app_data_dir.join("images"))); + assert!(std::path::Path::new(migrated_override).starts_with(app_data_dir.join("images"))); + assert_eq!( + sprite + .reference_natural_size + .as_ref() + .unwrap() + .source + .as_deref(), + Some(migrated_base) + ); + assert_eq!( + sprite.poses[0] + .image_override_metrics + .as_ref() + .unwrap() + .source, + migrated_override + ); + assert_eq!(std::fs::read(migrated_base).unwrap(), b"base-image"); + assert_eq!(std::fs::read(migrated_override).unwrap(), b"override-image"); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn sprite_rehome_rewrites_coupled_metric_sources() { + let dir = rehome_test_directory("sprite-sources"); + let images_dir = dir.join("images"); + std::fs::create_dir_all(&images_dir).unwrap(); + std::fs::write(images_dir.join("base.png"), b"base-image").unwrap(); + std::fs::write(images_dir.join("override.png"), b"override-image").unwrap(); + let foreign_base = + r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\images\base.png".to_string(); + let foreign_override = + r"C:\Users\me\AppData\Roaming\com.dmnote.desktop\images\override.png".to_string(); + let mut data = AppStoreData::default(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + base_image: Some(foreign_base.clone()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some(foreign_base), + width: 800, + height: 600, + }), + poses: vec![SpritePose { + image_override: Some(foreign_override.clone()), + image_override_metrics: Some(SpriteImageMetrics { + source: foreign_override, + width: 320, + height: 240, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + + assert!(rehome_foreign_asset_references(&dir, &mut data)); + + let sprite = &data.sprite_positions["4key"][0]; + assert_eq!( + sprite.base_image.as_deref(), + Some(images_dir.join("base.png").to_string_lossy().as_ref()) + ); + assert_eq!( + sprite.reference_natural_size.as_ref().unwrap().source, + sprite.base_image + ); + assert_eq!( + sprite.poses[0].image_override.as_deref(), + Some(images_dir.join("override.png").to_string_lossy().as_ref()) + ); + assert_eq!( + sprite.poses[0] + .image_override_metrics + .as_ref() + .unwrap() + .source, + sprite.poses[0].image_override.as_deref().unwrap() + ); + assert!(!rehome_foreign_asset_references(&dir, &mut data)); + + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn cross_platform_asset_forms_rehome_to_existing_local_files() { let dir = rehome_test_directory("absolute-forms"); diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 52697893e..50c124052 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -6,8 +6,8 @@ use crate::{ errors::EditorCommitError, models::{ AppStoreData, EditorDocumentV1, EditorPatchV1, GraphPosition, KeyMappings, KeyPosition, - KeySlot, KnobPosition, StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, - EDITOR_SCHEMA_VERSION, + KeySlot, KnobPosition, ReactiveSpritePosition, SpritePose, StatPosition, + EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, }, }; @@ -27,47 +27,57 @@ pub(crate) struct BackfillOutcome { } trait NativeElement: Clone + PartialEq { - fn position(&self) -> &KeyPosition; - fn position_mut(&mut self) -> &mut KeyPosition; + fn id(&self) -> &str; + fn id_mut(&mut self) -> &mut String; } impl NativeElement for KeyPosition { - fn position(&self) -> &KeyPosition { - self + fn id(&self) -> &str { + &self.id } - fn position_mut(&mut self) -> &mut KeyPosition { - self + fn id_mut(&mut self) -> &mut String { + &mut self.id } } impl NativeElement for StatPosition { - fn position(&self) -> &KeyPosition { - &self.position + fn id(&self) -> &str { + &self.position.id } - fn position_mut(&mut self) -> &mut KeyPosition { - &mut self.position + fn id_mut(&mut self) -> &mut String { + &mut self.position.id } } impl NativeElement for GraphPosition { - fn position(&self) -> &KeyPosition { - &self.position + fn id(&self) -> &str { + &self.position.id } - fn position_mut(&mut self) -> &mut KeyPosition { - &mut self.position + fn id_mut(&mut self) -> &mut String { + &mut self.position.id } } impl NativeElement for KnobPosition { - fn position(&self) -> &KeyPosition { - &self.position + fn id(&self) -> &str { + &self.position.id } - fn position_mut(&mut self) -> &mut KeyPosition { - &mut self.position + fn id_mut(&mut self) -> &mut String { + &mut self.position.id + } +} + +impl NativeElement for ReactiveSpritePosition { + fn id(&self) -> &str { + &self.id + } + + fn id_mut(&mut self) -> &mut String { + &mut self.id } } @@ -109,8 +119,8 @@ fn backfill_collection( continue; }; for element in elements { - let id = &element.position().id; - if is_valid_element_id(id) && seen.insert(id.clone()) { + let id = element.id(); + if is_valid_element_id(id) && seen.insert(id.to_string()) { continue; } @@ -120,12 +130,43 @@ fn backfill_collection( } let id = new_unique_id(reserved); seen.insert(id.clone()); - element.position_mut().id = id; + *element.id_mut() = id; outcome.changed = true; } } } +fn backfill_sprite_pose_ids( + collection: &mut HashMap>, + mode: Option<&str>, + seen: &mut HashSet, + reserved: &mut HashSet, + outcome: &mut BackfillOutcome, +) { + for collection_mode in sorted_modes(collection) { + if mode.is_some_and(|mode| mode != collection_mode) { + continue; + } + let Some(sprites) = collection.get_mut(&collection_mode) else { + continue; + }; + for sprite in sprites { + for pose in &mut sprite.poses { + if is_valid_element_id(&pose.pose_id) && seen.insert(pose.pose_id.clone()) { + continue; + } + if !pose.pose_id.is_empty() { + outcome.repaired = true; + } + let id = new_unique_id(reserved); + seen.insert(id.clone()); + pose.pose_id = id; + outcome.changed = true; + } + } + } +} + pub(crate) fn backfill_store_element_ids(store: &mut AppStoreData) -> BackfillOutcome { let mut seen = HashSet::new(); let mut reserved = collect_store_ids(store); @@ -154,6 +195,19 @@ pub(crate) fn backfill_store_element_ids(store: &mut AppStoreData) -> BackfillOu &mut reserved, &mut outcome, ); + backfill_collection( + &mut store.sprite_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + backfill_sprite_pose_ids( + &mut store.sprite_positions, + None, + &mut seen, + &mut reserved, + &mut outcome, + ); outcome } @@ -163,8 +217,8 @@ fn collect_collection_ids( ) { for elements in collection.values() { for element in elements { - if is_valid_element_id(&element.position().id) { - ids.insert(element.position().id.clone()); + if is_valid_element_id(element.id()) { + ids.insert(element.id().to_string()); } } } @@ -176,6 +230,14 @@ fn collect_store_ids(store: &AppStoreData) -> HashSet { collect_collection_ids(&store.stat_positions, &mut ids); collect_collection_ids(&store.graph_positions, &mut ids); collect_collection_ids(&store.knob_positions, &mut ids); + collect_collection_ids(&store.sprite_positions, &mut ids); + for sprite in store.sprite_positions.values().flatten() { + for pose in &sprite.poses { + if is_valid_element_id(&pose.pose_id) { + ids.insert(pose.pose_id.clone()); + } + } + } ids } @@ -186,7 +248,72 @@ fn rekey_collection( for mode in sorted_modes(collection) { if let Some(elements) = collection.get_mut(&mode) { for element in elements { - element.position_mut().id = new_unique_id(reserved); + *element.id_mut() = new_unique_id(reserved); + } + } + } +} + +fn rekey_key_collection( + collection: &mut HashMap>, + mode: Option<&str>, + reserved: &mut HashSet, +) -> HashMap { + let mut replacements = HashMap::new(); + for collection_mode in sorted_modes(collection) { + if mode.is_some_and(|mode| mode != collection_mode) { + continue; + } + let Some(elements) = collection.get_mut(&collection_mode) else { + continue; + }; + for element in elements { + let old_id = element.id.clone(); + let new_id = new_unique_id(reserved); + if !old_id.is_empty() { + replacements.insert(old_id, new_id.clone()); + } + element.id = new_id; + } + } + replacements +} + +fn rekey_sprite_pose_ids( + collection: &mut HashMap>, + mode: Option<&str>, + reserved: &mut HashSet, +) { + for collection_mode in sorted_modes(collection) { + if mode.is_some_and(|mode| mode != collection_mode) { + continue; + } + if let Some(sprites) = collection.get_mut(&collection_mode) { + for sprite in sprites { + for pose in &mut sprite.poses { + pose.pose_id = new_unique_id(reserved); + } + } + } + } +} + +fn remap_sprite_triggers( + collection: &mut HashMap>, + mode: Option<&str>, + replacements: &HashMap, +) { + for (collection_mode, sprites) in collection { + if mode.is_some_and(|mode| mode != collection_mode) { + continue; + } + for sprite in sprites { + for pose in &mut sprite.poses { + for trigger in &mut pose.triggers { + if let Some(replacement) = replacements.get(trigger) { + trigger.clone_from(replacement); + } + } } } } @@ -194,10 +321,13 @@ fn rekey_collection( pub(crate) fn rekey_store_element_ids(store: &mut AppStoreData) { let mut reserved = collect_store_ids(store); - rekey_collection(&mut store.key_positions, &mut reserved); + let key_replacements = rekey_key_collection(&mut store.key_positions, None, &mut reserved); rekey_collection(&mut store.stat_positions, &mut reserved); rekey_collection(&mut store.graph_positions, &mut reserved); rekey_collection(&mut store.knob_positions, &mut reserved); + rekey_collection(&mut store.sprite_positions, &mut reserved); + rekey_sprite_pose_ids(&mut store.sprite_positions, None, &mut reserved); + remap_sprite_triggers(&mut store.sprite_positions, None, &key_replacements); } fn rekey_collection_mode( @@ -207,13 +337,13 @@ fn rekey_collection_mode( ) { if let Some(elements) = collection.get_mut(mode) { for element in elements { - element.position_mut().id = new_unique_id(reserved); + *element.id_mut() = new_unique_id(reserved); } } } pub(crate) fn rekey_mode_element_ids(store: &mut AppStoreData, mode: &str) { - rekey_mode_element_ids_for_collections(store, mode, true, true, true, true); + rekey_mode_element_ids_for_collections(store, mode, true, true, true, true, true); } pub(crate) fn rekey_mode_element_ids_for_collections( @@ -223,10 +353,13 @@ pub(crate) fn rekey_mode_element_ids_for_collections( stat_positions: bool, graph_positions: bool, knob_positions: bool, + sprite_positions: bool, ) { let mut reserved = collect_store_ids(store); + let mut key_replacements = HashMap::new(); if key_positions { - rekey_collection_mode(&mut store.key_positions, mode, &mut reserved); + key_replacements = + rekey_key_collection(&mut store.key_positions, Some(mode), &mut reserved); } if stat_positions { rekey_collection_mode(&mut store.stat_positions, mode, &mut reserved); @@ -237,6 +370,11 @@ pub(crate) fn rekey_mode_element_ids_for_collections( if knob_positions { rekey_collection_mode(&mut store.knob_positions, mode, &mut reserved); } + if sprite_positions { + rekey_collection_mode(&mut store.sprite_positions, mode, &mut reserved); + rekey_sprite_pose_ids(&mut store.sprite_positions, Some(mode), &mut reserved); + } + remap_sprite_triggers(&mut store.sprite_positions, Some(mode), &key_replacements); } fn backfill_collection_mode( @@ -250,8 +388,8 @@ fn backfill_collection_mode( return; }; for element in elements { - let id = &element.position().id; - if is_valid_element_id(id) && seen.insert(id.clone()) { + let id = element.id(); + if is_valid_element_id(id) && seen.insert(id.to_string()) { continue; } @@ -261,7 +399,7 @@ fn backfill_collection_mode( } let id = new_unique_id(reserved); seen.insert(id.clone()); - element.position_mut().id = id; + *element.id_mut() = id; outcome.changed = true; } } @@ -275,12 +413,31 @@ pub(crate) fn backfill_mode_element_ids_for_collections( stat_positions: bool, graph_positions: bool, knob_positions: bool, + sprite_positions: bool, ) -> BackfillOutcome { let mut seen = HashSet::new(); collect_collection_ids_outside_target(&store.key_positions, mode, key_positions, &mut seen); collect_collection_ids_outside_target(&store.stat_positions, mode, stat_positions, &mut seen); collect_collection_ids_outside_target(&store.graph_positions, mode, graph_positions, &mut seen); collect_collection_ids_outside_target(&store.knob_positions, mode, knob_positions, &mut seen); + collect_collection_ids_outside_target( + &store.sprite_positions, + mode, + sprite_positions, + &mut seen, + ); + for (collection_mode, sprites) in &store.sprite_positions { + if sprite_positions && collection_mode == mode { + continue; + } + for sprite in sprites { + for pose in &sprite.poses { + if is_valid_element_id(&pose.pose_id) { + seen.insert(pose.pose_id.clone()); + } + } + } + } let mut reserved = collect_store_ids(store); let mut outcome = BackfillOutcome::default(); if key_positions { @@ -319,6 +476,22 @@ pub(crate) fn backfill_mode_element_ids_for_collections( &mut outcome, ); } + if sprite_positions { + backfill_collection_mode( + &mut store.sprite_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + backfill_sprite_pose_ids( + &mut store.sprite_positions, + Some(mode), + &mut seen, + &mut reserved, + &mut outcome, + ); + } outcome } diff --git a/src-tauri/src/state/native_element_id/tests.rs b/src-tauri/src/state/native_element_id/tests.rs index 50b1f1577..fb6bfdbe3 100644 --- a/src-tauri/src/state/native_element_id/tests.rs +++ b/src-tauri/src/state/native_element_id/tests.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; use crate::models::{ AppStoreData, EditorPatchV1, GraphPosition, GraphStatType, GraphType, KeyPosition, - KnobPosition, SlotMatch, StatPosition, StatType, + KnobPosition, ReactiveSpritePosition, SlotMatch, SpritePose, StatPosition, StatType, }; use super::*; @@ -94,6 +94,69 @@ fn validation_code(error: EditorCommitError) -> String { .unwrap() } +fn sprite_pose(trigger: u128, transform_x: f64) -> SpritePose { + let mut pose = SpritePose { + triggers: vec![Uuid::from_u128(trigger).to_string()], + ..SpritePose::default() + }; + pose.transform.x = transform_x; + pose +} + +fn store_with_sprites(sprites: Vec) -> AppStoreData { + let mut store = AppStoreData { + sprite_positions: HashMap::from([("mode".to_string(), sprites)]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + store +} + +fn prepare_v1_sprite_patch( + store: &AppStoreData, + sprites: Vec, +) -> Vec { + let mut patch = EditorPatchV1 { + sprite_positions: Some(HashMap::from([("mode".to_string(), sprites)])), + ..EditorPatchV1::default() + }; + prepare_commit_patch_element_ids(store, &mut patch).unwrap(); + patch.sprite_positions.unwrap().remove("mode").unwrap() +} + +#[test] +fn backfill_assigns_globally_unique_sprite_and_pose_ids() { + let duplicate = uuid::Uuid::new_v4().to_string(); + let mut store = AppStoreData::default(); + store.sprite_positions.insert( + "mode".to_string(), + vec![ReactiveSpritePosition { + id: duplicate.clone(), + poses: vec![ + SpritePose { + pose_id: duplicate, + ..SpritePose::default() + }, + SpritePose::default(), + ], + ..ReactiveSpritePosition::default() + }], + ); + + let outcome = backfill_store_element_ids(&mut store); + let sprite = &store.sprite_positions["mode"][0]; + let ids = [ + sprite.id.as_str(), + sprite.poses[0].pose_id.as_str(), + sprite.poses[1].pose_id.as_str(), + ]; + + assert!(outcome.changed); + assert!(outcome.repaired); + assert!(ids.iter().all(|id| is_valid_element_id(id))); + assert_eq!(ids.into_iter().collect::>().len(), 3); +} + #[test] fn backfill_replaces_only_missing_invalid_and_duplicate_ids() { let mut store = store_with_all_collections(); @@ -201,6 +264,206 @@ fn v2_requires_valid_ids_and_checks_merged_global_uniqueness() { ); } +#[test] +fn v2_sprite_patch_requires_valid_globally_unique_pose_ids() { + let mut store = store_with_all_collections(); + store.sprite_positions.insert( + "mode".to_string(), + vec![ReactiveSpritePosition { + poses: vec![SpritePose::default()], + ..ReactiveSpritePosition::default() + }], + ); + rekey_store_element_ids(&mut store); + let valid = EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + sprite_positions: Some(store.sprite_positions.clone()), + ..EditorPatchV1::default() + }; + + for invalid_pose_id in [Uuid::nil().to_string(), "not-a-uuid".to_string()] { + let mut invalid = valid.clone(); + invalid + .sprite_positions + .as_mut() + .unwrap() + .get_mut("mode") + .unwrap()[0] + .poses[0] + .pose_id = invalid_pose_id; + assert_eq!( + validation_code(prepare_commit_patch_element_ids(&store, &mut invalid).unwrap_err()), + INVALID_ELEMENT_ID + ); + } + + let duplicate_pose_id = store.sprite_positions["mode"][0].poses[0].pose_id.clone(); + let mut duplicate = valid; + duplicate.sprite_positions.as_mut().unwrap().insert( + "other-mode".to_string(), + vec![ReactiveSpritePosition { + id: Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: duplicate_pose_id, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + assert_eq!( + validation_code(prepare_commit_patch_element_ids(&store, &mut duplicate).unwrap_err()), + DUPLICATE_ELEMENT_ID + ); +} + +#[test] +fn v1_idless_sprite_pose_reorder_inherits_ids_by_value() { + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![sprite_pose(10, 1.0), sprite_pose(20, 2.0)], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_ids = original + .poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(); + let mut candidate = original.clone(); + candidate.poses.swap(0, 1); + for pose in &mut candidate.poses { + pose.pose_id.clear(); + } + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + + assert_eq!(assigned[0].poses[0].pose_id, original_ids[1]); + assert_eq!(assigned[0].poses[1].pose_id, original_ids[0]); +} + +#[test] +fn v1_idless_sprite_pose_value_change_gets_fresh_id() { + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![sprite_pose(10, 1.0)], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_pose_id = original.poses[0].pose_id.clone(); + let mut candidate = original.clone(); + candidate.poses[0].pose_id.clear(); + candidate.poses[0].transform.x = 9.0; + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + let assigned_pose_id = &assigned[0].poses[0].pose_id; + + assert_ne!(assigned_pose_id, &original_pose_id); + assert!(is_valid_element_id(assigned_pose_id)); +} + +#[test] +fn v1_idless_sprite_pose_normalizes_triggers_for_value_matching() { + let trigger_a = Uuid::from_u128(10).to_string(); + let trigger_b = Uuid::from_u128(20).to_string(); + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![SpritePose { + triggers: vec![trigger_a.clone(), trigger_b.clone()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_pose_id = original.poses[0].pose_id.clone(); + let mut candidate = original.clone(); + candidate.poses[0].pose_id.clear(); + candidate.poses[0].triggers = vec![trigger_b, trigger_a.clone(), trigger_a]; + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + + assert_eq!(assigned[0].poses[0].pose_id, original_pose_id); +} + +#[test] +fn v1_idless_sprite_pose_does_not_inherit_from_another_sprite() { + let store = store_with_sprites(vec![ + ReactiveSpritePosition { + poses: vec![sprite_pose(10, 1.0)], + ..ReactiveSpritePosition::default() + }, + ReactiveSpritePosition { + poses: vec![sprite_pose(20, 2.0)], + ..ReactiveSpritePosition::default() + }, + ]); + let sprites = &store.sprite_positions["mode"]; + let first_pose_id = sprites[0].poses[0].pose_id.clone(); + let second_pose_id = sprites[1].poses[0].pose_id.clone(); + let mut candidate = sprites.clone(); + candidate[0].poses[0] = candidate[1].poses[0].clone(); + candidate[0].poses[0].pose_id.clear(); + + let assigned = prepare_v1_sprite_patch(&store, candidate); + let assigned_pose_id = &assigned[0].poses[0].pose_id; + + assert_ne!(assigned_pose_id, &first_pose_id); + assert_ne!(assigned_pose_id, &second_pose_id); + assert!(is_valid_element_id(assigned_pose_id)); + assert_eq!(assigned[1].poses[0].pose_id, second_pose_id); +} + +#[test] +fn v1_idless_new_sprite_parent_gets_fresh_pose_ids() { + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![sprite_pose(10, 1.0), sprite_pose(20, 2.0)], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_pose_ids = original + .poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(); + let mut candidate = original.clone(); + candidate.id.clear(); + for pose in &mut candidate.poses { + pose.pose_id.clear(); + } + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + + assert_ne!(assigned[0].id, original.id); + assert!(assigned[0] + .poses + .iter() + .all(|pose| !original_pose_ids.contains(&pose.pose_id))); +} + +#[test] +fn v1_idless_equal_sprite_poses_consume_current_ids_in_order() { + let pose = sprite_pose(10, 1.0); + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![pose.clone(), pose], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_ids = original + .poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(); + let mut candidate = original.clone(); + for pose in &mut candidate.poses { + pose.pose_id.clear(); + } + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + let assigned_ids = assigned[0] + .poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(); + + assert_eq!(assigned_ids, original_ids); +} + #[test] fn v1_preserves_explicit_current_ids_and_rekeys_stale_ids() { let store = store_with_all_collections(); diff --git a/src-tauri/src/state/native_element_id/v1_adapter.rs b/src-tauri/src/state/native_element_id/v1_adapter.rs index dd8252086..2315eb4b4 100644 --- a/src-tauri/src/state/native_element_id/v1_adapter.rs +++ b/src-tauri/src/state/native_element_id/v1_adapter.rs @@ -12,8 +12,8 @@ pub(super) fn collect_collection_ids_outside_target( continue; } for element in elements { - if is_valid_element_id(&element.position().id) { - ids.insert(element.position().id.clone()); + if is_valid_element_id(element.id()) { + ids.insert(element.id().to_string()); } } } @@ -29,7 +29,7 @@ fn validate_supplied_collection_ids( continue; }; for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; + let id = element.id(); if id.is_empty() { if require_id { return Err(EditorCommitError::validation( @@ -45,7 +45,7 @@ fn validate_supplied_collection_ids( format!("native element {mode}[{index}] has an invalid ID"), )); } - if !seen.insert(id.clone()) { + if !seen.insert(id.to_string()) { return Err(EditorCommitError::validation( DUPLICATE_ELEMENT_ID, format!("native element ID {id} appears more than once in the commit"), @@ -56,6 +56,51 @@ fn validate_supplied_collection_ids( Ok(()) } +fn validate_sprite_pose_ids( + collection: &HashMap>, + require_id: bool, + seen: &mut HashSet, +) -> Result<(), EditorCommitError> { + for mode in sorted_modes(collection) { + let Some(sprites) = collection.get(&mode) else { + continue; + }; + for (sprite_index, sprite) in sprites.iter().enumerate() { + for (pose_index, pose) in sprite.poses.iter().enumerate() { + if pose.pose_id.is_empty() { + if require_id { + return Err(EditorCommitError::validation( + MISSING_ELEMENT_ID, + format!( + "sprite pose {mode}[{sprite_index}].poses[{pose_index}] is missing an ID" + ), + )); + } + continue; + } + if !is_valid_element_id(&pose.pose_id) { + return Err(EditorCommitError::validation( + INVALID_ELEMENT_ID, + format!( + "sprite pose {mode}[{sprite_index}].poses[{pose_index}] has an invalid ID" + ), + )); + } + if !seen.insert(pose.pose_id.clone()) { + return Err(EditorCommitError::validation( + DUPLICATE_ELEMENT_ID, + format!( + "native element ID {} appears more than once in the commit", + pose.pose_id + ), + )); + } + } + } + } + Ok(()) +} + fn validate_supplied_patch_ids( patch: &EditorPatchV1, require_id: bool, @@ -73,6 +118,10 @@ fn validate_supplied_patch_ids( if let Some(collection) = patch.knob_positions.as_ref() { validate_supplied_collection_ids(collection, require_id, &mut seen)?; } + if let Some(collection) = patch.sprite_positions.as_ref() { + validate_supplied_collection_ids(collection, require_id, &mut seen)?; + validate_sprite_pose_ids(collection, require_id, &mut seen)?; + } Ok(seen) } @@ -89,9 +138,9 @@ fn sanitize_v1_supplied_collection_ids( continue; }; for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; + let id = element.id(); if !id.is_empty() { - owned.insert(id.clone(), (mode.clone(), index)); + owned.insert(id.to_string(), (mode.clone(), index)); } } } @@ -102,7 +151,7 @@ fn sanitize_v1_supplied_collection_ids( continue; }; for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; + let id = element.id(); if id.is_empty() { continue; } @@ -113,7 +162,7 @@ fn sanitize_v1_supplied_collection_ids( )); } occurrences - .entry(id.clone()) + .entry(id.to_string()) .or_default() .push((mode.clone(), index)); } @@ -144,7 +193,7 @@ fn sanitize_v1_supplied_collection_ids( .get_mut(&mode) .and_then(|elements| elements.get_mut(index)) { - element.position_mut().id.clear(); + element.id_mut().clear(); } } Ok(()) @@ -166,14 +215,106 @@ fn sanitize_v1_supplied_patch_ids( if let Some(collection) = patch.knob_positions.as_mut() { sanitize_v1_supplied_collection_ids(collection, &store.knob_positions)?; } + if let Some(collection) = patch.sprite_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.sprite_positions)?; + let owned = store + .sprite_positions + .values() + .flatten() + .flat_map(|sprite| sprite.poses.iter().map(|pose| pose.pose_id.clone())) + .collect::>(); + let mut seen = HashSet::new(); + for mode in sorted_modes(collection) { + let Some(sprites) = collection.get_mut(&mode) else { + continue; + }; + for (sprite_index, sprite) in sprites.iter_mut().enumerate() { + for (pose_index, pose) in sprite.poses.iter_mut().enumerate() { + if pose.pose_id.is_empty() { + continue; + } + if !is_valid_element_id(&pose.pose_id) { + return Err(EditorCommitError::validation( + INVALID_ELEMENT_ID, + format!( + "sprite pose {mode}[{sprite_index}].poses[{pose_index}] has an invalid ID" + ), + )); + } + if !owned.contains(&pose.pose_id) || !seen.insert(pose.pose_id.clone()) { + pose.pose_id.clear(); + } + } + } + } + } Ok(()) } +fn adapt_v1_sprite_pose_ids( + current: &HashMap>, + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, + reserved: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(sprites) = candidate.get_mut(&mode) else { + continue; + }; + let current_sprites = current.get(&mode); + for sprite in sprites { + let current_sprite = current_sprites + .and_then(|sprites| sprites.iter().find(|current| current.id == sprite.id)); + let normalized_current = current_sprite + .map(|sprite| { + sprite + .poses + .iter() + .map(sprite_pose_value_without_id) + .collect::>() + }) + .unwrap_or_default(); + for pose in &mut sprite.poses { + if !pose.pose_id.is_empty() { + consumed_current_ids.insert(pose.pose_id.clone()); + continue; + } + let target = sprite_pose_value_without_id(pose); + let inherited = current_sprite.and_then(|sprite| { + sprite + .poses + .iter() + .zip(&normalized_current) + .find(|(current_pose, normalized)| { + is_valid_element_id(¤t_pose.pose_id) + && !consumed_current_ids.contains(¤t_pose.pose_id) + && **normalized == target + }) + .map(|(current_pose, _)| current_pose.pose_id.clone()) + }); + if let Some(id) = inherited { + consumed_current_ids.insert(id.clone()); + pose.pose_id = id; + } else { + pose.pose_id = new_unique_id(reserved); + } + } + } + } +} + +fn sprite_pose_value_without_id(pose: &SpritePose) -> SpritePose { + let mut copy = pose.clone(); + copy.pose_id.clear(); + copy.normalize_triggers(); + copy +} + /// id를 비운 값 사본. 비교마다 양쪽을 clone하지 않도록, 스캔 전에 한 번씩만 /// 만들어 재사용한다 (승계 웨이브의 순서·우선순위는 그대로 유지) fn value_without_id(element: &T) -> T { let mut copy = element.clone(); - copy.position_mut().id.clear(); + copy.id_mut().clear(); copy } @@ -198,14 +339,14 @@ fn keep_or_rekey_supplied_ids( continue; }; for element in elements { - let id = element.position().id.clone(); + let id = element.id().to_string(); if id.is_empty() { continue; } if canonical_ids.contains(&id) { consumed_current_ids.insert(id); } else { - element.position_mut().id = new_unique_id(reserved); + *element.id_mut() = new_unique_id(reserved); } } } @@ -227,7 +368,7 @@ fn inherit_ids_by_value_within_mode( }; let normalized_current: Vec = current_elements.iter().map(value_without_id).collect(); for element in elements { - if !element.position().id.is_empty() { + if !element.id().is_empty() { continue; } let target = value_without_id(&*element); @@ -235,14 +376,14 @@ fn inherit_ids_by_value_within_mode( .iter() .zip(&normalized_current) .find(|(current_element, normalized)| { - let current_id = ¤t_element.position().id; + let current_id = current_element.id(); !consumed_current_ids.contains(current_id) && **normalized == target }) .map(|(current_element, _)| current_element); if let Some(current_element) = inherited { - let id = current_element.position().id.clone(); + let id = current_element.id().to_string(); consumed_current_ids.insert(id.clone()); - element.position_mut().id = id; + *element.id_mut() = id; } } } @@ -260,7 +401,7 @@ fn inherit_ids_by_value( continue; }; for element in elements { - if !element.position().id.is_empty() { + if !element.id().is_empty() { continue; } let target = value_without_id(&*element); @@ -268,16 +409,16 @@ fn inherit_ids_by_value( .iter() .zip(&normalized_current) .find(|(current_element, normalized)| { - let current_id = ¤t_element.position().id; + let current_id = current_element.id(); !consumed_current_ids.contains(current_id) && **normalized == target }) .map(|(current_element, _)| current_element); if let Some(current_element) = inherited { - let id = current_element.position().id.clone(); + let id = current_element.id().to_string(); consumed_current_ids.insert(id.clone()); - element.position_mut().id = id; + *element.id_mut() = id; } else { - element.position_mut().id = new_unique_id(reserved); + *element.id_mut() = new_unique_id(reserved); } } } @@ -570,6 +711,21 @@ fn adapt_v1_patch_ids( &mut reserved, ); } + if let Some(collection) = patch.sprite_positions.as_mut() { + adapt_v1_collection( + &store.sprite_positions, + collection, + &canonical_ids, + &mut consumed_current_ids, + &mut reserved, + ); + adapt_v1_sprite_pose_ids( + &store.sprite_positions, + collection, + &mut consumed_current_ids, + &mut reserved, + ); + } Ok(()) } @@ -582,7 +738,7 @@ fn validate_document_collection_ids( continue; }; for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; + let id = element.id(); if id.is_empty() { return Err(EditorCommitError::validation( MISSING_ELEMENT_ID, @@ -595,7 +751,7 @@ fn validate_document_collection_ids( format!("native element {mode}[{index}] has an invalid ID"), )); } - if !seen.insert(id.clone()) { + if !seen.insert(id.to_string()) { return Err(EditorCommitError::validation( DUPLICATE_ELEMENT_ID, format!("native element ID {id} is not globally unique"), @@ -613,7 +769,9 @@ pub(crate) fn validate_document_element_ids( validate_document_collection_ids(&document.key_positions, &mut seen)?; validate_document_collection_ids(&document.stat_positions, &mut seen)?; validate_document_collection_ids(&document.graph_positions, &mut seen)?; - validate_document_collection_ids(&document.knob_positions, &mut seen) + validate_document_collection_ids(&document.knob_positions, &mut seen)?; + validate_document_collection_ids(&document.sprite_positions, &mut seen)?; + validate_sprite_pose_ids(&document.sprite_positions, true, &mut seen) } fn patch_includes_native_elements(patch: &EditorPatchV1) -> bool { @@ -621,6 +779,7 @@ fn patch_includes_native_elements(patch: &EditorPatchV1) -> bool { || patch.stat_positions.is_some() || patch.graph_positions.is_some() || patch.knob_positions.is_some() + || patch.sprite_positions.is_some() } pub(crate) fn prepare_commit_patch_element_ids( diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 1d37ce553..e312f3cdb 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -28,7 +28,7 @@ use tauri::Runtime; use super::assets::builtin_sounds::seed_builtin_sounds; use super::atomic_file::atomic_replace; use super::editor::{ - canonical_request_fingerprint, next_revision, repair_selected_mode, request_fingerprint, + gesture_request_fingerprint, next_revision, repair_selected_mode, request_fingerprint, request_payload_size, sync_key_counters, touched_pair, validate_document_transition, validate_document_transition_with_keying, validate_history_restore_metadata, validate_paired_update, validate_request_envelope, GrandfatherKeying, RequestFingerprint, @@ -46,8 +46,9 @@ use super::history::{ HISTORY_SCOPE_MISMATCH, HISTORY_TARGET_ALREADY_APPLIED, INVALID_HISTORY_OPERATION_ID, }; use super::migration::{ - find_legacy_store_file, load_store_from_path, migrate_key_images_to_app_data, - migrate_local_fonts_to_app_data, normalize_state, rehome_foreign_asset_references, + fill_missing_sprite_image_metrics, find_legacy_store_file, load_store_from_path, + migrate_key_images_to_app_data, migrate_local_fonts_to_app_data, normalize_state, + rehome_foreign_asset_references, }; use super::plugin::{ add_plugin_group_refs, decode_plugin_instance_entries, decode_plugin_instances_lenient, @@ -319,6 +320,9 @@ impl AppStore { if migrate_key_images_to_app_data(dir, &mut state) { needs_persist = true; } + if fill_missing_sprite_image_metrics(&mut state.sprite_positions) { + needs_persist = true; + } // 내장 키음 시딩 if seed_builtin_sounds(dir, &mut state) { needs_persist = true; diff --git a/src-tauri/src/state/store/asset_references.rs b/src-tauri/src/state/store/asset_references.rs index 3ffd22baa..02cb72e69 100644 --- a/src-tauri/src/state/store/asset_references.rs +++ b/src-tauri/src/state/store/asset_references.rs @@ -142,6 +142,12 @@ pub(super) fn collect_local_image_paths( paths.collect(app_data_dir, position.active_image.as_ref()); paths.collect(app_data_dir, position.inactive_image.as_ref()); } + for sprite in data.sprite_positions.values().flatten() { + paths.collect(app_data_dir, sprite.base_image.as_ref()); + for pose in &sprite.poses { + paths.collect(app_data_dir, pose.image_override.as_ref()); + } + } collect_plugin_managed_asset_paths(app_data_dir, data, "images", &mut paths); diff --git a/src-tauri/src/state/store/editor_commit.rs b/src-tauri/src/state/store/editor_commit.rs index b9adb0cbc..a6ff14ff5 100644 --- a/src-tauri/src/state/store/editor_commit.rs +++ b/src-tauri/src/state/store/editor_commit.rs @@ -194,6 +194,7 @@ impl AppStore { enforce_touched_fields: false, }; let change = if let Some(changes) = request.changes.as_mut() { + changes.merge_omitted_sprite_fields(&guard.data.sprite_positions); native_element_id::prepare_commit_patch_element_ids(&guard.data, changes)?; let touched_fields = changes.included_fields(); self.commit_editor_patch_locked( diff --git a/src-tauri/src/state/store/editor_transactions.rs b/src-tauri/src/state/store/editor_transactions.rs index eaf61561c..bede11ceb 100644 --- a/src-tauri/src/state/store/editor_transactions.rs +++ b/src-tauri/src/state/store/editor_transactions.rs @@ -52,6 +52,7 @@ pub(super) fn prepare_editor_patch_transition( candidate.apply_to_store(&mut scratch); crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); + crate::state::migration::normalize_sprite_triggers(&mut scratch); candidate = EditorDocumentV1::from_store(&scratch); validate_paired_update( diff --git a/src-tauri/src/state/store/gesture_commit.rs b/src-tauri/src/state/store/gesture_commit.rs index a2c78c582..44e0428e6 100644 --- a/src-tauri/src/state/store/gesture_commit.rs +++ b/src-tauri/src/state/store/gesture_commit.rs @@ -60,7 +60,7 @@ impl AppStore { admission: &HistoryAdmissionLease, runtime_counters: Option<&KeyCounters>, ) -> std::result::Result { - let fingerprint = canonical_request_fingerprint(&request)?; + let fingerprint = gesture_request_fingerprint(&request)?; let mut guard = self .lock_for_update() .map_err(|error| EditorCommitError::io(error.to_string()))?; @@ -98,6 +98,7 @@ impl AppStore { } if let Some(changes) = request.editor_changes.as_mut() { + changes.merge_omitted_sprite_fields(&guard.data.sprite_positions); native_element_id::prepare_commit_patch_element_ids(&guard.data, changes)?; } diff --git a/src-tauri/src/state/store/history_restore.rs b/src-tauri/src/state/store/history_restore.rs index 1c805a210..d31db5a71 100644 --- a/src-tauri/src/state/store/history_restore.rs +++ b/src-tauri/src/state/store/history_restore.rs @@ -482,6 +482,7 @@ impl AppStore { target.apply_override_patches(&mut scratch); crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); + crate::state::migration::normalize_sprite_triggers(&mut scratch); let candidate = EditorDocumentV1::from_store(&scratch); validate_paired_update(¤t, &candidate, true, true)?; scratch.editor_revision = current_store.editor_revision; @@ -586,6 +587,7 @@ impl AppStore { ); crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); + crate::state::migration::normalize_sprite_triggers(&mut scratch); let candidate = EditorDocumentV1::from_store(&scratch); validate_paired_update(¤t, &candidate, true, true)?; scratch.editor_revision = current_store.editor_revision; diff --git a/src-tauri/src/state/store/legacy_transactions.rs b/src-tauri/src/state/store/legacy_transactions.rs index 149535f9c..12cfb5f4c 100644 --- a/src-tauri/src/state/store/legacy_transactions.rs +++ b/src-tauri/src/state/store/legacy_transactions.rs @@ -491,6 +491,7 @@ impl AppStore { .unwrap_or_default(); crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); + crate::state::migration::normalize_sprite_triggers(&mut scratch); // editorRevision은 이 트랜잭션만 관리 scratch.editor_revision = current_store.editor_revision; diff --git a/src-tauri/src/state/store/tests.rs b/src-tauri/src/state/store/tests.rs index 358750ded..4c011df3b 100644 --- a/src-tauri/src/state/store/tests.rs +++ b/src-tauri/src/state/store/tests.rs @@ -23,9 +23,11 @@ use crate::{ GesturePluginInstancesChange, GraphPosition, GraphStatType, GraphType, JsPlugin, KeyCounters, KeyPosition, KeySlot, KnobPosition, LayerGroupDef, NoteColor, OverlayBounds, PanelBounds, PendingProcessedWavReplacement, PluginInstancesCommitRequest, - PluginInstancesReconcileRequest, PluginPoint, SavedPluginInstance, SettingsPatchInput, - SlotMatch, SoundLibraryEntry, SoundSource, StatPosition, StatType, TabCss, TabNoteSettings, - EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, + PluginInstancesReconcileRequest, PluginPoint, ReactiveSpritePosition, SavedPluginInstance, + SettingsPatchInput, SlotMatch, SoundLibraryEntry, SoundSource, SpriteActivation, + SpriteAnchor, SpriteImageMetrics, SpritePose, SpriteReferenceNaturalSize, SpriteTransform, + StatPosition, StatType, TabCss, TabNoteSettings, EDITOR_COMMIT_SCHEMA_VERSION_V2, + EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, }, services::{css_watcher::commit_css_reload, settings::apply_patch_to_store}, state::{ @@ -54,6 +56,72 @@ fn test_directory(label: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("dmnote-{label}-{}", uuid::Uuid::new_v4())) } +fn png_header(width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes +} + +#[test] +fn store_initialization_fills_missing_sprite_metrics_after_paths_resolve() { + let directory = test_directory("sprite-metrics-load"); + let images = directory.join("images"); + let base = images.join("base.png"); + let pose = images.join("pose.png"); + std::fs::create_dir_all(&images).unwrap(); + std::fs::write(&base, png_header(640, 360)).unwrap(); + std::fs::write(&pose, png_header(320, 240)).unwrap(); + let mut data = AppStoreData::default(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ + ReactiveSpritePosition { + base_image: Some(base.to_string_lossy().into_owned()), + poses: vec![SpritePose { + image_override: Some(pose.to_string_lossy().into_owned()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }, + ReactiveSpritePosition { + base_image: Some(images.join("missing.png").to_string_lossy().into_owned()), + ..ReactiveSpritePosition::default() + }, + ], + ); + std::fs::write( + directory.join("store.json"), + serde_json::to_vec(&data).unwrap(), + ) + .unwrap(); + + let store = AppStore::initialize_for_test(&directory).unwrap(); + let snapshot = store.snapshot(); + let sprite = &snapshot.sprite_positions["4key"][0]; + assert_eq!( + sprite + .reference_natural_size + .as_ref() + .map(|size| (size.width, size.height)), + Some((640, 360)) + ); + assert_eq!( + sprite.poses[0] + .image_override_metrics + .as_ref() + .map(|size| (size.width, size.height)), + Some((320, 240)) + ); + assert_eq!( + snapshot.sprite_positions["4key"][1].reference_natural_size, + None + ); + + store.flush_and_shutdown().unwrap(); + std::fs::remove_dir_all(directory).unwrap(); +} + fn regular_file_count(directory: &Path) -> usize { std::fs::read_dir(directory) .unwrap() @@ -286,6 +354,13 @@ fn set_bounds_op( } } +fn resize_sprite_op(id: impl Into, bounds: EditorBoundsV1) -> EditorOpV1 { + EditorOpV1::ResizeSprite { + id: id.into(), + bounds, + } +} + fn delete_element_op(element_type: EditorElementTypeV1, id: impl Into) -> EditorOpV1 { EditorOpV1::DeleteElement { element_type, @@ -595,6 +670,7 @@ fn editor_reset_mode_filters_loaded_and_unloaded_plugin_instances_once() { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode("4key".to_string()), @@ -5277,6 +5353,412 @@ fn editor_commit_history_skips_no_op_and_deduplicates_mutation() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn element_rotation_survives_legacy_updates_gesture_history_and_restart() { + fn native_positions_mut(document: &mut EditorDocumentV1) -> [&mut KeyPosition; 4] { + [ + &mut document.key_positions.get_mut("4key").unwrap()[0], + &mut document.stat_positions.get_mut("4key").unwrap()[0].position, + &mut document.graph_positions.get_mut("4key").unwrap()[0].position, + &mut document.knob_positions.get_mut("4key").unwrap()[0].position, + ] + } + + let directory = tempfile::tempdir().unwrap(); + let store = initialize_neutral_editor_store(directory.path()); + let new_position = |rotation| KeyPosition { + id: uuid::Uuid::new_v4().to_string(), + rotation, + ..KeyPosition::default() + }; + let mut key_positions = store.editor_get().document.key_positions; + key_positions.get_mut("4key").unwrap()[0].rotation = 45.0; + store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(key_positions), + stat_positions: Some(HashMap::from([( + "4key".to_string(), + vec![StatPosition { + stat_type: StatType::Kps, + position: new_position(-30.0), + }], + )])), + graph_positions: Some(HashMap::from([( + "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: new_position(180.0), + }], + )])), + knob_positions: Some(HashMap::from([( + "4key".to_string(), + vec![KnobPosition { + axis_id: "axis".to_string(), + sensitivity: 1.0, + reverse: false, + position: new_position(-180.0), + }], + )])), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let initial = store.editor_get().document; + let targets = [ + ( + EditorElementTypeV1::Key, + initial.key_positions["4key"][0].id.clone(), + ), + ( + EditorElementTypeV1::Stat, + initial.stat_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Graph, + initial.graph_positions["4key"][0].position.id.clone(), + ), + ( + EditorElementTypeV1::Knob, + initial.knob_positions["4key"][0].position.id.clone(), + ), + ]; + let group_targets = targets + .iter() + .map(|(element_type, id)| EditorElementGroupTargetV1 { + element_type: *element_type, + id: id.clone(), + }) + .collect::>(); + let group_id = "rotation-group"; + let mut grouped = initial.clone(); + for position in native_positions_mut(&mut grouped) { + position.group_id = Some(group_id.to_string()); + } + grouped.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: group_id.to_string(), + name: "Rotation Group".to_string(), + }], + ); + let change = store + .commit_editor_document(editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + vec![EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: group_targets.clone(), + target_group: Some(EditorTargetGroupV1::Create { + id: group_id.to_string(), + name: "Rotation Group".to_string(), + }), + }], + )) + .unwrap(); + assert_eq!(change.document, grouped); + + let gesture_id = uuid::Uuid::new_v4().to_string(); + let mut rotated = grouped.clone(); + for rotation in [-45.5, 90.0] { + let mut request = editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + targets + .iter() + .map(|(element_type, id)| { + patch_property_op( + *element_type, + id, + EditorElementPropertyPatchV1::Rotation(rotation), + ) + }) + .collect(), + ); + request.gesture_id = Some(gesture_id.clone()); + let change = store.commit_editor_document(request).unwrap(); + rotated.key_positions.get_mut("4key").unwrap()[0].rotation = rotation; + rotated.stat_positions.get_mut("4key").unwrap()[0] + .position + .rotation = rotation; + rotated.graph_positions.get_mut("4key").unwrap()[0] + .position + .rotation = rotation; + rotated.knob_positions.get_mut("4key").unwrap()[0] + .position + .rotation = rotation; + assert_eq!(change.document, rotated); + let patch = serde_json::to_value(&change.event.unwrap().patch).unwrap(); + for field in crate::models::POSITION_COLLECTION_FIELDS { + assert_eq!(patch[field]["4key"][0]["rotation"], rotation); + } + } + + let mut moved = rotated.clone(); + moved.key_positions.get_mut("4key").unwrap()[0].dx += 25.0; + moved.stat_positions.get_mut("4key").unwrap()[0].position.dy += 25.0; + moved.graph_positions.get_mut("4key").unwrap()[0] + .position + .width += 25.0; + moved.knob_positions.get_mut("4key").unwrap()[0] + .position + .height += 25.0; + let change = store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + key_positions: Some(moved.key_positions.clone()), + stat_positions: Some(moved.stat_positions.clone()), + graph_positions: Some(moved.graph_positions.clone()), + knob_positions: Some(moved.knob_positions.clone()), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + assert_eq!(change.document, moved); + + let mut resized = moved.clone(); + let resized_bounds = native_positions_mut(&mut resized) + .into_iter() + .map(|position| { + position.dx *= 1.5; + position.dy *= 2.0; + position.width *= 1.5; + position.height *= 2.0; + bounds(position) + }) + .collect::>(); + let resize_gesture_id = uuid::Uuid::new_v4().to_string(); + let mut request = editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + targets + .iter() + .zip(resized_bounds) + .map(|((element_type, id), bounds)| set_bounds_op(*element_type, id, bounds)) + .collect(), + ); + request.gesture_id = Some(resize_gesture_id.clone()); + let change = store.commit_editor_document(request).unwrap(); + assert_eq!(change.document, resized); + assert_eq!(change.event.unwrap().gesture_id, Some(resize_gesture_id)); + + let mut ungrouped = resized.clone(); + for position in native_positions_mut(&mut ungrouped) { + position.group_id = None; + } + ungrouped.layer_groups.get_mut("4key").unwrap().clear(); + let change = store + .commit_editor_document(editor_ops_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + vec![EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: group_targets, + target_group: None, + }], + )) + .unwrap(); + assert_eq!(change.document, ungrouped); + + for (direction, expected) in [ + (HistoryDirection::Undo, &resized), + (HistoryDirection::Undo, &moved), + (HistoryDirection::Undo, &rotated), + (HistoryDirection::Undo, &grouped), + (HistoryDirection::Undo, &initial), + (HistoryDirection::Redo, &grouped), + (HistoryDirection::Redo, &rotated), + (HistoryDirection::Redo, &moved), + (HistoryDirection::Redo, &resized), + (HistoryDirection::Redo, &ungrouped), + ] { + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + store + .apply_history_operation( + direction, + &operation_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!(&store.editor_get().document, expected); + } + + store.flush_and_shutdown().unwrap(); + let disk: AppStoreData = + serde_json::from_slice(&std::fs::read(directory.path().join("store.json")).unwrap()) + .unwrap(); + assert_eq!(EditorDocumentV1::from_store(&disk), ungrouped); + drop(store); + let reopened = AppStore::initialize_for_test(directory.path()).unwrap(); + assert_eq!(reopened.editor_get().document, ungrouped); + reopened.flush_and_shutdown().unwrap(); +} + +#[test] +fn sprite_layout_rotation_commits_atomically_with_keys_and_preserves_pose_history() { + let directory = tempfile::tempdir().unwrap(); + let store = initialize_neutral_editor_store(directory.path()); + let mut keys = store.editor_get().document.key_positions; + let key = &mut keys.get_mut("4key").unwrap()[0]; + key.rotation = 170.0; + let sprite = ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + dx: 100.0, + dy: -50.0, + rotation: 175.0, + idle_transform: SpriteTransform { + rotation: 170.0, + x: 25.0, + ..SpriteTransform::default() + }, + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![key.id.clone()], + pivot: Some(SpriteAnchor { x: 0.2, y: 0.8 }), + transform: SpriteTransform { + rotation: -170.0, + y: -35.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(keys), + sprite_positions: Some(HashMap::from([("4key".to_string(), vec![sprite.clone()])])), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + let initial = store.editor_get().document; + let gesture_id = uuid::Uuid::new_v4().to_string(); + let mut rotated = initial.clone(); + for (delta, key_rotation, sprite_rotation) in [(10.0, 180.0, -175.0), (20.0, -170.0, -165.0)] { + rotated = initial.clone(); + let key = &mut rotated.key_positions.get_mut("4key").unwrap()[0]; + key.dx += delta; + key.dy -= delta; + key.rotation = key_rotation; + let sprite = &mut rotated.sprite_positions.get_mut("4key").unwrap()[0]; + sprite.dx -= delta; + sprite.dy += delta; + sprite.rotation = sprite_rotation; + let mut request = editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(rotated.key_positions.clone()), + sprite_positions: Some(rotated.sprite_positions.clone()), + ..EditorPatchV1::default() + }, + ); + request.gesture_id = Some(gesture_id.clone()); + let change = store.commit_editor_document(request).unwrap(); + assert_eq!(change.document, rotated); + assert_eq!( + change.result.changed_fields, + vec![EditorField::KeyPositions, EditorField::SpritePositions] + ); + let event = change.event.unwrap(); + assert_eq!(event.gesture_id, Some(gesture_id.clone())); + assert_eq!( + event.patch.sprite_positions.unwrap()["4key"][0].rotation, + sprite_rotation + ); + } + assert_eq!( + rotated.sprite_positions["4key"][0].idle_transform, + sprite.idle_transform + ); + assert_eq!(rotated.sprite_positions["4key"][0].poses, sprite.poses); + + let revision = store.editor_get().revision; + let mut invalid = rotated.clone(); + invalid.key_positions.get_mut("4key").unwrap()[0].dx += 25.0; + invalid.sprite_positions.get_mut("4key").unwrap()[0].rotation = 181.0; + assert!(store + .commit_editor_document(editor_request( + revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + key_positions: Some(invalid.key_positions), + sprite_positions: Some(invalid.sprite_positions), + ..EditorPatchV1::default() + }, + )) + .is_err()); + assert_eq!(store.editor_get().revision, revision); + assert_eq!(store.editor_get().document, rotated); + + let mut legacy_updated = rotated.clone(); + legacy_updated.sprite_positions.get_mut("4key").unwrap()[0].class_name = + Some("legacy-style".to_string()); + let mut legacy = serde_json::to_value(EditorPatchV1 { + sprite_positions: Some(legacy_updated.sprite_positions.clone()), + ..EditorPatchV1::default() + }) + .unwrap(); + legacy["spritePositions"]["4key"][0] + .as_object_mut() + .unwrap() + .remove("rotation"); + let change = store + .commit_editor_document(editor_request( + revision, + uuid::Uuid::new_v4().to_string(), + serde_json::from_value(legacy).unwrap(), + )) + .unwrap(); + assert_eq!(change.document, legacy_updated); + + for (direction, expected) in [ + (HistoryDirection::Undo, &rotated), + (HistoryDirection::Undo, &initial), + (HistoryDirection::Redo, &rotated), + (HistoryDirection::Redo, &legacy_updated), + ] { + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + store + .apply_history_operation( + direction, + &operation_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!(&store.editor_get().document, expected); + } + store.flush_and_shutdown().unwrap(); + drop(store); + let reopened = AppStore::initialize_for_test(directory.path()).unwrap(); + assert_eq!(reopened.editor_get().document, legacy_updated); + reopened.flush_and_shutdown().unwrap(); +} + #[test] fn editor_ops_follow_stable_id_and_restore_the_latest_snapshot_on_undo() { let dir = test_directory("editor-ops-stable-id-history-test"); @@ -6118,6 +6600,324 @@ fn graph_and_knob_literal_batch_replays_and_round_trips_one_history_entry() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn reactive_sprite_editor_commit_patch_round_trips_without_field_loss() { + let dir = test_directory("reactive-sprite-editor-commit-round-trip-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let sprite = ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + base_image: Some("https://example.com/base.png".to_string()), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![ + "00000000-0000-4000-8000-000000000001".to_string(), + "00000000-0000-4000-8000-000000000002".to_string(), + ], + image_override: Some("https://example.com/pose.png".to_string()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let sprite_positions = + crate::models::SpritePositions::from([("4key".to_string(), vec![sprite.clone()])]); + + let committed = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + sprite_positions: Some(sprite_positions.clone()), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + assert_eq!( + committed.result.changed_fields, + [EditorField::SpritePositions] + ); + assert_eq!(committed.document.sprite_positions, sprite_positions); + assert_eq!( + store.editor_get().document.sprite_positions["4key"], + [sprite] + ); + + let mut duplicate_trigger_sets = committed.document.sprite_positions.clone(); + let mut duplicate_pose = duplicate_trigger_sets["4key"][0].poses[0].clone(); + duplicate_pose.pose_id = uuid::Uuid::new_v4().to_string(); + duplicate_pose.triggers.reverse(); + duplicate_trigger_sets.get_mut("4key").unwrap()[0] + .poses + .push(duplicate_pose); + let error = store + .commit_editor_document(editor_request( + committed.result.revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + sprite_positions: Some(duplicate_trigger_sets), + ..EditorPatchV1::default() + }, + )) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("DUPLICATE_SPRITE_POSE_TRIGGERS") + ); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn legacy_sprite_changes_preserve_omitted_canonical_fields_and_undo_full_snapshot() { + let dir = test_directory("legacy-sprite-presence-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = initialize_default_state(); + let sprite = ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + base_image: Some("/images/base.png".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some("/images/base.png".to_string()), + width: 800, + height: 600, + }), + activation: SpriteActivation::OnPress, + press_duration_ms: 900, + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + image_override: Some("/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/pose.png".to_string(), + width: 320, + height: 240, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + data.sprite_positions + .insert("4key".to_string(), vec![sprite]); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + store.persist_current().unwrap(); + let initial = store.editor_get().document.sprite_positions; + + let legacy_request = |transition_easing: &str| { + let mut sprites = initial.clone(); + sprites.get_mut("4key").unwrap()[0].transition_easing = transition_easing.to_string(); + let mut changes = serde_json::to_value(EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + sprite_positions: Some(sprites), + ..EditorPatchV1::default() + }) + .unwrap(); + let sprite = changes + .pointer_mut("/spritePositions/4key/0") + .and_then(Value::as_object_mut) + .unwrap(); + for field in [ + "baseImage", + "referenceNaturalSize", + "activation", + "pressDurationMs", + ] { + sprite.remove(field); + } + let pose = sprite["poses"][0].as_object_mut().unwrap(); + pose.remove("imageOverride"); + pose.remove("imageOverrideMetrics"); + crate::state::editor::decode_editor_commit_request(json!({ + "baseRevision": 0, + "mutationId": uuid::Uuid::new_v4().to_string(), + "changes": changes + })) + .unwrap() + }; + + let no_op = store + .commit_editor_document(legacy_request(&initial["4key"][0].transition_easing)) + .unwrap(); + assert!(no_op.result.changed_fields.is_empty()); + assert_eq!(no_op.result.revision, 0); + assert_eq!(store.history_status().history_revision, 0); + + let committed = store + .commit_editor_document(legacy_request("linear")) + .unwrap(); + assert_eq!( + committed.result.changed_fields, + [EditorField::SpritePositions] + ); + let saved = &store.editor_get().document.sprite_positions["4key"][0]; + let expected = &initial["4key"][0]; + assert_eq!(saved.base_image, expected.base_image); + assert_eq!( + saved.reference_natural_size, + expected.reference_natural_size + ); + assert_eq!(saved.activation, expected.activation); + assert_eq!(saved.press_duration_ms, expected.press_duration_ms); + assert_eq!( + saved.poses[0].image_override, + expected.poses[0].image_override + ); + assert_eq!( + saved.poses[0].image_override_metrics, + expected.poses[0].image_override_metrics + ); + assert_eq!(saved.transition_easing, "linear"); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + &store.snapshot().key_counters, + || {}, + ) + .unwrap(); + drop(barrier); + assert_eq!(store.editor_get().document.sprite_positions, initial); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn user_sprite_pose_rotation_v1_patch_commits_with_missing_image_files() { + let dir = test_directory("user-sprite-pose-rotation-v1-test"); + std::fs::create_dir_all(&dir).unwrap(); + let first_trigger = "0bf18ec9-7af0-4f03-a009-aebc99e70167"; + let second_trigger = "9c2d07a6-e6d5-4ecc-84be-ca67d2d483d0"; + let mut data = initialize_default_state(); + let key_positions = data.key_positions.get_mut("4key").unwrap(); + key_positions[0].id = first_trigger.to_string(); + key_positions[1].id = second_trigger.to_string(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: "495749a9-0a13-4204-9d47-28fd91109c7f".to_string(), + dx: 600.5, + dy: 59.0, + width: 90.0, + height: 75.0, + base_image: Some("/missing/images/base.png".to_string()), + reference_natural_size: Some(SpriteReferenceNaturalSize { + source: Some("/missing/images/base.png".to_string()), + width: 240, + height: 210, + }), + pivot: crate::models::SpriteAnchor { x: 1.0, y: 0.0 }, + poses: vec![ + SpritePose { + pose_id: "5120c78c-76c0-4447-b866-35d9769e9ae8".to_string(), + triggers: vec![first_trigger.to_string()], + transform: SpriteTransform { + x: 1.41, + y: 0.24, + rotation: -33.6, + scale: 1.0, + }, + image_override: Some("/missing/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/missing/images/pose.png".to_string(), + width: 240, + height: 210, + }), + ..SpritePose::default() + }, + SpritePose { + pose_id: "2cc7ec94-6f85-4a07-9ae9-0d28eae8289a".to_string(), + triggers: vec![second_trigger.to_string()], + transform: SpriteTransform { + x: 4.9, + ..SpriteTransform::default() + }, + image_override: Some("/missing/images/pose.png".to_string()), + image_override_metrics: Some(SpriteImageMetrics { + source: "/missing/images/pose.png".to_string(), + width: 240, + height: 210, + }), + ..SpritePose::default() + }, + ], + transition_ms: 90, + ..ReactiveSpritePosition::default() + }], + ); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + let mut sprite_positions = store.editor_get().document.sprite_positions; + sprite_positions.get_mut("4key").unwrap()[0].poses[0] + .transform + .rotation = 20.0; + let request = crate::state::editor::decode_editor_commit_request(json!({ + "baseRevision": 0, + "mutationId": uuid::Uuid::new_v4().to_string(), + "changes": { + "schemaVersion": 1, + "spritePositions": sprite_positions, + } + })) + .unwrap(); + + let committed = store.commit_editor_document(request).unwrap(); + assert_eq!( + committed.document.sprite_positions["4key"][0].poses[0] + .transform + .rotation, + 20.0 + ); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn sprite_commit_normalizes_metrics_without_an_override() { + let dir = test_directory("sprite-pivot-normalization-commit-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let sprite = ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + image_override: None, + image_override_metrics: Some(SpriteImageMetrics { + source: "/images/removed.png".to_string(), + width: 320, + height: 240, + }), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + + store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + sprite_positions: Some(HashMap::from([("4key".to_string(), vec![sprite])])), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + let pose = &store.editor_get().document.sprite_positions["4key"][0].poses[0]; + assert_eq!(pose.image_override_metrics, None); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn common_option_batches_replay_and_round_trip_all_native_types() { let dir = test_directory("editor-inline-style-history-test"); @@ -12222,6 +13022,131 @@ fn editor_ops_apply_all_element_types_in_request_order() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn resize_sprite_and_set_bounds_batch_undoes_as_one_history_entry() { + let dir = test_directory("resize-sprite-mixed-batch-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = initialize_default_state(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + dx: 10.0, + dy: 20.0, + width: 200.0, + height: 100.0, + idle_transform: SpriteTransform { + x: 12.0, + y: -6.0, + ..SpriteTransform::default() + }, + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + transform: SpriteTransform { + x: -30.0, + y: 44.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + crate::state::native_element_id::backfill_store_element_ids(&mut data); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + store.persist_current().unwrap(); + let initial = store.editor_get(); + let key = initial.document.key_positions["4key"][0].clone(); + let sprite = initial.document.sprite_positions["4key"][0].clone(); + let key_bounds = EditorBoundsV1 { + dx: key.dx + 50.0, + dy: key.dy + 25.0, + width: key.width, + height: key.height, + }; + let sprite_bounds = EditorBoundsV1 { + dx: 5.0, + dy: 8.0, + width: 400.0, + height: 50.0, + }; + + let change = store + .commit_editor_document(editor_ops_request( + initial.revision, + uuid::Uuid::new_v4().to_string(), + vec![ + resize_sprite_op(&sprite.id, sprite_bounds), + set_bounds_op(EditorElementTypeV1::Key, &key.id, key_bounds), + ], + )) + .unwrap(); + + assert_eq!(change.result.revision, initial.revision + 1); + assert_eq!( + change.result.changed_fields, + [EditorField::KeyPositions, EditorField::SpritePositions] + ); + assert_eq!( + change + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| (result.status, result.bounds)) + .collect::>(), + vec![ + (EditorOpResultStatusV1::Applied, Some(sprite_bounds)), + (EditorOpResultStatusV1::Applied, Some(key_bounds)), + ] + ); + assert_eq!(store.history_status().history_revision, 1); + let committed = store.editor_get().document; + assert_eq!(bounds(&committed.key_positions["4key"][0]), key_bounds); + assert_eq!( + ( + committed.sprite_positions["4key"][0].dx, + committed.sprite_positions["4key"][0].dy, + committed.sprite_positions["4key"][0].width, + committed.sprite_positions["4key"][0].height, + ), + ( + sprite_bounds.dx, + sprite_bounds.dy, + sprite_bounds.width, + sprite_bounds.height, + ) + ); + assert_eq!( + ( + committed.sprite_positions["4key"][0].idle_transform.x, + committed.sprite_positions["4key"][0].idle_transform.y, + committed.sprite_positions["4key"][0].poses[0].transform.x, + committed.sprite_positions["4key"][0].poses[0].transform.y, + ), + (24.0, -3.0, -60.0, 22.0) + ); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + + let restored = store.editor_get().document; + assert_eq!(restored.key_positions["4key"][0], key); + assert_eq!(restored.sprite_positions["4key"][0], sprite); + assert!(!undo.status.can_undo); + assert!(undo.status.can_redo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn editor_ops_reject_type_mismatch_and_invalid_missing_bounds_atomically() { let dir = test_directory("editor-ops-atomic-validation-test"); @@ -16597,6 +17522,62 @@ fn asset_references_include_every_position_kind() { } } +#[test] +fn reactive_sprite_images_survive_explicit_orphan_sweep() { + let dir = test_directory("reactive-sprite-image-sweep-test"); + let images = dir.join("images"); + std::fs::create_dir_all(&images).unwrap(); + let base_image = images.join("sprite-base.png"); + let first_pose_image = images.join("sprite-pose-first.png"); + let second_pose_image = images.join("sprite-pose-second.png"); + let orphan_image = images.join("orphan.png"); + for path in [ + &base_image, + &first_pose_image, + &second_pose_image, + &orphan_image, + ] { + std::fs::write(path, b"image").unwrap(); + } + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + legacy_editor_commit(&store, &[EditorField::SpritePositions], |data| { + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + base_image: Some(base_image.to_string_lossy().into_owned()), + poses: vec![ + SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + image_override: Some(first_pose_image.to_string_lossy().into_owned()), + ..SpritePose::default() + }, + SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + image_override: Some(second_pose_image.to_string_lossy().into_owned()), + ..SpritePose::default() + }, + ], + ..ReactiveSpritePosition::default() + }], + ); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(base_image.exists()); + assert!(first_pose_image.exists()); + assert!(second_pose_image.exists()); + assert!(!orphan_image.exists()); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn plugin_managed_assets_remain_until_plugin_data_releases_them() { let dir = test_directory("plugin-managed-assets-sweep-test"); @@ -17701,12 +18682,16 @@ fn repaired_asset_references_survive_the_next_normal_session_sweep() { let stat_sound = dir.join("sounds").join("stat.wav"); let graph_sound = dir.join("sounds").join("graph.wav"); let knob_sound = dir.join("sounds").join("knob.wav"); + let sprite_base_image = dir.join("images").join("sprite-base.png"); + let sprite_pose_image = dir.join("images").join("sprite-pose.png"); for path in [ &font_path, &library_sound, &stat_sound, &graph_sound, &knob_sound, + &sprite_base_image, + &sprite_pose_image, ] { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, b"asset-fixture").unwrap(); @@ -17760,6 +18745,18 @@ fn repaired_asset_references_survive_the_next_normal_session_sweep() { }) .unwrap(), ); + let sprite = serde_json::to_value(ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + base_image: Some(sprite_base_image.to_string_lossy().into_owned()), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + image_override: Some(sprite_pose_image.to_string_lossy().into_owned()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }) + .unwrap(); let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); let fields = fixture.as_object_mut().unwrap(); @@ -17774,6 +18771,10 @@ fn repaired_asset_references_survive_the_next_normal_session_sweep() { json!({ "asset-mode": [graph] }), ); fields.insert("knobPositions".to_string(), json!({ "asset-mode": [knob] })); + fields.insert( + "spritePositions".to_string(), + json!({ "asset-mode": [sprite, { "width": "invalid" }] }), + ); std::fs::write( dir.join("store.json"), serde_json::to_vec_pretty(&fixture).unwrap(), @@ -17790,6 +18791,7 @@ fn repaired_asset_references_survive_the_next_normal_session_sweep() { assert_eq!(snapshot.stat_positions["asset-mode"].len(), 1); assert_eq!(snapshot.graph_positions["asset-mode"].len(), 1); assert_eq!(snapshot.knob_positions["asset-mode"].len(), 1); + assert_eq!(snapshot.sprite_positions["asset-mode"].len(), 1); recovered.cleanup_orphan_assets_now().unwrap(); recovered.flush_and_shutdown().unwrap(); drop(recovered); @@ -17803,6 +18805,8 @@ fn repaired_asset_references_survive_the_next_normal_session_sweep() { &stat_sound, &graph_sound, &knob_sound, + &sprite_base_image, + &sprite_pose_image, ] { assert!(path.exists(), "recovered asset was quarantined: {path:?}"); } diff --git a/src-tauri/src/state/window/mod.rs b/src-tauri/src/state/window/mod.rs index 53f52c6c8..8f6691257 100644 --- a/src-tauri/src/state/window/mod.rs +++ b/src-tauri/src/state/window/mod.rs @@ -7,3 +7,6 @@ pub(crate) mod macos_window_corners; pub(crate) mod panel_drag; #[cfg(target_os = "windows")] pub(crate) mod windows_window_corners; + +#[cfg(unix)] +pub(crate) mod unix_termination; diff --git a/src-tauri/src/state/window/unix_termination.rs b/src-tauri/src/state/window/unix_termination.rs new file mode 100644 index 000000000..6d65b2206 --- /dev/null +++ b/src-tauri/src/state/window/unix_termination.rs @@ -0,0 +1,15 @@ +use std::io; + +use tokio::signal::unix::{signal, SignalKind}; + +pub(crate) fn install(request_shutdown: impl Fn() + Send + 'static) -> io::Result<()> { + let mut termination = + tauri::async_runtime::block_on(async { signal(SignalKind::terminate()) })?; + tauri::async_runtime::spawn(async move { + while termination.recv().await.is_some() { + log::info!("[Shutdown] SIGTERM received; requesting frontend editor flush"); + request_shutdown(); + } + }); + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6f11f7c4a..0e903bc71 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,6 +16,7 @@ "label": "main", "create": false, "title": "DM Note - Settings", + "windowClassname": "DmNoteMainWindow", "width": 902, "height": 488, "resizable": false, diff --git a/src/renderer/__tests__/editor/editorDocumentSelection.test.ts b/src/renderer/__tests__/editor/editorDocumentSelection.test.ts index 038a36052..e9f64e0f1 100644 --- a/src/renderer/__tests__/editor/editorDocumentSelection.test.ts +++ b/src/renderer/__tests__/editor/editorDocumentSelection.test.ts @@ -34,6 +34,7 @@ const makeDocument = (): CanonicalEditorDocumentV1 => ({ statPositions: { '4key': [] }, graphPositions: { '4key': [] }, knobPositions: { '4key': [] }, + spritePositions: { '4key': [] }, layerGroups: { '4key': [] }, }); diff --git a/src/renderer/__tests__/editor/keyDeletionSelection.test.tsx b/src/renderer/__tests__/editor/keyDeletionSelection.test.tsx index 8d21969ff..cd3a0df2e 100644 --- a/src/renderer/__tests__/editor/keyDeletionSelection.test.tsx +++ b/src/renderer/__tests__/editor/keyDeletionSelection.test.tsx @@ -150,4 +150,17 @@ describe('키 삭제 선택 정리', () => { expect(apiMocks.rebindKeySlotById).not.toHaveBeenCalled(); expect(apiMocks.update).not.toHaveBeenCalled(); }); + + it('저장 대기 중 대상 키가 사라지면 무시된 편집을 기록한다', async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + apiMocks.rebindKeySlotById.mockResolvedValueOnce(false); + act(() => root.render()); + await act(async () => latest().handleKeyMappingChange(1, 'Z')); + + expect(warning).toHaveBeenCalledWith( + 'Element operation skipped (fail-closed)', + 'key mapping target disappeared', + ); + warning.mockRestore(); + }); }); diff --git a/src/renderer/__tests__/editor/previewOverlaySession.test.ts b/src/renderer/__tests__/editor/previewOverlaySession.test.ts index 05cf57835..2a08ff51c 100644 --- a/src/renderer/__tests__/editor/previewOverlaySession.test.ts +++ b/src/renderer/__tests__/editor/previewOverlaySession.test.ts @@ -327,6 +327,38 @@ describe('editGestureController', () => { ); }); + it('새 게스처는 남은 로컬 프리뷰를 교체하고 브로커에도 종료를 알린다', () => { + const staleSessionId = '11111111-1111-4111-8111-111111111111'; + previewOverlay.applyLocalPatch(staleSessionId, '4key', [0], { + backgroundColor: '#111111', + }); + + editGestureController.preview('4key', [ + { id: KEY_ID_A, patch: { backgroundColor: '#abcdef' } }, + ]); + + expect(previewApi.cancel).toHaveBeenCalledWith(staleSessionId); + expect(useKeyStore.getState().positions['4key'][0].backgroundColor).toBe( + '#abcdef', + ); + }); + + it('활성 게스처가 없어도 cancel은 남은 로컬 프리뷰를 회수한다', () => { + const staleSessionId = '11111111-1111-4111-8111-111111111111'; + const canonicalColor = + useKeyStore.getState().positions['4key'][0].backgroundColor; + previewOverlay.applyLocalPatch(staleSessionId, '4key', [0], { + backgroundColor: '#111111', + }); + + editGestureController.cancel(); + + expect(previewApi.cancel).toHaveBeenCalledWith(staleSessionId); + expect(useKeyStore.getState().positions['4key'][0].backgroundColor).toBe( + canonicalColor, + ); + }); + it('preview는 coalescing 후 채널로 발행됨', async () => { editGestureController.preview('4key', [ { id: KEY_ID_A, patch: { backgroundColor: '#111111' } }, @@ -450,6 +482,29 @@ describe('editGestureController', () => { expect(useKeyStore.getState().positions['4key'][0].width).toBe(100); }); + it('새 조작이 교체한 정산 대기 세션은 늦게 실패해도 되살아나지 않는다', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + let rejectFirst!: (reason: Error) => void; + const firstCommit = new Promise((_resolve, reject) => { + rejectFirst = reject; + }); + + editGestureController.preview('4key', [ + { id: KEY_ID_A, patch: { width: 100 } }, + ]); + editGestureController.settleCommit(firstCommit); + + editGestureController.preview('4key', [ + { id: KEY_ID_A, patch: { width: 120 } }, + ]); + editGestureController.cancel(); + rejectFirst(new Error('late io')); + await flushPromises(); + + expect(editGestureController.hasActiveGesture()).toBe(false); + expect(useKeyStore.getState().positions['4key'][0].width).toBe(60); + }); + it('settleCommit 실패 뒤 편집 대상이 갈렸으면 세션을 되살리지 않는다', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); useGridSelectionStore.setState({ diff --git a/src/renderer/__tests__/panel/batchStyleActiveAggregation.test.tsx b/src/renderer/__tests__/panel/batchStyleActiveAggregation.test.tsx index 493291d34..822370be1 100644 --- a/src/renderer/__tests__/panel/batchStyleActiveAggregation.test.tsx +++ b/src/renderer/__tests__/panel/batchStyleActiveAggregation.test.tsx @@ -171,6 +171,7 @@ describe('혼합 선택 active 그림자 집계', () => { hideFontControls shadowActiveState getMixedValue={mixedGetter([statPosition, keyPosition])} + getMixedValueSize={mixedGetter([statPosition, keyPosition])} getKeyOnlyMixedValue={mixedGetter([keyPosition])} getSelectedKeysData={() => []} handleBatchAlign={vi.fn()} @@ -226,6 +227,7 @@ describe('혼합 선택 active 그림자 집계', () => { showShadowControls={false} shadowActiveState={false} getMixedValue={mixedGetter([graphPosition])} + getMixedValueSize={mixedGetter([graphPosition])} getSelectedKeysData={() => []} handleBatchAlign={vi.fn()} handleBatchDistribute={vi.fn()} @@ -281,6 +283,7 @@ describe('혼합 선택 active 그림자 집계', () => { hideFontControls showShadowControls={false} getMixedValue={mixedGetter([keyPosition, keyPosition])} + getMixedValueSize={mixedGetter([keyPosition, keyPosition])} getSelectedKeysData={() => []} handleBatchAlign={vi.fn()} handleBatchDistribute={vi.fn()} @@ -367,6 +370,7 @@ describe('그래프 전용 배치 패널 배선', () => { handleBatchStyleChangeComplete={vi.fn()} handleGraphBatchSharedSetting={vi.fn()} getMixedValueGraphs={mixedGraphs} + getMixedValueGeometry={mixedGraphs} getMixedValueGraphsAsKey={mixedGraphs} getSelectedGraphsData={() => []} batchScrollRefFor={() => () => {}} diff --git a/src/renderer/__tests__/panel/batchStyleEditCancel.test.tsx b/src/renderer/__tests__/panel/batchStyleEditCancel.test.tsx index b7501a0bd..919bc3dde 100644 --- a/src/renderer/__tests__/panel/batchStyleEditCancel.test.tsx +++ b/src/renderer/__tests__/panel/batchStyleEditCancel.test.tsx @@ -21,6 +21,8 @@ interface CapturedNumberInput { min?: number; max?: number; prefix?: string; + value?: number; + isMixed?: boolean; onChange?: (value: number) => void; onPreview?: (value: number) => void; onBlur?: () => void; @@ -98,7 +100,9 @@ describe('배치 스타일 숫자 필드 취소', () => { let handleBatchResize: Mock; let handleBatchResizePreview: Mock; - const renderPanel = () => { + const renderPanel = ( + overrides: Partial> = {}, + ) => { const positions = [flatPosition(), flatPosition(), flatPosition()]; act(() => { root.render( @@ -115,6 +119,7 @@ describe('배치 스타일 숫자 필드 취소', () => { selectedCount={3} shadowActiveState getMixedValue={mixedGetter(positions)} + getMixedValueSize={mixedGetter(positions)} getKeyOnlyMixedValue={mixedGetter(positions)} getSelectedKeysData={() => []} handleBatchAlign={handleBatchAlign} @@ -129,6 +134,7 @@ describe('배치 스타일 숫자 필드 취소', () => { panelElement={null} useCustomCSS={false} t={(key) => key} + {...overrides} /> , ); @@ -155,6 +161,32 @@ describe('배치 스타일 숫자 필드 취소', () => { vi.restoreAllMocks(); }); + // 크기 조절 대상에는 스프라이트가 들어가지만 스타일 대상에는 없다. + // 크기 입력이 스타일 getter로 되돌아가면 스프라이트 값이 대표값에 가린다 + it('크기 입력은 스타일 getter가 아니라 크기 getter를 읽는다', () => { + renderPanel({ + getMixedValue: ((_getter: unknown, _fallback: T) => ({ + isMixed: false, + value: 60 as unknown as T, + })) as React.ComponentProps['getMixedValue'], + getMixedValueSize: ((_getter: unknown, _fallback: T) => ({ + isMixed: true, + value: 200 as unknown as T, + })) as React.ComponentProps< + typeof BatchStyleTabContent + >['getMixedValueSize'], + }); + + for (const prefix of ['W', 'H']) { + const field = captured.numberInputs.find( + (props) => props.prefix === prefix, + ); + expect(field).toBeTruthy(); + expect(field?.isMixed).toBe(true); + expect(field?.value).toBe(200); + } + }); + it('간격 취소는 예약만 되고 아직 안 나간 커밋을 걷는다', () => { renderPanel(); const spacing = fieldByMax(500); diff --git a/src/renderer/__tests__/rendering/gradientAxisHandle.test.tsx b/src/renderer/__tests__/rendering/gradientAxisHandle.test.tsx index 999a0a313..f4a238091 100644 --- a/src/renderer/__tests__/rendering/gradientAxisHandle.test.tsx +++ b/src/renderer/__tests__/rendering/gradientAxisHandle.test.tsx @@ -128,6 +128,65 @@ describe('GradientAxisOverlay 드래그 로직', () => { const stopDot = (n: number) => host.querySelector(`[aria-label="stop ${n}"]`) as HTMLElement; + it('회전 요소의 축·스톱은 페인트 방향을 따르고 드래그는 로컬 각도로 저장한다', () => { + act(() => { + root.render( + , + ); + }); + expect(parseFloat(axisAnchor('end').style.left)).toBeCloseTo(200); + expect(parseFloat(axisAnchor('end').style.top)).toBeCloseTo(250); + expect(parseFloat(stopDot(2).style.left)).toBeCloseTo(200); + expect(parseFloat(stopDot(2).style.top)).toBeCloseTo(234); + expect(strip().style.transform).toBe('translate(-50%, -50%) rotate(90deg)'); + act(() => { + axisAnchor('end').dispatchEvent( + pointerEvent('pointerdown', { + pointerId: 40, + clientX: 200, + clientY: 250, + }), + ); + window.dispatchEvent( + pointerEvent('pointermove', { + pointerId: 40, + clientX: 100, + clientY: 150, + }), + ); + window.dispatchEvent( + pointerEvent('pointerup', { + pointerId: 40, + clientX: 100, + clientY: 150, + }), + ); + }); + expect(apply.mock.lastCall).toEqual([{ ...SPEC, angle: 180 }, true]); + }); + it('세션이 있으면 축 슬라이더·끝 앵커·스톱 점을 렌더한다', () => { expect(strip()).toBeTruthy(); expect(axisAnchor('start')).toBeTruthy(); @@ -218,7 +277,9 @@ describe('GradientAxisOverlay 드래그 로직', () => { }); expect(useGradientEditStore.getState().session).toBeNull(); - expect(host.querySelector('[data-dmn-gradient-overlay="true"]')).toBeNull(); + expect( + host.querySelector('[data-dmn-canvas-editor-overlay="true"]'), + ).toBeNull(); }); it('축을 잡고 window에서 움직이면 각도 프리뷰·커밋이 적용된다', () => { diff --git a/src/renderer/__tests__/rendering/renderedElementContract.test.tsx b/src/renderer/__tests__/rendering/renderedElementContract.test.tsx index 0160ad16e..0283ebfa7 100644 --- a/src/renderer/__tests__/rendering/renderedElementContract.test.tsx +++ b/src/renderer/__tests__/rendering/renderedElementContract.test.tsx @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Key } from '@components/shared/key/Key'; import StatItem from '@components/overlay/counters/StatItem'; import OverlayKnobItem from '@components/overlay/counters/OverlayKnobItem'; +import OverlaySpriteItem from '@components/overlay/counters/OverlaySpriteItem'; import OutsideCounter from '@components/overlay/counters/OutsideCounter'; import GraphPanel from '@components/shared/graph/GraphPanel'; import { resetAllKeySignals, setKeyActive } from '@stores/signals/keySignals'; @@ -401,6 +402,62 @@ describe('렌더 DOM 계약', () => { }); }); + describe('오버레이 SpriteItem', () => { + it('래퍼가 히트 마커를, 안쪽 표면이 data 속성을 싣는다', () => { + act(() => { + root.render( + , + ); + }); + const el = host.querySelector( + '[data-sprite-element="true"]', + ); + expect(el).not.toBeNull(); + // 클래스는 위치 래퍼, 표식은 안쪽 표면 - 노브·카운터와 같은 배치라 + // 사용자 CSS의 `.클래스 [data-sprite-element]`가 성립한다 + const wrapper = host.querySelector( + '[data-overlay-hit="true"]', + ); + expect(wrapper).not.toBeNull(); + expect(wrapper!.contains(el)).toBe(true); + expect(wrapper).not.toBe(el); + expect(el!.getAttribute('data-sprite-state')).toBe('inactive'); + }); + }); + describe('오버레이 외부 카운터', () => { it('실시간 숫자 노드는 별도 히트 영역을 만들지 않는다', () => { host.innerHTML = renderToStaticMarkup( diff --git a/src/renderer/api/modules/app/appApi.test.ts b/src/renderer/api/modules/app/appApi.test.ts index 6b6661d73..1b6d8189b 100644 --- a/src/renderer/api/modules/app/appApi.test.ts +++ b/src/renderer/api/modules/app/appApi.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { invoke, coordinatorFlush, drainEditorWrites } = vi.hoisted(() => ({ invoke: vi.fn(), @@ -14,7 +14,7 @@ vi.mock( }), ); vi.mock('@src/renderer/editor/runtime/lifecycle/editorWriteBarrier', () => ({ - drainEditorWrites, + beginEditorWriteBarrier: () => drainEditorWrites, })); import { @@ -41,6 +41,9 @@ const bootstrapPayload = () => ({ }); describe('runAfterEditorFlush', () => { + beforeEach(() => { + drainEditorWrites.mockResolvedValue(true); + }); afterEach(() => { vi.restoreAllMocks(); invoke.mockReset(); diff --git a/src/renderer/api/modules/app/appApi.ts b/src/renderer/api/modules/app/appApi.ts index 2ecdf36f6..aa217473e 100644 --- a/src/renderer/api/modules/app/appApi.ts +++ b/src/renderer/api/modules/app/appApi.ts @@ -7,6 +7,7 @@ import type { } from '@src/types/plugin/api'; import { subscribe } from '../shared'; import { assertCanonicalEditorDocument } from '@src/types/editor'; +import { beginEditorWriteBarrier } from '@src/renderer/editor/runtime/lifecycle/editorWriteBarrier'; import type { BootstrapPayload, @@ -30,8 +31,17 @@ export async function runAfterEditorFlush( load: EditorCoordinatorLoader = loadEditorCoordinator, ): Promise { try { - const { editorCoordinator } = await load(); - await editorCoordinator.flush(); + const drainWrites = beginEditorWriteBarrier(); + let writesCommitted: boolean; + try { + const { editorCoordinator } = await load(); + await editorCoordinator.flush(); + } finally { + writesCommitted = await drainWrites(); + } + if (!writesCommitted) { + throw new Error('pending window writes failed to drain'); + } } catch (error) { console.error(`[Editor] ${action} canceled because saving failed`, error); throw error; @@ -40,15 +50,9 @@ export async function runAfterEditorFlush( } export const acknowledgeLifecycleAfterEditorFlush = (handshakeId: string) => - runAfterEditorFlush('app lifecycle', async () => { - const editorBarrier = await import( - '@src/renderer/editor/runtime/lifecycle/editorWriteBarrier' - ); - if (!(await editorBarrier.drainEditorWrites())) { - throw new Error('pending window writes failed to drain'); - } - return invoke('app_quit_after_editor_flush', { handshakeId }); - }); + runAfterEditorFlush('app lifecycle', () => + invoke('app_quit_after_editor_flush', { handshakeId }), + ); export const cancelLifecycleEditorFlush = (handshakeId: string) => invoke('app_cancel_editor_flush', { handshakeId }); @@ -56,6 +60,8 @@ export const cancelLifecycleEditorFlush = (handshakeId: string) => export const appApi = { bootstrap: async (): Promise => { const payload = await invoke('app_bootstrap'); + // 구버전 payload 호환: 스프라이트 미포함이면 빈 컬렉션 + payload.spritePositions ??= {}; assertCanonicalEditorDocument( { schemaVersion: 1, @@ -64,6 +70,7 @@ export const appApi = { statPositions: payload.statPositions, graphPositions: payload.graphPositions, knobPositions: payload.knobPositions, + spritePositions: payload.spritePositions, layerGroups: payload.layerGroups, }, 'app_bootstrap editor document', diff --git a/src/renderer/api/modules/app/settingsApi.lifecycle.test.tsx b/src/renderer/api/modules/app/settingsApi.lifecycle.test.tsx new file mode 100644 index 000000000..edeb3996f --- /dev/null +++ b/src/renderer/api/modules/app/settingsApi.lifecycle.test.tsx @@ -0,0 +1,165 @@ +// @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 Checkbox from '@components/main/common/checkbox/Checkbox'; +import { flushFocusedEditor } from '@src/renderer/editor/runtime/lifecycle/lifecycleEditorFlush'; +import { settingsApi } from './settingsApi'; +import { acknowledgeLifecycleAfterEditorFlush } from './appApi'; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn(), flush: vi.fn() })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +vi.mock( + '@src/renderer/editor/runtime/coordinator/editorStateCoordinator', + () => ({ + editorCoordinator: { flush: mocks.flush }, + }), +); + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; + +const nextTurn = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('설정 저장과 lifecycle 응답', () => { + let root: Root; + let iframe: HTMLIFrameElement; + let host: HTMLDivElement; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + mocks.invoke.mockReset().mockResolvedValue(undefined); + mocks.flush.mockReset().mockResolvedValue(undefined); + vi.spyOn(console, 'error').mockImplementation(() => {}); + iframe = document.createElement('iframe'); + document.body.append(iframe); + const child = iframe.contentWindow!; + child.requestAnimationFrame = () => 1; + child.cancelAnimationFrame = () => {}; + host = child.document.createElement('div'); + child.document.body.append(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + iframe.remove(); + vi.restoreAllMocks(); + }); + + const arrangeWrite = () => { + const write = deferred(); + mocks.invoke.mockImplementation((command) => + command === 'settings_update' ? write.promise : Promise.resolve(), + ); + return write; + }; + + it.each(['success', 'failure'] as const)( + '프레임이 멈춘 토글의 저장 %s를 정산한 뒤에만 ack를 결정한다', + async (outcome) => { + const write = arrangeWrite(); + act(() => + root.render( + { + void settingsApi.update({ noteEffect: true }).catch(() => {}); + }} + />, + ), + ); + act(() => host.querySelector('[role="switch"]')!.click()); + expect(mocks.invoke).not.toHaveBeenCalled(); + let completed = false; + let settlement!: Promise; + await act(async () => { + settlement = (async () => { + const committed = await flushFocusedEditor(); + if (committed) + await acknowledgeLifecycleAfterEditorFlush('close-test'); + completed = true; + return committed; + })(); + await nextTurn(); + await nextTurn(); + }); + expect(mocks.invoke).toHaveBeenCalledWith('settings_update', { + patch: { noteEffect: true }, + }); + const completedBeforeWrite = completed; + await act(async () => { + if (outcome === 'success') write.resolve({ noteEffect: true }); + else write.reject(new Error('settings rejected')); + await settlement; + }); + expect(completedBeforeWrite).toBe(false); + await expect(settlement).resolves.toBe(outcome === 'success'); + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === 'app_quit_after_editor_flush', + ), + ).toHaveLength(outcome === 'success' ? 1 : 0); + }, + ); + + it('정산 직전 시작한 설정 저장의 빠른 실패를 놓치지 않는다', async () => { + const write = arrangeWrite(); + const saving = settingsApi.update({ noteEffect: true }).catch(() => {}); + const settlement = flushFocusedEditor(); + write.reject(new Error('already pending settings failed')); + + await saving; + await expect(settlement).resolves.toBe(false); + }); + + it('coordinator flush 중 끝난 설정 실패가 뒤늦은 ack로 바뀌지 않는다', async () => { + const coordinatorFlush = deferred(); + mocks.flush.mockReturnValue(coordinatorFlush.promise); + const write = arrangeWrite(); + const ack = acknowledgeLifecycleAfterEditorFlush('late-failure'); + const acknowledged = ack.then( + () => true, + () => false, + ); + await vi.waitFor(() => expect(mocks.flush).toHaveBeenCalledOnce()); + const saving = settingsApi.update({ noteEffect: true }).catch(() => {}); + write.reject(new Error('failed during coordinator flush')); + await saving; + coordinatorFlush.resolve(); + + await expect(acknowledged).resolves.toBe(false); + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === 'app_quit_after_editor_flush', + ), + ).toHaveLength(0); + }); + + it('ack 진입 전에 시작된 저장도 coordinator를 기다리는 동안 추적한다', async () => { + const write = arrangeWrite(); + const saving = settingsApi.update({ noteEffect: true }).catch(() => {}); + const ack = acknowledgeLifecycleAfterEditorFlush('already-saving'); + const acknowledged = ack.then( + () => true, + () => false, + ); + write.reject(new Error('in-flight settings failed')); + + await saving; + await expect(acknowledged).resolves.toBe(false); + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === 'app_quit_after_editor_flush', + ), + ).toHaveLength(0); + }); +}); diff --git a/src/renderer/api/modules/app/settingsApi.ts b/src/renderer/api/modules/app/settingsApi.ts index 0ef412f1a..3f4fcb9a5 100644 --- a/src/renderer/api/modules/app/settingsApi.ts +++ b/src/renderer/api/modules/app/settingsApi.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { subscribe } from '../shared'; +import { trackEditorWrite } from '@src/renderer/editor/runtime/lifecycle/editorWriteBarrier'; import type { SettingsState, @@ -10,7 +11,7 @@ import type { export const settingsApi = { get: () => invoke('settings_get'), update: (patch: SettingsPatchInput) => - invoke('settings_update', { patch }), + trackEditorWrite(invoke('settings_update', { patch })), onChanged: (listener: (diff: SettingsDiff) => void) => subscribe('settings:changed', listener), }; diff --git a/src/renderer/api/modules/editor/editorAdapters.test.ts b/src/renderer/api/modules/editor/editorAdapters.test.ts index c4eeb4324..665d6731b 100644 --- a/src/renderer/api/modules/editor/editorAdapters.test.ts +++ b/src/renderer/api/modules/editor/editorAdapters.test.ts @@ -1,14 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; -const { commitPatch } = vi.hoisted(() => ({ +const { commitPatch, commitGeneratedPatch } = vi.hoisted(() => ({ commitPatch: vi.fn(), + commitGeneratedPatch: vi.fn(), })); vi.mock( '@src/renderer/editor/runtime/coordinator/editorStateCoordinator', () => ({ - editorCoordinator: { commitPatch }, + editorCoordinator: { commitPatch, commitGeneratedPatch }, }), ); @@ -17,10 +18,16 @@ import { graphItemsApi, knobItemsApi, layerGroupsApi, + spriteItemsApi, statItemsApi, } from './itemsApi'; -import type { EditorDocumentV1 } from '@src/types/editor'; +import type { + CanonicalEditorDocumentV1, + CanonicalReactiveSpritePosition, + EditorDocumentV1, + EditorPatchV1, +} from '@src/types/editor'; import type { KeyPositions } from '@src/types/key/keys'; const document: EditorDocumentV1 = { @@ -30,13 +37,39 @@ const document: EditorDocumentV1 = { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: { '4key': [{ id: 'group-1', name: 'Group 1' }] }, }; +// 백엔드 실물 wire 형태: layerName·groupId는 None이면 직렬화에서 생략된다 +const spriteFixture = (id: string): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, + id, + dx: 0, + dy: 0, + width: 200, + height: 150, + rotation: 0, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + referenceNaturalSize: null, +}); + describe('editor API compatibility adapters', () => { beforeEach(() => { commitPatch.mockReset(); commitPatch.mockResolvedValue(structuredClone(document)); + commitGeneratedPatch.mockReset(); + commitGeneratedPatch.mockResolvedValue(structuredClone(document)); }); it('routes key writers through editor_commit and preserves return shapes', async () => { @@ -96,6 +129,154 @@ describe('editor API compatibility adapters', () => { ]); }); + it('sprite patchPosition은 슬롯 안 최신 base에서 대상만 패치해 생성한다', async () => { + const spriteA = spriteFixture('cccccccc-cccc-4ccc-8ccc-cccccccccccc'); + const spriteB = spriteFixture('dddddddd-dddd-4ddd-8ddd-dddddddddddd'); + const base = { + ...structuredClone(document), + spritePositions: { '4key': [spriteA, spriteB] }, + } as CanonicalEditorDocumentV1; + const generated: Array = []; + commitGeneratedPatch.mockImplementation( + async (generate: (base: CanonicalEditorDocumentV1) => unknown) => { + generated.push(generate(base) as EditorPatchV1 | null); + return structuredClone(document); + }, + ); + + // patch가 명시 null layerName·groupId를 실어 와도 wire 형태로 정규화 + await expect( + spriteItemsApi.patchPosition('4key', spriteA.id, { + baseImage: 'hand.png', + layerName: null, + groupId: null, + }), + ).resolves.toBe('committed'); + + expect(commitGeneratedPatch).toHaveBeenCalledWith( + expect.any(Function), + undefined, + ); + // 최신 base 기준으로 대상 필드만 갱신, 같은 모드 이웃은 그대로 + expect(generated[0]).toEqual({ + schemaVersion: 1, + spritePositions: { + '4key': [{ ...spriteA, baseImage: 'hand.png' }, spriteB], + }, + }); + const patched = (generated[0] as EditorPatchV1).spritePositions?.[ + '4key' + ]?.[0] as CanonicalReactiveSpritePosition; + expect('layerName' in patched).toBe(false); + expect('groupId' in patched).toBe(false); + }); + + it('sprite patchPosition은 대상 소실이면 무커밋 targetMissing이다', async () => { + const emptyBase = structuredClone(document) as CanonicalEditorDocumentV1; + const generated: Array = []; + commitGeneratedPatch.mockImplementation( + async (generate: (base: CanonicalEditorDocumentV1) => unknown) => { + generated.push(generate(emptyBase) as EditorPatchV1 | null); + return structuredClone(document); + }, + ); + + await expect( + spriteItemsApi.patchPosition( + '4key', + 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + { baseImage: 'hand.png' }, + ), + ).resolves.toBe('targetMissing'); + expect(generated[0]).toBeNull(); + }); + + it('sprite patchPosition은 대기 뒤 최신 대상에서 의도 patch를 다시 계산한다', async () => { + const sprite = { + ...spriteFixture('cccccccc-cccc-4ccc-8ccc-cccccccccccc'), + poses: [ + { + poseId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + triggers: ['ffffffff-ffff-4fff-8fff-ffffffffffff'], + transform: { x: 42, y: 7, rotation: 0, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, + pivot: { x: 0.4, y: 0.6 }, + }, + ], + }; + const base = { + ...structuredClone(document), + spritePositions: { '4key': [sprite] }, + } as CanonicalEditorDocumentV1; + let generated: EditorPatchV1 | null = null; + commitGeneratedPatch.mockImplementation( + async (generate: (base: CanonicalEditorDocumentV1) => unknown) => { + generated = generate(base) as EditorPatchV1 | null; + return structuredClone(document); + }, + ); + + const stalePoses = structuredClone(sprite.poses); + stalePoses[0].transform.x = -100; + await spriteItemsApi.patchPosition( + '4key', + sprite.id, + { poses: stalePoses }, + undefined, + (current) => ({ + pivot: { x: 0.25, y: 0.75 }, + poses: current.poses, + }), + ); + + const patched = generated?.spritePositions?.['4key']?.[0]; + expect(patched?.pivot).toEqual({ x: 0.25, y: 0.75 }); + expect(patched?.poses[0].transform.x).toBe(42); + }); + + it('sprite patchPosition은 최신 상태에서 의도를 적용할 수 없으면 건너뛴다', async () => { + const sprite = spriteFixture('cccccccc-cccc-4ccc-8ccc-cccccccccccc'); + const base = { + ...structuredClone(document), + spritePositions: { '4key': [sprite] }, + } as CanonicalEditorDocumentV1; + let generated: EditorPatchV1 | null = null; + commitGeneratedPatch.mockImplementation( + async (generate: (base: CanonicalEditorDocumentV1) => unknown) => { + generated = generate(base) as EditorPatchV1 | null; + return structuredClone(document); + }, + ); + + await expect( + spriteItemsApi.patchPosition( + '4key', + sprite.id, + { hidden: true }, + undefined, + () => null, + ), + ).resolves.toBe('skipped'); + expect(generated).toBeNull(); + }); + + it('sprite patchPosition은 gestureId를 editor_commit 메타로 실어 보낸다', async () => { + // 수동 mock은 generate를 실행하지 않아 typed 결과는 targetMissing 유지 + await expect( + spriteItemsApi.patchPosition( + '4key', + 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + { hidden: true }, + '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50', + ), + ).resolves.toBe('targetMissing'); + + expect(commitGeneratedPatch).toHaveBeenCalledWith(expect.any(Function), { + gestureId: '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50', + }); + }); + it('preserves each legacy caller return value when writes are queued together', async () => { const first = { '4key': ['B'] }; const second = { '4key': ['C'] }; diff --git a/src/renderer/api/modules/editor/editorApi.test.ts b/src/renderer/api/modules/editor/editorApi.test.ts index 301570fa5..6378c454b 100644 --- a/src/renderer/api/modules/editor/editorApi.test.ts +++ b/src/renderer/api/modules/editor/editorApi.test.ts @@ -8,9 +8,12 @@ import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; import { EDITOR_OPS_VERSION, EditorProtocolError, + assertCanonicalEditorDocument, assertEditorCommittedEvent, assertEditorOpsV1, + assertEditorPatch, } from '@src/types/editor'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; import { editorApi, editorCommitRaw } from './editorApi'; import type { EditorCommittedV1, EditorDocumentV1 } from '@src/types/editor'; @@ -29,6 +32,7 @@ const canonicalDocument = (): EditorDocumentV1 => ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }); @@ -1331,3 +1335,189 @@ describe('요소 ID 검증이 백엔드와 같은 기준을 쓴다', () => { ).toThrow(/zUpdates\[0\] target is invalid/); }); }); + +// pose id는 요소 id와 같은 전역 네임스페이스를 쓴다 - 백엔드 +// native_element_id의 단일 seen 집합 검증을 canonical 경계가 미러한다 +describe('canonical 검증의 스프라이트 poseId 네임스페이스', () => { + const SPRITE_ID = '00000000-0000-4000-8000-0000000000f0'; + const POSE_ID = '00000000-0000-4000-8000-0000000000f1'; + + const sprite = ( + overrides: Partial = {}, + ): ReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, + id: SPRITE_ID, + dx: 0, + dy: 0, + width: 200, + height: 200, + rotation: 0, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [ + { + imageOverrideMetrics: null, + poseId: POSE_ID, + triggers: [], + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + referenceNaturalSize: null, + ...overrides, + }); + + const documentWith = ( + sprites: ReactiveSpritePosition[], + ): EditorDocumentV1 => ({ + ...canonicalDocument(), + spritePositions: { '4key': sprites }, + }); + + it('유효 UUID poseId는 표기 변형까지 통과한다', () => { + for (const poseId of [ + POSE_ID, + '000000000000400080000000000000f1', + '{00000000-0000-4000-8000-0000000000f1}', + 'urn:uuid:00000000-0000-4000-8000-0000000000f1', + ]) { + const document = documentWith([sprite()]); + document.spritePositions['4key'][0].poses[0].poseId = poseId; + expect(() => assertCanonicalEditorDocument(document)).not.toThrow(); + } + }); + + it.each([ + ['missing', undefined], + ['invalid', 'pose-0'], + ['nil', '00000000-0000-0000-0000-000000000000'], + ])('%s poseId를 거절한다', (_label, poseId) => { + const document = documentWith([sprite()]); + Object.assign(document.spritePositions['4key'][0].poses[0], { poseId }); + expect(() => assertCanonicalEditorDocument(document)).toThrow( + EditorProtocolError, + ); + }); + + it('poseId가 요소 id와 충돌하면 거절한다', () => { + const document = documentWith([sprite()]); + document.spritePositions['4key'][0].poses[0].poseId = + document.keyPositions['4key'][0].id!; + expect(() => assertCanonicalEditorDocument(document)).toThrow( + /not globally unique/, + ); + }); + + it('같은 스프라이트 안의 poseId 중복을 거절한다', () => { + const document = documentWith([sprite()]); + const [pose] = document.spritePositions['4key'][0].poses; + document.spritePositions['4key'][0].poses = [pose, { ...pose }]; + expect(() => assertCanonicalEditorDocument(document)).toThrow( + /not globally unique/, + ); + }); + + it('다른 모드의 스프라이트 간 poseId 중복도 거절한다', () => { + const document = documentWith([sprite()]); + document.spritePositions['5key'] = [ + sprite({ id: '00000000-0000-4000-8000-0000000000f2' }), + ]; + expect(() => assertCanonicalEditorDocument(document)).toThrow( + /not globally unique/, + ); + }); + + it('input 방향 patch는 poseId 생략을 허용하고 canonical 방향은 거절한다', () => { + const withoutPoseId = sprite(); + delete (withoutPoseId.poses[0] as { poseId?: string }).poseId; + const patch = { + schemaVersion: 1, + spritePositions: { '4key': [withoutPoseId] }, + }; + expect(() => + assertEditorPatch(patch, 'plugin patch', 'input'), + ).not.toThrow(); + expect(() => assertEditorPatch(patch)).toThrow(EditorProtocolError); + }); + + it('committed patch의 스프라이트는 id·poseId 발급 완료 상태만 통과한다', () => { + const committed = ( + positions: ReactiveSpritePosition[], + ): EditorCommittedV1 => ({ + schemaVersion: 1, + revision: 1, + mutationId: 'committed-sprite-check', + changedFields: ['spritePositions'], + patch: { + schemaVersion: 1, + spritePositions: { '4key': positions }, + }, + }); + expect(() => + assertEditorCommittedEvent(committed([sprite()])), + ).not.toThrow(); + + const invalidPose = sprite(); + invalidPose.poses[0].poseId = 'pose-0'; + expect(() => assertEditorCommittedEvent(committed([invalidPose]))).toThrow( + /poseId is invalid/, + ); + + const collided = sprite(); + collided.poses[0].poseId = SPRITE_ID; + expect(() => assertEditorCommittedEvent(committed([collided]))).toThrow( + /poseId is invalid/, + ); + }); + + it('frozen 스프라이트는 poseId 형식·스냅샷 내 중복을 거절한다', () => { + const frozenOp = (positions: ReactiveSpritePosition[]) => [ + { + kind: 'insertFrozenElements' as const, + mode: '4key', + elements: positions.map((position) => ({ + elementType: 'sprite' as const, + position, + })), + groups: [], + zUpdates: [], + }, + ]; + expect(() => assertEditorOpsV1(frozenOp([sprite()]))).not.toThrow(); + + const invalidPose = sprite(); + invalidPose.poses[0].poseId = 'pose-0'; + expect(() => assertEditorOpsV1(frozenOp([invalidPose]))).toThrow( + /poseId is invalid/, + ); + + const duplicated = sprite(); + const [pose] = duplicated.poses; + duplicated.poses = [pose, { ...pose }]; + expect(() => assertEditorOpsV1(frozenOp([duplicated]))).toThrow( + /invalid or duplicate ID/, + ); + + // poseId는 요소 id와 같은 전역 네임스페이스 - 자기 스프라이트 id, + // 다른 frozen 스프라이트의 poseId, 다른 frozen 요소 id와도 충돌 금지 + const selfCollision = sprite(); + selfCollision.poses[0].poseId = SPRITE_ID; + expect(() => assertEditorOpsV1(frozenOp([selfCollision]))).toThrow( + /invalid or duplicate ID/, + ); + + const crossA = sprite(); + const crossB = sprite({ id: '00000000-0000-4000-8000-0000000000f3' }); + expect(() => assertEditorOpsV1(frozenOp([crossA, crossB]))).toThrow( + /invalid or duplicate ID/, + ); + }); +}); diff --git a/src/renderer/api/modules/editor/editorApi.ts b/src/renderer/api/modules/editor/editorApi.ts index 87355f304..388ab155c 100644 --- a/src/renderer/api/modules/editor/editorApi.ts +++ b/src/renderer/api/modules/editor/editorApi.ts @@ -17,12 +17,14 @@ import type { EditorCommittedV1, CanonicalEditorGetResult, EditorGetResult, + PluginEditorCommitRequest, } from '@src/types/editor'; // envelope 무가공 전송. 플러그인 게이트웨이 전용 - multiKey 기본값을 주입하지 // 않아 플러그인이 선언한 값만 백엔드 게이트에 도달한다 (계약 §10) export const editorCommitRaw = async ( - request: EditorCommitRequest, + // 플러그인 요청은 스프라이트가 input 타입(poseId 생략 허용)이라 별도 멤버 + request: EditorCommitRequest | PluginEditorCommitRequest, ): Promise => { if (window.__dmn_runtime === 'obs') throw new EditorReadOnlyError(); assertSafeEditorRevision(request.baseRevision, 'baseRevision'); @@ -42,7 +44,9 @@ export const editorApi = { return result as CanonicalEditorGetResult; }, // 자사 표면: 멀티 키 지원을 항상 선언 (명시값이 있으면 그 값 우선, 계약 §10) - commit: (request: EditorCommitRequest): Promise => + commit: ( + request: EditorCommitRequest | PluginEditorCommitRequest, + ): Promise => editorCommitRaw({ multiKey: true, ...request }), onCommitted: (listener: (event: EditorCommittedV1) => void) => subscribe('editor:committed', (event) => { diff --git a/src/renderer/api/modules/editor/invokeEditorWrite.ts b/src/renderer/api/modules/editor/invokeEditorWrite.ts new file mode 100644 index 000000000..6efd441a0 --- /dev/null +++ b/src/renderer/api/modules/editor/invokeEditorWrite.ts @@ -0,0 +1,7 @@ +import { invoke } from '@tauri-apps/api/core'; +import { trackEditorWrite } from '@src/renderer/editor/runtime/lifecycle/editorWriteBarrier'; + +// 영속 변경 명령 전용, 프론트 정산을 기다리는 종료·history 요청에는 사용하지 않음 +export const invokeEditorWrite = ( + ...args: Parameters +): Promise => trackEditorWrite(invoke(...args)); diff --git a/src/renderer/api/modules/editor/itemsApi.ts b/src/renderer/api/modules/editor/itemsApi.ts index afb12867f..f969dbec6 100644 --- a/src/renderer/api/modules/editor/itemsApi.ts +++ b/src/renderer/api/modules/editor/itemsApi.ts @@ -6,8 +6,16 @@ import { editorCoordinator } from '@src/renderer/editor/runtime/coordinator/edit import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; +import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; import type { LayerGroups } from '@src/types/layerGroups'; +// 커밋 성사 여부를 호출자가 판별하는 typed 결과. targetMissing은 무커밋 +type SpritePatchCommitResult = 'committed' | 'targetMissing' | 'skipped'; +type SpritePatchGenerator = ( + current: ReactiveSpritePosition, +) => Partial | null; + export const statItemsApi = { getPositions: () => invoke('stat_positions_get'), updatePositions: (positions: StatItemPositions) => @@ -53,6 +61,50 @@ export const knobItemsApi = { subscribe('knobPositions:changed', listener), }; +export const spriteItemsApi = { + // 스프라이트 전용 필드는 ops patchElement가 거부하므로 전체 필드 패치로 커밋. + // 호출 시점 캡처 전체 레코드는 동결 소유권 검사에 걸려 낙관 적용이 빠지므로 + // 직렬 슬롯 안 최신 base에서 대상 스프라이트만 패치해 생성한다. 대상 소실이면 + // 무커밋 targetMissing. gestureId는 숫자 스크럽 preview 게스처 정산용 + patchPosition: ( + mode: string, + id: string, + patch: Partial, + gestureId?: string, + generatePatch?: SpritePatchGenerator, + ): Promise => { + let outcome: SpritePatchCommitResult = 'targetMissing'; + return enqueueEditorCompatibilityWrite( + () => + editorCoordinator.commitGeneratedPatch( + (base) => { + const modePositions = base.spritePositions[mode] ?? []; + const index = modePositions.findIndex((sprite) => sprite.id === id); + if (index < 0) return null; + outcome = 'skipped'; + const current = modePositions[index]; + const latestPatch = generatePatch ? generatePatch(current) : patch; + if (!latestPatch) return null; + outcome = 'committed'; + const nextMode = [...modePositions]; + // patch가 명시 null layerName·groupId를 실어 와도 wire 형태 유지 + nextMode[index] = toSpriteWireShape({ + ...current, + ...latestPatch, + id, + }); + return { + schemaVersion: 1, + spritePositions: { ...base.spritePositions, [mode]: nextMode }, + }; + }, + gestureId ? { gestureId } : undefined, + ), + () => outcome, + ); + }, +}; + export const layerGroupsApi = { get: () => invoke('layer_groups_get'), update: (groups: LayerGroups) => diff --git a/src/renderer/api/modules/editor/keyModeApi.ts b/src/renderer/api/modules/editor/keyModeApi.ts index b400557dc..6b855c00e 100644 --- a/src/renderer/api/modules/editor/keyModeApi.ts +++ b/src/renderer/api/modules/editor/keyModeApi.ts @@ -1,6 +1,6 @@ -import { invoke } from '@tauri-apps/api/core'; +import { invokeEditorWrite } from './invokeEditorWrite'; import type { KeysModeResponse } from '@src/types/plugin/api'; export const setKeyMode = (mode: string) => - invoke('keys_set_mode', { mode }); + invokeEditorWrite('keys_set_mode', { mode }); diff --git a/src/renderer/api/modules/editor/keysApi.ts b/src/renderer/api/modules/editor/keysApi.ts index 9ab3a1a78..c0c76718b 100644 --- a/src/renderer/api/modules/editor/keysApi.ts +++ b/src/renderer/api/modules/editor/keysApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from '../shared'; import { rawKeyEventBus } from '@utils/input/rawKeyEventBus'; @@ -109,12 +110,12 @@ export const keysApi = { invoke('keys_reset_mode', { mode }), ), setCounters: (counters: KeyCounters) => - invoke('keys_set_counters', { counters }), - resetCounters: () => invoke('keys_reset_counters'), + invokeEditorWrite('keys_set_counters', { counters }), + resetCounters: () => invokeEditorWrite('keys_reset_counters'), resetCountersMode: (mode: string) => - invoke('keys_reset_counters_mode', { mode }), + invokeEditorWrite('keys_reset_counters_mode', { mode }), resetSingleCounter: (mode: string, key: string) => - invoke('keys_reset_single_counter', { mode, key }), + invokeEditorWrite('keys_reset_single_counter', { mode, key }), onChanged: (listener: (keys: KeyMappings) => void) => subscribe('keys:changed', listener), onPositionsChanged: (listener: (positions: KeyPositions) => void) => @@ -172,9 +173,9 @@ export const keysApi = { invoke('custom_tabs_rename', { id, name }), ), select: (id: string) => - invoke('custom_tabs_select', { id }), + invokeEditorWrite('custom_tabs_select', { id }), restore: (customTabs: CustomTab[], selectedKeyType: string) => - invoke('custom_tabs_restore', { + invokeEditorWrite('custom_tabs_restore', { customTabs, selectedKeyType, }), diff --git a/src/renderer/api/modules/editor/noteTabApi.ts b/src/renderer/api/modules/editor/noteTabApi.ts index f9dd2dcbe..e78c6b582 100644 --- a/src/renderer/api/modules/editor/noteTabApi.ts +++ b/src/renderer/api/modules/editor/noteTabApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from '../shared'; @@ -14,12 +15,15 @@ export const noteTabApi = { tabId: string, settings: import('@src/types/settings/noteSettings').TabNoteSettings | null, ) => - invoke('note_tab_set', { - tabId, - settings, - }), + invokeEditorWrite( + 'note_tab_set', + { + tabId, + settings, + }, + ), clear: (tabId: string) => - invoke( + invokeEditorWrite( 'note_tab_clear', { tabId, diff --git a/src/renderer/api/modules/editor/persistentWrites.lifecycle.test.ts b/src/renderer/api/modules/editor/persistentWrites.lifecycle.test.ts new file mode 100644 index 000000000..9a87960ef --- /dev/null +++ b/src/renderer/api/modules/editor/persistentWrites.lifecycle.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beginEditorWriteBarrier } from '@src/renderer/editor/runtime/lifecycle/editorWriteBarrier'; +import { overlayApi } from '../window/overlayApi'; +import { cssApi } from '../resources/cssApi'; +import { jsApi } from '../plugin/jsApi'; +import { pluginApi } from '../plugin/pluginApi'; +import { noteTabApi } from './noteTabApi'; +import { keysApi } from './keysApi'; +import { setKeyMode } from './keyModeApi'; +import { + counterAnimationApi, + keySoundOutputApi, + soundApi, +} from '../resources/resourceApi'; +import { obsApi } from '../window/obsApi'; +import { historyApi } from './historyApi'; +import { acknowledgeLifecycleAfterEditorFlush } from '../app/appApi'; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +vi.mock('../shared', () => ({ subscribe: vi.fn() })); +vi.mock( + '@src/renderer/editor/runtime/coordinator/editorStateCoordinator', + () => ({ + editorCoordinator: { flush: vi.fn(async () => {}) }, + }), +); + +const writes: [string, () => Promise][] = [ + ['overlay_set_visible', () => overlayApi.setVisible(true)], + ['overlay_set_lock', () => overlayApi.setLock(true)], + ['overlay_set_anchor', () => overlayApi.setAnchor('top-left')], + ['overlay_resize', () => overlayApi.resize({ width: 400, height: 300 })], + ['overlay_reset_position', () => overlayApi.resetPosition()], + ['css_toggle', () => cssApi.toggle(true)], + ['css_set_content', () => cssApi.setContent('body {}')], + ['css_reset', () => cssApi.reset()], + ['css_history_activate', () => cssApi.historyActivate('saved.css')], + ['css_history_remove', () => cssApi.historyRemove('saved.css')], + ['css_tab_clear', () => cssApi.tab.clear('4key')], + ['css_tab_toggle', () => cssApi.tab.toggle('4key', true)], + [ + 'css_tab_activate_history', + () => cssApi.tab.activateHistory('4key', 'saved.css'), + ], + ['css_tab_set', () => cssApi.tab.set('4key', null)], + ['js_toggle', () => jsApi.toggle(true)], + ['js_reload', () => jsApi.reload()], + ['js_remove_plugin', () => jsApi.remove('test-plugin')], + ['js_set_plugin_enabled', () => jsApi.setPluginEnabled('test-plugin', true)], + ['js_set_content', () => jsApi.setContent('')], + ['js_reset', () => jsApi.reset()], + ['plugin_storage_set', () => pluginApi.storage.set('test/key', { value: 1 })], + ['plugin_storage_remove', () => pluginApi.storage.remove('test/key')], + ['plugin_storage_clear', () => pluginApi.storage.clear()], + [ + 'plugin_storage_clear_by_prefix', + () => pluginApi.storage.clearByPrefix('test/'), + ], + ['note_tab_set', () => noteTabApi.set('4key', null)], + ['note_tab_clear', () => noteTabApi.clear('4key')], + ['keys_set_mode', () => setKeyMode('4key')], + ['keys_set_counters', () => keysApi.setCounters({})], + ['keys_reset_counters', () => keysApi.resetCounters()], + ['keys_reset_counters_mode', () => keysApi.resetCountersMode('4key')], + ['keys_reset_single_counter', () => keysApi.resetSingleCounter('4key', 'A')], + ['custom_tabs_select', () => keysApi.customTabs.select('tab-id')], + ['custom_tabs_restore', () => keysApi.customTabs.restore([], '4key')], + ['sound_rename', () => soundApi.rename('sound.wav', 'Renamed')], + ['sound_set_hidden', () => soundApi.setHidden('sound.wav', true)], + ['sound_set_enabled', () => soundApi.setEnabled('sound.wav', false)], + [ + 'sound_save_processed_wav', + () => soundApi.saveProcessedWav('wav-data', 'trimmed.wav'), + ], + [ + 'sound_update_processed_wav', + () => soundApi.updateProcessedWav('sound.wav', 'wav-data'), + ], + [ + 'key_sound_set_output_backend', + () => keySoundOutputApi.setBackend({ kind: 'defaultDevice' }), + ], + [ + 'counter_animation_create', + () => + counterAnimationApi.create({ + name: 'Saved', + bezier: [0, 0, 1, 1], + scale: 1, + durationMs: 100, + }), + ], + ['obs_start', () => obsApi.start()], + ['obs_regenerate_token', () => obsApi.regenerateToken()], +]; + +const deferred = () => { + let resolve!: (value: unknown) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; + +describe('영속 쓰기 API의 종료 정산', () => { + beforeEach(() => { + mocks.invoke.mockReset(); + }); + + it.each(writes)( + '%s 응답 전 정산을 끝내지 않고 실패를 호출자와 barrier에 전달한다', + async (command, write) => { + const pending = deferred(); + mocks.invoke.mockReturnValue(pending.promise); + const drain = beginEditorWriteBarrier(); + const saving = write(); + const rejected = saving.catch((error: unknown) => error); + let settled = false; + const draining = drain().then((result) => { + settled = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const settledBeforeResponse = settled; + const error = new Error('disk write rejected'); + pending.reject(error); + expect(await rejected).toBe(error); + expect(await draining).toBe(false); + expect(settledBeforeResponse).toBe(false); + expect(mocks.invoke.mock.calls.map(([name]) => name)).toEqual([command]); + }, + ); + + it('성공한 쓰기의 반환값을 유지하고 종료 확인 응답보다 먼저 끝낸다', async () => { + const pending = deferred(); + mocks.invoke.mockImplementation((command) => + command === 'plugin_storage_set' ? pending.promise : Promise.resolve(), + ); + const saved = pluginApi.storage.set('plugin/key', 1); + const ack = acknowledgeLifecycleAfterEditorFlush('write-test'); + await new Promise((resolve) => setTimeout(resolve, 0)); + const commandsBeforeSaved = mocks.invoke.mock.calls.map(([name]) => name); + pending.resolve(undefined); + await expect(saved).resolves.toBeUndefined(); + await ack; + expect(commandsBeforeSaved).toEqual(['plugin_storage_set']); + expect(mocks.invoke.mock.calls.map(([name]) => name)).toEqual([ + 'plugin_storage_set', + 'app_quit_after_editor_flush', + ]); + }); + + it.each([ + ['settings read', () => overlayApi.get()], + ['plugin read', () => pluginApi.storage.get('test/key')], + ['history request', () => historyApi.undo('undo-id')], + ] as const)('%s는 저장 정산이 기다리지 않는다', async (_name, operation) => { + const pending = deferred(); + mocks.invoke.mockReturnValue(pending.promise); + const drain = beginEditorWriteBarrier(); + const response = operation(); + const result = await Promise.race([ + drain(), + new Promise((resolve) => setTimeout(() => resolve('blocked'), 50)), + ]); + pending.resolve(undefined); + await response; + expect(result).toBe(true); + }); +}); diff --git a/src/renderer/api/modules/plugin/jsApi.ts b/src/renderer/api/modules/plugin/jsApi.ts index 0bdd21cac..380683e45 100644 --- a/src/renderer/api/modules/plugin/jsApi.ts +++ b/src/renderer/api/modules/plugin/jsApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from '../editor/invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from '../shared'; @@ -15,15 +16,19 @@ export const jsApi = { get: () => invoke('js_get'), getUse: () => invoke('js_get_use'), toggle: (enabled: boolean) => - invoke('js_toggle', { enabled }), - load: () => invoke('js_load'), - reload: () => invoke('js_reload'), - remove: (id: string) => invoke('js_remove_plugin', { id }), + invokeEditorWrite('js_toggle', { enabled }), + load: () => invokeEditorWrite('js_load'), + reload: () => invokeEditorWrite('js_reload'), + remove: (id: string) => + invokeEditorWrite('js_remove_plugin', { id }), setPluginEnabled: (id: string, enabled: boolean) => - invoke('js_set_plugin_enabled', { id, enabled }), + invokeEditorWrite('js_set_plugin_enabled', { + id, + enabled, + }), setContent: (content: string) => - invoke('js_set_content', { content }), - reset: () => invoke('js_reset'), + invokeEditorWrite('js_set_content', { content }), + reset: () => invokeEditorWrite('js_reset'), onUse: (listener: (payload: JsTogglePayload) => void) => subscribe('js:use', listener), onState: (listener: (payload: JsStatePayload) => void) => diff --git a/src/renderer/api/modules/plugin/pluginApi.ts b/src/renderer/api/modules/plugin/pluginApi.ts index d8cc7befb..acabd5753 100644 --- a/src/renderer/api/modules/plugin/pluginApi.ts +++ b/src/renderer/api/modules/plugin/pluginApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from '../editor/invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; export const pluginApi = { @@ -6,11 +7,12 @@ export const pluginApi = { invoke('plugin_storage_get', { key }), set: (key: string, value: unknown) => - invoke('plugin_storage_set', { key, value }), + invokeEditorWrite('plugin_storage_set', { key, value }), - remove: (key: string) => invoke('plugin_storage_remove', { key }), + remove: (key: string) => + invokeEditorWrite('plugin_storage_remove', { key }), - clear: () => invoke('plugin_storage_clear'), + clear: () => invokeEditorWrite('plugin_storage_clear'), keys: () => invoke('plugin_storage_keys'), @@ -18,7 +20,7 @@ export const pluginApi = { invoke('plugin_storage_has_data', { prefix }), clearByPrefix: (prefix: string) => - invoke('plugin_storage_clear_by_prefix', { prefix }), + invokeEditorWrite('plugin_storage_clear_by_prefix', { prefix }), }, registerCleanup: () => { console.warn( diff --git a/src/renderer/api/modules/resources/cssApi.ts b/src/renderer/api/modules/resources/cssApi.ts index be5c746ef..115be7d55 100644 --- a/src/renderer/api/modules/resources/cssApi.ts +++ b/src/renderer/api/modules/resources/cssApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from '../editor/invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from '../shared'; @@ -14,16 +15,16 @@ export const cssApi = { get: () => invoke('css_get'), getUse: () => invoke('css_get_use'), toggle: (enabled: boolean) => - invoke('css_toggle', { enabled }), - load: () => invoke('css_load'), + invokeEditorWrite('css_toggle', { enabled }), + load: () => invokeEditorWrite('css_load'), setContent: (content: string) => - invoke('css_set_content', { content }), - reset: () => invoke('css_reset'), + invokeEditorWrite('css_set_content', { content }), + reset: () => invokeEditorWrite('css_reset'), historyGet: () => invoke('css_history_get'), historyActivate: (path: string) => - invoke('css_history_activate', { path }), + invokeEditorWrite('css_history_activate', { path }), historyRemove: (path: string) => - invoke('css_history_remove', { path }), + invokeEditorWrite('css_history_remove', { path }), onUse: (listener: (payload: CssTogglePayload) => void) => subscribe('css:use', listener), onContent: (listener: (payload: CustomCss) => void) => @@ -38,18 +39,21 @@ export const cssApi = { tabId, }), load: (tabId: string) => - invoke('css_tab_load', { - tabId, - }), + invokeEditorWrite( + 'css_tab_load', + { + tabId, + }, + ), clear: (tabId: string) => - invoke( + invokeEditorWrite( 'css_tab_clear', { tabId, }, ), toggle: (tabId: string, enabled: boolean) => - invoke( + invokeEditorWrite( 'css_tab_toggle', { tabId, @@ -57,7 +61,7 @@ export const cssApi = { }, ), activateHistory: (tabId: string, path: string) => - invoke( + invokeEditorWrite( 'css_tab_activate_history', { tabId, @@ -65,17 +69,20 @@ export const cssApi = { }, ), export: (tabId: string) => - invoke( + invokeEditorWrite( 'css_tab_export', { tabId, }, ), set: (tabId: string, css: import('@src/types/plugin/css').TabCss | null) => - invoke('css_tab_set', { - tabId, - css, - }), + invokeEditorWrite( + 'css_tab_set', + { + tabId, + css, + }, + ), onChanged: ( listener: ( payload: import('@src/types/plugin/api').TabCssResponse, diff --git a/src/renderer/api/modules/resources/presetsApi.ts b/src/renderer/api/modules/resources/presetsApi.ts index 7e242de8a..75c78a188 100644 --- a/src/renderer/api/modules/resources/presetsApi.ts +++ b/src/renderer/api/modules/resources/presetsApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from '../editor/invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from '../shared'; import { runExclusiveLegacyMutation } from '@src/renderer/editor/runtime/lifecycle/legacyEditorMutation'; @@ -8,12 +9,12 @@ import type { } from '@src/types/plugin/api'; export const presetsApi = { - save: () => invoke('preset_save'), + save: () => invokeEditorWrite('preset_save'), load: () => runExclusiveLegacyMutation(() => invoke('preset_load'), ), - saveTab: () => invoke('preset_save_tab'), + saveTab: () => invokeEditorWrite('preset_save_tab'), loadTab: () => runExclusiveLegacyMutation(() => invoke('preset_load_tab'), diff --git a/src/renderer/api/modules/resources/resourceApi.ts b/src/renderer/api/modules/resources/resourceApi.ts index 8cfc0699a..e9e21e3a1 100644 --- a/src/renderer/api/modules/resources/resourceApi.ts +++ b/src/renderer/api/modules/resources/resourceApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from '../editor/invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from '../shared'; import { @@ -7,26 +8,35 @@ import { export const fontApi = { load: () => - invoke('font_load'), + invokeEditorWrite( + 'font_load', + ), }; export const imageApi = { load: () => runLegacyEditorMutation(() => - invoke('image_load'), + invokeEditorWrite( + 'image_load', + ), ), }; export const soundApi = { load: () => - invoke('sound_load'), + invokeEditorWrite( + 'sound_load', + ), list: () => invoke('sound_list'), rename: (soundPath: string, displayName: string) => - invoke('sound_rename', { - soundPath, - displayName, - }), + invokeEditorWrite( + 'sound_rename', + { + soundPath, + displayName, + }, + ), remove: (soundPath: string) => runExclusiveLegacyMutation(() => invoke( @@ -37,13 +47,13 @@ export const soundApi = { ), ), setHidden: (soundPath: string, hidden: boolean) => - invoke( + invokeEditorWrite( 'sound_set_hidden', { soundPath, hidden }, ), // deprecated — setHidden의 역논리 별칭 (enabled = !hidden) setEnabled: (soundPath: string, enabled: boolean) => - invoke( + invokeEditorWrite( 'sound_set_enabled', { soundPath, enabled }, ), @@ -55,19 +65,18 @@ export const soundApi = { trimStartRatio?: number, trimEndRatio?: number, ) => - invoke( - 'sound_save_processed_wav', - { - request: { - wavBase64, - fileName, - originalBase64, - originalExtension, - trimStartRatio, - trimEndRatio, - }, + invokeEditorWrite< + import('@src/types/plugin/api').SoundSaveProcessedWavResult + >('sound_save_processed_wav', { + request: { + wavBase64, + fileName, + originalBase64, + originalExtension, + trimStartRatio, + trimEndRatio, }, - ), + }), loadOriginal: (soundPath: string) => invoke( 'sound_load_original', @@ -80,18 +89,17 @@ export const soundApi = { trimEndRatio?: number, displayName?: string, ) => - invoke( - 'sound_update_processed_wav', - { - request: { - soundPath, - wavBase64, - trimStartRatio, - trimEndRatio, - displayName, - }, + invokeEditorWrite< + import('@src/types/plugin/api').SoundUpdateProcessedWavResult + >('sound_update_processed_wav', { + request: { + soundPath, + wavBase64, + trimStartRatio, + trimEndRatio, + displayName, }, - ), + }), setLatencyLogging: (enabled: boolean) => invoke('key_sound_set_latency_logging', { enabled }).then(() => undefined), }; @@ -137,7 +145,9 @@ export const keySoundOutputApi = { invoke('key_sound_list_output_devices'), getState: () => invoke('key_sound_get_output_state'), setBackend: (backend: KeySoundOutputBackend) => - invoke('key_sound_set_output_backend', { backend }), + invokeEditorWrite('key_sound_set_output_backend', { + backend, + }), }; export const counterAnimationApi = { @@ -148,10 +158,9 @@ export const counterAnimationApi = { create: ( request: import('@src/types/plugin/api').CounterAnimationCreateRequest, ) => - invoke( - 'counter_animation_create', - { request }, - ), + invokeEditorWrite< + import('@src/types/plugin/api').CounterAnimationUpsertResponse + >('counter_animation_create', { request }), update: ( request: import('@src/types/plugin/api').CounterAnimationUpdateRequest, options: { preflight?: () => void } = {}, diff --git a/src/renderer/api/modules/window/obsApi.ts b/src/renderer/api/modules/window/obsApi.ts index 3fd338067..32a54141f 100644 --- a/src/renderer/api/modules/window/obsApi.ts +++ b/src/renderer/api/modules/window/obsApi.ts @@ -1,13 +1,14 @@ +import { invokeEditorWrite } from '../editor/invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from '../shared'; import type { ObsStatus } from '@src/types/obs'; export const obsApi = { - start: () => invoke('obs_start'), + start: () => invokeEditorWrite('obs_start'), stop: () => invoke('obs_stop'), status: () => invoke('obs_status'), - regenerateToken: () => invoke('obs_regenerate_token'), + regenerateToken: () => invokeEditorWrite('obs_regenerate_token'), onStatus: (listener: (status: ObsStatus) => void) => subscribe('obs:status', listener), // OBS WS 재연결/lag 복구 신호 (ipcShim 로컬 합성 이벤트 — 네이티브에서는 발화하지 않음) diff --git a/src/renderer/api/modules/window/overlayApi.ts b/src/renderer/api/modules/window/overlayApi.ts index 3dbd9a788..4b83a1bed 100644 --- a/src/renderer/api/modules/window/overlayApi.ts +++ b/src/renderer/api/modules/window/overlayApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from '../editor/invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from '../shared'; @@ -13,10 +14,11 @@ import type { export const overlayApi = { get: () => invoke('overlay_get'), setVisible: (visible: boolean) => - invoke('overlay_set_visible', { visible }), - setLock: (locked: boolean) => invoke('overlay_set_lock', { locked }), + invokeEditorWrite('overlay_set_visible', { visible }), + setLock: (locked: boolean) => + invokeEditorWrite('overlay_set_lock', { locked }), setAnchor: (anchor: string) => - invoke('overlay_set_anchor', { anchor }), + invokeEditorWrite('overlay_set_anchor', { anchor }), // 반환값은 실제로 적용된 bounds - 요청 크기가 한계를 넘으면 잘린다 // OBS 모드는 allowlist 밖이라 shim이 거절한다 resize: (payload: { @@ -24,11 +26,13 @@ export const overlayApi = { height: number; anchor?: string; contentTopOffset?: number; + contentLeftOffset?: number; fixedPositionDeltaX?: number; fixedPositionDeltaY?: number; - }) => invoke('overlay_resize', { payload }), + }) => invokeEditorWrite('overlay_resize', { payload }), // 오버레이를 작업 영역 안쪽 중앙으로 되돌린다 - 화면 밖으로 나갔을 때의 탈출구 - resetPosition: () => invoke('overlay_reset_position'), + resetPosition: () => + invokeEditorWrite('overlay_reset_position'), // 반환값은 페이드 적용 여부 (창 없음·미지원 플랫폼이면 false) transitionFade: (alpha: number, durationMs: number) => invoke('overlay_transition_fade', { alpha, durationMs }), diff --git a/src/renderer/benchmarks/grid/gridMarqueeBenchmark.tsx b/src/renderer/benchmarks/grid/gridMarqueeBenchmark.tsx index 69d97629d..b260b00ed 100644 --- a/src/renderer/benchmarks/grid/gridMarqueeBenchmark.tsx +++ b/src/renderer/benchmarks/grid/gridMarqueeBenchmark.tsx @@ -18,6 +18,7 @@ export const GridMarqueeBenchmarkSurface = ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, selectedKeyType: 'benchmark', pluginElements: [], clientToGridCoords: (x, y) => ({ x, y }), diff --git a/src/renderer/components/main/EditorSaveNotice.test.tsx b/src/renderer/components/main/EditorSaveNotice.test.tsx new file mode 100644 index 000000000..9ecbf96a6 --- /dev/null +++ b/src/renderer/components/main/EditorSaveNotice.test.tsx @@ -0,0 +1,176 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createEditorCoordinator } from '@src/renderer/editor/runtime/coordinator/editorCoordinator'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; +import type { + CanonicalEditorDocumentV1, + EditorCommitResult, +} from '@src/types/editor'; +import EditorSaveNotice from './EditorSaveNotice'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + coordinator: null as ReturnType | null, + settle: vi.fn(), + commit: vi.fn(), +})); +vi.mock( + '@src/renderer/editor/runtime/coordinator/editorStateCoordinator', + () => ({ + editorCoordinator: { + getState: () => mocks.coordinator!.getState(), + subscribe: ( + listener: Parameters< + ReturnType['subscribe'] + >[0], + ) => mocks.coordinator!.subscribe(listener), + flush: () => mocks.coordinator!.flush(), + }, + }), +); +vi.mock('@src/renderer/editor/runtime/lifecycle/lifecycleEditorFlush', () => ({ + flushFocusedEditor: mocks.settle, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@stores/data/useHistoryStatusStore', () => ({ + useHistoryStatusStore: (selector: (state: { busy: boolean }) => unknown) => + selector({ busy: false }), +})); + +describe('미저장 편집 안내와 실제 coordinator 재시도', () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(async () => { + mocks.commit.mockReset(); + mocks.settle.mockReset().mockResolvedValue(true); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let document: CanonicalEditorDocumentV1 = { + schemaVersion: 1, + keys: { '4key': ['A'] }, + keyPositions: { + '4key': [ + { + ...createDefaultKeyPosition(), + id: '11111111-1111-4111-8111-111111111111', + }, + ], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: {}, + layerGroups: {}, + }; + mocks.coordinator = createEditorCoordinator({ + transport: { + get: async () => ({ revision: 0, document: structuredClone(document) }), + commit: mocks.commit, + onCommitted: () => + Object.assign(() => {}, { ready: Promise.resolve() }), + }, + readDocument: () => document, + applyDocument: (next) => { + document = next; + }, + focusTarget: null, + visibilityTarget: null, + }); + await mocks.coordinator.start(); + container = window.document.createElement('div'); + window.document.body.append(container); + root = createRoot(container); + act(() => root.render()); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + mocks.coordinator!.stop(); + vi.restoreAllMocks(); + }); + + const failEdit = async (errorCode = 'IO_ERROR') => { + mocks.commit.mockRejectedValue({ + errorCode, + message: 'save failed', + retryable: true, + }); + await act(async () => { + await mocks + .coordinator!.commitPatch({ schemaVersion: 1, keys: { '4key': ['B'] } }) + .catch(() => {}); + }); + }; + + it.each(['HISTORY_IN_PROGRESS', 'HISTORY_EPOCH_CONFLICT', 'IO_ERROR'])( + '%s 이후 초안을 유지하고 명시적 재시도 경로를 표시한다', + async (code) => { + await failEdit(code); + expect(mocks.coordinator!.getState().dirty).toBe(true); + expect(container.querySelector('[role="status"]')?.textContent).toContain( + 'editorSave.pendingFailure', + ); + expect(container.querySelector('button')?.textContent).toBe( + 'editorSave.retrySave', + ); + expect(mocks.commit).toHaveBeenCalledOnce(); + }, + ); + + it('저장 성공 응답까지 안내를 유지하고 중복 클릭은 한 번만 저장한다', async () => { + await failEdit(); + let resolve!: (result: EditorCommitResult) => void; + mocks.commit.mockImplementation( + () => + new Promise((yes) => { + resolve = yes; + }), + ); + await act(async () => { + container.querySelector('button')!.click(); + container.querySelector('button')!.click(); + await new Promise((yes) => setTimeout(yes, 0)); + }); + expect(mocks.commit).toHaveBeenCalledTimes(2); + expect(container.querySelector('button')?.disabled).toBe(true); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + await act(async () => { + resolve({ revision: 1, changedFields: ['keys'] }); + await new Promise((yes) => setTimeout(yes, 0)); + }); + expect(mocks.coordinator!.getState().dirty).toBe(false); + expect(container.querySelector('[role="status"]')).toBeNull(); + }); + + it('재시도도 실패하면 안내와 초안을 유지한다', async () => { + await failEdit(); + await act(async () => { + container.querySelector('button')!.click(); + await new Promise((yes) => setTimeout(yes, 0)); + }); + expect(mocks.commit).toHaveBeenCalledTimes(2); + expect(mocks.coordinator!.getState().pendingLocal?.keys['4key']).toEqual([ + 'B', + ]); + expect(container.querySelector('button')?.disabled).toBe(false); + }); + + it('다른 편집 정산이 실패하면 저장을 진행하지 않는다', async () => { + await failEdit(); + mocks.settle.mockResolvedValue(false); + await act(async () => { + container.querySelector('button')!.click(); + }); + expect(mocks.commit).toHaveBeenCalledOnce(); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + }); + + it('저장 실패가 없으면 안내를 표시하지 않는다', () => { + expect(container.querySelector('[role="status"]')).toBeNull(); + }); +}); diff --git a/src/renderer/components/main/EditorSaveNotice.tsx b/src/renderer/components/main/EditorSaveNotice.tsx new file mode 100644 index 000000000..1c2a8e99d --- /dev/null +++ b/src/renderer/components/main/EditorSaveNotice.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from '@contexts/useTranslation'; +import { useSingleFlightAction } from '@hooks/useSingleFlightAction'; +import { useHistoryStatusStore } from '@stores/data/useHistoryStatusStore'; +import { editorCoordinator } from '@src/renderer/editor/runtime/coordinator/editorStateCoordinator'; +import { flushFocusedEditor } from '@src/renderer/editor/runtime/lifecycle/lifecycleEditorFlush'; +import type { EditorCoordinatorState } from '@src/renderer/editor/runtime/coordinator/editorCoordinator'; + +const EditorSaveNotice = () => { + const { t } = useTranslation(); + const [needsSave, setNeedsSave] = useState(false); + const [saving, setSaving] = useState(false); + const historyBusy = useHistoryStatusStore((state) => state.busy); + + useEffect(() => { + const update = (state: EditorCoordinatorState) => { + if (!state.dirty) setNeedsSave(false); + else if (state.failureKind === 'transient') setNeedsSave(true); + setSaving(state.phase === 'saving'); + }; + const unsubscribe = editorCoordinator.subscribe(update); + update(editorCoordinator.getState()); + return unsubscribe; + }, []); + + const { run: retry, pending } = useSingleFlightAction(async () => { + try { + if (!(await flushFocusedEditor())) return; + await editorCoordinator.flush(); + } catch (error) { + console.error('편집 내용 다시 저장 실패', error); + } + }); + + if (!needsSave) return null; + const busy = pending || saving || historyBusy; + + return ( +
+ {t('editorSave.pendingFailure')} + +
+ ); +}; + +export default EditorSaveNotice; diff --git a/src/renderer/components/main/Grid/PropertiesPanel.detachedContracts.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel.detachedContracts.test.tsx index b49ceec65..94e0461fe 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.detachedContracts.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.detachedContracts.test.tsx @@ -12,6 +12,20 @@ import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useKeySlotCapture } from '@hooks/useKeySlotCapture'; import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { + cancelPluginSettingsSessionForPlugin, + openPluginSettingsSession, +} from '@plugins/runtime/pluginSettingsSession'; +import { + makeCanonicalSpritePosition, + makeSpritePose, +} from '@utils/sprite/spriteFixtures'; + +type MixedGetter = ( + getter: (pos: { width?: number; height?: number }) => T | undefined, + defaultValue: T, +) => { isMixed: boolean; value: T }; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -298,13 +312,32 @@ vi.mock('@src/renderer/editor/runtime/gesture/editGestureController', () => ({ settleCommit: settleCommitMock, }, })); -vi.mock('./PropertiesPanel/index', () => { +vi.mock('./PropertiesPanel/index', async () => { + const { default: PanelRenameControl } = await import( + './PropertiesPanel/navigation/PanelRenameControl' + ); const Stub = ({ children }: { children?: React.ReactNode }) => (
{children}
); - const SingleKeyStatPanel = (props: Record) => { - singleKeyStatPropsMock(props); - return ; + // 헤더 이름 변경 계약만 실제 컨트롤로 검증 - 패널 props와 컨트롤 props가 같은 이름을 쓴다 + const SingleKeyStatPanel = ( + renameProps: Omit< + React.ComponentProps, + 'title' | 'titleClassName' | 'renameButtonTitle' + >, + ) => { + singleKeyStatPropsMock(renameProps); + return ( + <> + + + + ); }; const SingleGraphPanel = (props: Record) => { singleGraphPropsMock(props); @@ -342,7 +375,18 @@ vi.mock('./PropertiesPanel/index', () => { batchPluginPropsMock(props); return
; }, - PluginSettingsPanelView: () => , + PluginSettingsPanelView: ({ + handlePluginSettingsPanelConfirm, + }: { + handlePluginSettingsPanelConfirm: () => void; + }) => ( + <> + + + + ), usePanelScroll: () => ({ batchScrollRefFor: () => vi.fn(), singleScrollRefFor: () => vi.fn(), @@ -676,6 +720,61 @@ describe('PropertiesPanel canonical native contract', () => { window.__dmn_window_type = originalWindowType; }); + it('헤더 이름을 Escape로 취소한 뒤 다음 이름을 저장한다', async () => { + const id = '61111111-1111-4111-8111-111111111111'; + installSingle('key', id); + mounted = mountPanel(); + const container = mounted.container; + const startRename = async () => { + await act(async () => + container + .querySelector( + 'button[title="contextMenu.rename"]', + )! + .click(), + ); + const input = container.querySelector('input')!; + await act(async () => input.focus()); + return input; + }; + const typeName = async (input: HTMLInputElement, value: string) => { + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + const cancelled = await startRename(); + await typeName(cancelled, 'Cancelled'); + const blur = vi.fn(); + cancelled.addEventListener('blur', blur); + await act(async () => + cancelled.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ), + ); + expect(container.querySelector('input')).toBeNull(); + expect(blur).not.toHaveBeenCalled(); + expect(patchLayerNameMock).not.toHaveBeenCalled(); + + const input = await startRename(); + await typeName(input, 'Saved'); + await act(async () => + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ), + ); + + expect(patchLayerNameMock).toHaveBeenCalledExactlyOnceWith( + 'key', + id, + 'Saved', + ); + }); + it.each([ ['key', '11111111-1111-4111-8111-111111111111'], ['stat', '22222222-2222-4222-8222-222222222222'], @@ -950,6 +1049,124 @@ describe('PropertiesPanel canonical native contract', () => { expect(keyLegacyUpdateMock).not.toHaveBeenCalled(); }); + describe('혼합 선택 크기 표시 집합', () => { + const MIXED_KEY_ID = 'a4444444-4444-4444-8444-444444444444'; + const MIXED_SPRITE_ID = 'a5555555-5555-4555-8555-555555555555'; + + // 크기 입력은 resize가 실제로 조절하는 집합을 그대로 읽어야 한다. + // 스타일 집합에는 스프라이트가 없어서 그걸 쓰면 키 값이 대표로 뜨고, + // 그 값을 확정하면 스프라이트의 모든 자세 오프셋까지 배율이 먹는다 + it('키와 스프라이트를 함께 고르면 크기 getter가 Mixed를 낸다', () => { + const key = { ...createDefaultKeyPosition(), id: MIXED_KEY_ID }; + useKeyStore.setState({ + keyMappings: { '4key': ['A'] }, + positions: { '4key': [key] }, + canonicalPositions: { '4key': [key] }, + }); + useSpriteStore.setState({ + positions: { + '4key': [ + makeCanonicalSpritePosition({ + id: MIXED_SPRITE_ID, + layerName: null, + groupId: null, + width: 200, + height: 200, + }), + ], + }, + }); + useGridSelectionStore.setState({ + selectedElements: [ + { type: 'key', id: MIXED_KEY_ID, index: 0 }, + { type: 'sprite', id: MIXED_SPRITE_ID, index: 0 }, + ], + selectedGroupIds: [], + }); + + mounted = mountPanel(); + + const props = batchKeyLikePropsMock.mock.lastCall?.[0] as { + getMixedValue: MixedGetter; + getMixedValueGeometry: MixedGetter; + }; + + expect(props.getMixedValueGeometry((pos) => pos.width, 60)).toEqual({ + isMixed: true, + value: 60, + }); + // 스타일 집합은 스프라이트를 보지 않는다 - 크기에 이걸 쓰면 안 되는 이유 + expect(props.getMixedValue((pos) => pos.width, 60)).toEqual({ + isMixed: false, + value: 60, + }); + }); + + // 커밋은 resizeSprite로 자세까지 스케일한다. 미리보기가 원시 + // bounds만 얹으면 드래그 중엔 활동 영역만 줄다가 놓는 순간 튄다 + it('스프라이트 크기 미리보기는 이미지와 자세까지 커밋과 같게 투영한다', () => { + const key = { ...createDefaultKeyPosition(), id: MIXED_KEY_ID }; + useKeyStore.setState({ + keyMappings: { '4key': ['A'] }, + positions: { '4key': [key] }, + canonicalPositions: { '4key': [key] }, + }); + useSpriteStore.setState({ + positions: { + '4key': [ + makeCanonicalSpritePosition({ + id: MIXED_SPRITE_ID, + layerName: null, + groupId: null, + width: 200, + height: 200, + poses: [ + makeSpritePose({ + poseId: 'pose-1', + transform: { x: -60, y: 0, rotation: 0, scale: 1 }, + }), + ], + }), + ], + }, + }); + useGridSelectionStore.setState({ + selectedElements: [ + { type: 'key', id: MIXED_KEY_ID, index: 0 }, + { type: 'sprite', id: MIXED_SPRITE_ID, index: 0 }, + ], + selectedGroupIds: [], + }); + + mounted = mountPanel(); + const geometryProps = batchPropsMock.mock.lastCall?.[0] as { + onStableGeometryPreview: (operation: Record) => void; + }; + act(() => + geometryProps.onStableGeometryPreview({ + kind: 'resize', + dimension: 'width', + value: 100, + }), + ); + + const spriteCall = previewMock.mock.calls.find( + (call) => + (call[2] as { domain?: string } | undefined)?.domain === + 'spritePosition', + ); + expect(spriteCall).toBeTruthy(); + const patch = ( + spriteCall?.[1] as Array<{ id: string; patch: Record }> + )[0].patch; + // 200 -> 100 이므로 배율 0.5가 콘텐츠에도 그대로 걸린다 + expect(patch.width).toBe(100); + expect( + (patch.poses as Array<{ transform: { x: number } }>)[0].transform.x, + ).toBe(-30); + }); + }); + describe('혼합 선택 batch geometry pluginTargets 결합', () => { const FIRST_KEY_ID = 'a1111111-1111-4111-8111-111111111111'; const SECOND_KEY_ID = 'a2222222-2222-4222-8222-222222222222'; @@ -1206,6 +1423,65 @@ describe('PropertiesPanel 편집 세션 scope 경계', () => { expect(scopedOf('plugin-settings')).toBe('false'); }); + + it('실제 플러그인 설정 세션의 저장 실패를 한 번 정산하고 안내한다', async () => { + const originalApi = window.api; + const alert = vi.fn().mockResolvedValue(undefined); + const onCancel = vi.fn(); + const resolve = vi.fn(); + const originalSettings = { enabled: false }; + let rejectSave!: (error: Error) => void; + const onConfirm = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectSave = reject; + }), + ); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + window.api = { ui: { dialog: { alert } } } as never; + try { + openPluginSettingsSession({ + pluginId: 'save-test', + definition: { settings: {} }, + settings: { enabled: true }, + originalSettings, + onChange: vi.fn(), + onConfirm, + onCancel, + resolve, + }); + mounted = mountPanel(); + + await act(async () => { + const button = [...mounted.container.querySelectorAll('button')].find( + (element) => element.textContent === 'save-plugin-settings', + ); + expect(button).toBeDefined(); + button!.click(); + }); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith( + { enabled: true }, + originalSettings, + ); + expect(usePropertiesPanelStore.getState().pluginSettingsPanel).toBeNull(); + expect(resolve).not.toHaveBeenCalled(); + await act(async () => rejectSave(new Error('disk write failed'))); + + expect(onCancel).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledExactlyOnceWith(false); + expect(alert).toHaveBeenCalledExactlyOnceWith('common.saveFailed', { + confirmText: 'common.ok', + }); + expect(usePropertiesPanelStore.getState().pluginSettingsPanel).toBeNull(); + } finally { + cancelPluginSettingsSessionForPlugin('save-test'); + window.api = originalApi; + consoleError.mockRestore(); + } + }); }); describe('PropertiesPanel 배치 색상 피커 대상 결합', () => { diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index b62b43724..683d69864 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -22,6 +22,7 @@ import { PluginSelectionPanel, SingleGraphPanel, SingleKnobPanel, + SingleSpritePanel, SingleKeyStatPanel, BatchKeyLikePanel, BatchGraphOnlyPanel, @@ -88,12 +89,16 @@ const PropertiesPanel: React.FC = ({ statItemPositions, graphItemPositions, knobItemPositions, + canonicalSpritePositions, + spriteItemPositions, selectedKeyElements, selectedStatElements, selectedGraphElements, selectedKnobElements, + selectedSpriteElements, selectedKeyLikeElements, selectedBatchStyleElements, + selectedBatchGeometryElements, selectedPluginElements, selectedPluginElement, stableBatchGeometryTargets, @@ -116,6 +121,7 @@ const PropertiesPanel: React.FC = ({ singleGraphIndex, singleGraphPosition, singleKnobPosition, + singleSpritePosition, selectedGroupInfo, } = usePropertiesPanelSelection(); const { @@ -131,6 +137,7 @@ const PropertiesPanel: React.FC = ({ getMixedValueKnobs, getMixedValueKnobsAsKey, getMixedValueBatch, + getMixedValueGeometry, getMixedValueActiveCapable, getMixedValueKeysOnly, } = createBatchSelectionModel({ @@ -141,11 +148,13 @@ const PropertiesPanel: React.FC = ({ statItemPositions, graphItemPositions, knobItemPositions, + spriteItemPositions, selectedKeyElements, selectedKeyLikeElements, selectedGraphElements, selectedKnobElements, selectedBatchStyleElements, + selectedBatchGeometryElements, }); const { useCustomCSS } = useSettingsStore(); const { @@ -156,7 +165,7 @@ const PropertiesPanel: React.FC = ({ handleChange: handlePluginSettingsPanelChange, handleConfirm: handlePluginSettingsPanelConfirm, handleCancel: handlePluginSettingsPanelCancel, - } = usePluginSettingsPanelController(); + } = usePluginSettingsPanelController({ t }); const isPanelVisibleStore = usePropertiesPanelStore( (state) => state.isCanvasPanelOpen, ); @@ -212,6 +221,7 @@ const PropertiesPanel: React.FC = ({ selectedPluginElements.length, selectedGraphElements.length, selectedKnobElements.length, + selectedSpriteElements.length, ].join('|'); const { activePageKey, @@ -255,6 +265,7 @@ const PropertiesPanel: React.FC = ({ singleStatPosition, singleGraphPosition, singleKnobPosition, + singleSpritePosition, singleKeyInfo, singleKeyCode, setPanelMode, @@ -323,7 +334,7 @@ const PropertiesPanel: React.FC = ({ selectedElements, selectedKeyElementsLength: selectedKeyElements.length, selectedKeyLikeElementsLength: selectedKeyLikeElements.length, - selectedBatchStyleElementsLength: selectedBatchStyleElements.length, + selectedBatchGeometryElementsLength: selectedBatchGeometryElements.length, selectedPluginElementsLength: selectedPluginElements.length, pluginSettingsPanel, closePage, @@ -435,7 +446,9 @@ const PropertiesPanel: React.FC = ({ statItemPositions, graphItemPositions, knobItemPositions, - selectedBatchStyleElements, + canonicalSpritePositions, + spriteItemPositions, + selectedBatchGeometryElements, stableBatchGeometryTargets, stablePluginGeometryElements, stablePluginGeometryTargets, @@ -502,16 +515,18 @@ const PropertiesPanel: React.FC = ({ // 플러그인 단독 다중은 경량 기하 배치 패널에 라우트 const renderSelectionPanelBody = () => { const selectionTotalCount = - selectedBatchStyleElements.length + selectedPluginElements.length; + selectedBatchGeometryElements.length + selectedPluginElements.length; const route = resolveSelectionPanelRoute({ keyLikeCount: selectedKeyLikeElements.length, graphCount: selectedGraphElements.length, knobCount: selectedKnobElements.length, + spriteCount: selectedSpriteElements.length, pluginCount: selectedPluginElements.length, hasSingleKeyPosition: !!singleKeyPosition, hasSingleStatPosition: !!singleStatPosition, hasSingleGraphPosition: !!singleGraphPosition, hasSingleKnobPosition: !!singleKnobPosition, + hasSingleSpritePosition: !!singleSpritePosition, }); // 다중 선택인 경우 (키/통계 포함, 또는 그래프+노브 혼합) @@ -551,6 +566,7 @@ const PropertiesPanel: React.FC = ({ getMixedValue={getMixedValue} getMixedValueCanonical={getMixedValueCanonical} getMixedValueBatch={getMixedValueBatch} + getMixedValueGeometry={getMixedValueGeometry} getMixedValueGraphs={getMixedValueGraphs} getMixedValueGraphsAsKey={getMixedValueGraphsAsKey} getMixedValueKeysOnly={getMixedValueKeysOnly} @@ -618,6 +634,7 @@ const PropertiesPanel: React.FC = ({ handleBatchResizePreview={handleBatchResizePreview} onElementPropertyCommit={handleBatchElementPropertyCommit} handleKnobBatchSharedSetting={handleKnobBatchSharedSetting} + getMixedValueGeometry={getMixedValueGeometry} getMixedValueKnobs={getMixedValueKnobs} getMixedValueKnobsAsKey={getMixedValueKnobsAsKey} getSelectedKnobsData={getSelectedKnobsData} @@ -659,6 +676,7 @@ const PropertiesPanel: React.FC = ({ handleBatchResizePreview={handleBatchResizePreview} onElementPropertyCommit={handleBatchElementPropertyCommit} handleGraphBatchSharedSetting={handleGraphBatchSharedSetting} + getMixedValueGeometry={getMixedValueGeometry} getMixedValueGraphs={getMixedValueGraphs} getMixedValueGraphsAsKey={getMixedValueGraphsAsKey} getSelectedGraphsData={getSelectedGraphsData} @@ -732,6 +750,28 @@ const PropertiesPanel: React.FC = ({ ); } + // 단일 스프라이트 요소 선택인 경우 - 전용 필드 커밋·프리뷰는 패널이 소유 + if (route.kind === 'singleSprite' && singleSpritePosition) { + return ( + + ); + } + // 단일 노브 요소 선택인 경우 if (route.kind === 'singleKnob' && singleKnobPosition) { return ( @@ -857,6 +897,10 @@ const PropertiesPanel: React.FC = ({ 'graph', selectedGraphElements[0]?.id, )} + onStylePropertyPreview={stableStylePropertyPreviewHandler( + 'graph', + selectedGraphElements[0]?.id, + )} onStylePropertyCommit={stableStylePropertyCommitHandler( 'graph', selectedGraphElements[0]?.id, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchKnobOnlyPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchKnobOnlyPanel.tsx index 7cf2ac4d9..0218151a7 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchKnobOnlyPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchKnobOnlyPanel.tsx @@ -66,6 +66,8 @@ interface BatchKnobOnlyPanelProps { options?: { gestureId?: string }, ) => void; handleKnobBatchSharedSetting: (updates: Partial) => void; + /** 크기 표시 전용 - resize 대상 집합(스프라이트 포함)에서 파생된다 */ + getMixedValueGeometry: MixedValueGetter; getMixedValueKnobs: MixedValueGetter; getMixedValueKnobsAsKey: MixedValueGetter; getSelectedKnobsData: () => KeyData[]; @@ -102,6 +104,7 @@ const BatchKnobOnlyPanel: React.FC = ({ handleBatchResizePreview, onElementPropertyCommit, handleKnobBatchSharedSetting, + getMixedValueGeometry, getMixedValueKnobs, getMixedValueKnobsAsKey, getSelectedKnobsData, @@ -238,6 +241,7 @@ const BatchKnobOnlyPanel: React.FC = ({ } getMixedValue={getMixedValueKnobsAsKey} + getMixedValueSize={getMixedValueGeometry} getSelectedKeysData={getSelectedKnobsData} handleBatchAlign={handleBatchAlign} handleBatchDistribute={handleBatchDistribute} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPluginOnlyPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPluginOnlyPanel.tsx index de589d675..587a84ad8 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPluginOnlyPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchPluginOnlyPanel.tsx @@ -3,6 +3,7 @@ import { PANEL_ROOT_CLASS } from '../navigation/panelChrome'; import { PropertySection } from '../controls/PropertyInputs'; import { TABS, type TabType } from '../types'; import BatchGeometrySection from './geometry/BatchGeometrySection'; +import SelectionRotationInput from './geometry/SelectionRotationInput'; import EditSessionBoundary from '../selection/EditSessionBoundary'; import BatchPanelHeader from './BatchPanelHeader'; import type { MixedValueResult } from './batchPanelShared'; @@ -95,6 +96,9 @@ const BatchPluginOnlyPanel: React.FC = ({ batchSpacing={batchPluginSpacing} t={t} /> +
diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 44e90daa4..33e7adeff 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -99,6 +99,8 @@ interface BatchKeyLikePanelProps { /** 프리뷰가 섞이지 않은 canonical 기준. 게스처 취소 뒤 로컬 복원용 */ getMixedValueCanonical: MixedValueGetter; getMixedValueBatch: MixedValueGetter; + /** 크기 표시 전용 - resize 대상 집합(스프라이트 포함)에서 파생된다 */ + getMixedValueGeometry: MixedValueGetter; getMixedValueGraphs: MixedValueGetter; getMixedValueGraphsAsKey: MixedValueGetter; getMixedValueKeysOnly: MixedValueGetter; @@ -173,6 +175,7 @@ export const BatchKeyLikePanel: React.FC = ({ getMixedValue, getMixedValueCanonical, getMixedValueBatch, + getMixedValueGeometry, getMixedValueGraphs, getMixedValueKeysOnly, getMixedValueActiveCapable, @@ -469,6 +472,7 @@ export const BatchKeyLikePanel: React.FC = ({ selectedKnobElements.length > 0 } getMixedValue={styleMixedValueGetter} + getMixedValueSize={getMixedValueGeometry} getSelectedKeysData={styleSelectedDataGetter} afterSizeContent={ hasGraphSelection ? ( diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchSelectionModel.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchSelectionModel.test.ts index 4c66de47c..b6ae69c9a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchSelectionModel.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchSelectionModel.test.ts @@ -3,6 +3,7 @@ import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KeyPosition } from '@src/types/key/keys'; import type { KnobItemPosition } from '@src/types/key/knobs'; import type { StatItemPosition } from '@src/types/key/statItems'; +import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; import { createBatchSelectionModel, type BatchSelectionModelInput, @@ -43,6 +44,15 @@ const createInput = (): BatchSelectionModelInput => { statItemPositions: { '4key': [stat] }, graphItemPositions: { '4key': [graph] }, knobItemPositions: { '4key': [knob] }, + spriteItemPositions: { + '4key': [ + makeCanonicalSpritePosition({ + id: 'sprite-1', + width: 200, + height: 200, + }), + ], + }, selectedKeyElements: [{ type: 'key', id: 'key-1' }], selectedKeyLikeElements: [ { type: 'key', id: 'key-1' }, @@ -56,6 +66,13 @@ const createInput = (): BatchSelectionModelInput => { { type: 'graph', id: 'graph-1' }, { type: 'knob', id: 'knob-1' }, ], + selectedBatchGeometryElements: [ + { type: 'stat', id: 'stat-1' }, + { type: 'key', id: 'key-1' }, + { type: 'graph', id: 'graph-1' }, + { type: 'knob', id: 'knob-1' }, + { type: 'sprite', id: 'sprite-1' }, + ], }; }; @@ -88,6 +105,25 @@ describe('createBatchSelectionModel', () => { ).toEqual({ isMixed: false, value: 40 }); }); + // 크기 입력은 resize 대상 집합을 읽는다 - 스타일 집합만 보면 스프라이트 값이 가려진다 + it('크기 혼합 값은 스프라이트를 포함한 기하 집합에서 집계한다', () => { + const input = createInput(); + input.selectedBatchStyleElements = [{ type: 'key', id: 'key-1' }]; + input.selectedBatchGeometryElements = [ + { type: 'key', id: 'key-1' }, + { type: 'sprite', id: 'sprite-1' }, + ]; + const model = createBatchSelectionModel(input); + + expect(model.getMixedValueBatch((position) => position.width, 0)).toEqual({ + isMixed: false, + value: 60, + }); + expect( + model.getMixedValueGeometry((position) => position.width, 0), + ).toEqual({ isMixed: true, value: 60 }); + }); + it('누락 ID를 제외하고 생성 시점의 선택 snapshot을 재사용한다', () => { const input = createInput(); input.selectedKeyLikeElements = [ diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchSelectionModel.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchSelectionModel.ts index 44abea386..4e773d439 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/batchSelectionModel.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/batchSelectionModel.ts @@ -9,6 +9,7 @@ import type { } from '@src/types/key/keys'; import type { KnobItemPosition, KnobItemPositions } from '@src/types/key/knobs'; import type { StatItemPositions } from '@src/types/key/statItems'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import { getKeyInfoByGlobalKey } from '@utils/input/KeyMaps'; import { aggregateMixedValue } from '@src/renderer/components/main/Grid/PropertiesPanel/batch/mixedValue'; import { slotCanonical, slotDisplayName } from '@utils/keySlot'; @@ -68,6 +69,7 @@ export interface BatchSelectionModelInput { statItemPositions: StatItemPositions; graphItemPositions: GraphItemPositions; knobItemPositions: KnobItemPositions; + spriteItemPositions: Record; selectedKeyElements: readonly BatchSelectedElement<'key'>[]; selectedKeyLikeElements: readonly BatchSelectedElement<'key' | 'stat'>[]; selectedGraphElements: readonly BatchSelectedElement<'graph'>[]; @@ -75,6 +77,10 @@ export interface BatchSelectionModelInput { selectedBatchStyleElements: readonly BatchSelectedElement< 'key' | 'stat' | 'graph' | 'knob' >[]; + // resize가 실제로 조절하는 집합 - 스타일 집합에 sprite가 더해진다 + selectedBatchGeometryElements: readonly BatchSelectedElement< + 'key' | 'stat' | 'graph' | 'knob' | 'sprite' + >[]; } export const createBatchSelectionModel = ({ @@ -85,11 +91,13 @@ export const createBatchSelectionModel = ({ statItemPositions, graphItemPositions, knobItemPositions, + spriteItemPositions, selectedKeyElements, selectedKeyLikeElements, selectedGraphElements, selectedKnobElements, selectedBatchStyleElements, + selectedBatchGeometryElements, }: BatchSelectionModelInput) => { const modePositions = positions[selectedKeyType] ?? []; const modeCanonicalPositions = canonicalPositions[selectedKeyType] ?? []; @@ -97,11 +105,13 @@ export const createBatchSelectionModel = ({ const modeStatPositions = statItemPositions[selectedKeyType] ?? []; const modeGraphPositions = graphItemPositions[selectedKeyType] ?? []; const modeKnobPositions = knobItemPositions[selectedKeyType] ?? []; + const modeSpritePositions = spriteItemPositions[selectedKeyType] ?? []; const keyIndex = indexPositionsById(modePositions); const statIndex = indexPositionsById(modeStatPositions); const graphIndex = indexPositionsById(modeGraphPositions); const knobIndex = indexPositionsById(modeKnobPositions); + const spriteIndex = indexPositionsById(modeSpritePositions); const canonicalKeyGroups = groupPositionsById(modeCanonicalPositions); const selectedKeysData = selectedKeyLikeElements @@ -186,6 +196,26 @@ export const createBatchSelectionModel = ({ .map((element) => keyIndex.get(element.id) ?? null) .filter((value): value is NonNullable => value !== null); + // 크기 표시·Mixed 판정 전용. resize가 실제로 조절하는 집합에서 그대로 파생시켜 + // 두 집합이 갈릴 수 없게 한다 - 스타일 집합에는 스프라이트가 없어서 그걸 쓰면 + // 키 60·스프라이트 200이 Mixed 없이 60으로 보이고, 그 값을 확정하면 + // 스프라이트의 모든 자세 오프셋까지 배율이 먹는다 + const selectedGeometryPositions = selectedBatchGeometryElements + .map((element) => { + const indexed = + element.type === 'key' + ? keyIndex.get(element.id) + : element.type === 'stat' + ? statIndex.get(element.id) + : element.type === 'graph' + ? graphIndex.get(element.id) + : element.type === 'knob' + ? knobIndex.get(element.id) + : spriteIndex.get(element.id); + return indexed ? (indexed.position as KeyPosition) : null; + }) + .filter((position): position is KeyPosition => position !== null); + const selectedActiveCapablePositions: KeyPosition[] = [ ...selectedKeyOnlyPositions.map(({ position }) => position), ...selectedKnobElements.flatMap((element) => { @@ -274,6 +304,10 @@ export const createBatchSelectionModel = ({ getter, defaultValue, ), + getMixedValueGeometry: ( + getter: (position: KeyPosition) => Value | undefined, + defaultValue: Value, + ) => aggregateMixedValue(selectedGeometryPositions, getter, defaultValue), getMixedValueActiveCapable: ( getter: (position: KeyPosition) => Value | undefined, defaultValue: Value, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/SelectionRotationInput.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/SelectionRotationInput.test.tsx new file mode 100644 index 000000000..4ad082e09 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/SelectionRotationInput.test.tsx @@ -0,0 +1,573 @@ +import type { ReactNode } from 'react'; +import type { Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + CanonicalEditorDocumentV1, + EditorCommittedV1, + EditorCommitRequest, + EditorCommitResult, +} from '@src/types/editor'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; +import { + makeCanonicalSpritePosition, + makeSpritePose, +} from '@utils/sprite/spriteFixtures'; +import type { SelectionRotationTarget } from '@utils/element/selectionRotation'; +import type { SelectionRotationFrame } from '../../../handles/selectionRotationGesture'; + +const transport = vi.hoisted(() => { + let resolveCommit!: (result: EditorCommitResult) => void; + let listener: ((event: EditorCommittedV1) => void) | null = null; + return { + get: vi.fn(), + commit: vi.fn( + (_request: EditorCommitRequest) => + new Promise((resolve) => { + resolveCommit = resolve; + }), + ), + onCommitted: vi.fn((next: (event: EditorCommittedV1) => void) => { + listener = next; + return Object.assign( + () => { + listener = null; + }, + { ready: Promise.resolve() }, + ); + }), + resolve: (result: EditorCommitResult) => resolveCommit(result), + emit: (event: EditorCommittedV1) => listener?.(event), + subscribe: vi.fn(async () => 1), + publish: vi.fn(async () => {}), + cancel: vi.fn(async () => {}), + }; +}); +vi.mock('@api/modules/editor/editorApi', () => ({ editorApi: transport })); +vi.mock('@api/modules/editor/previewApi', () => ({ previewApi: transport })); +vi.mock('@components/main/Grid/PropertiesPanel/index', async () => { + const { createElement } = await import('react'); + const { NumberInput } = await import( + '@components/main/common/numberInput/NumberInput' + ); + return { + NumberInput, + PropertyRow: ({ children }: { children: ReactNode }) => + createElement('div', {}, children), + }; +}); + +const MODE = '4key'; +const FIELDS = [ + 'keyPositions', + 'statPositions', + 'graphPositions', + 'knobPositions', + 'spritePositions', +] as const; +const TYPES = ['key', 'stat', 'graph', 'knob', 'sprite'] as const; +const KEY_IDS = [ + '78444669-146d-4e4b-a79e-ac35995b2fa3', + 'd3115e30-d0a5-4662-b985-138d23d1388e', + '82cf7bb7-5157-4a49-95be-14bbe51ed9b6', + 'a0192a48-f3e6-405a-a78b-d586d36e86df', + '6a4c3fdc-867f-40e2-8af6-efb92322194b', + 'cc1de0e9-1bd8-49d9-ad53-5e5ca71eb681', + '8933d814-8da1-434d-a848-f21ad5fc34a8', + '947e2792-58b7-4883-b7f9-6ca97418a1c9', +]; + +// 저장 시 두 좌표에서 1 ULP 차이가 발생했던 실제 혼합 선택의 기하 +const initialDocument = (): CanonicalEditorDocumentV1 => ({ + schemaVersion: 1, + keys: { [MODE]: ['A', 'D', 'W', 'E', 'R', 'T', 'S', 'F'] }, + keyPositions: { + [MODE]: [ + [270, 168.75, 0], + [416.25, 168.75, 0], + [270, 120, 25], + [318.75, 120, 0], + [367.5, 120, 0], + [416.25, 120, 0], + [318.75, 168.75, 0], + [367.5, 168.75, 0], + ].map(([dx, dy, rotation], index) => ({ + ...createDefaultKeyPosition(dx, dy), + id: KEY_IDS[index], + width: 45, + height: 45, + rotation, + })), + }, + statPositions: { + [MODE]: [ + { + ...createDefaultKeyPosition(270, 217.5), + id: '5c25bd15-6eea-4626-8271-b88d6eefb2b1', + width: 93.75, + height: 18.75, + rotation: 15, + statType: 'kps', + }, + { + ...createDefaultKeyPosition(367.5, 217.5), + id: 'c6ac524c-7812-4ac9-9409-e904b62e8b80', + width: 93.75, + height: 18.75, + rotation: 0, + statType: 'total', + }, + ], + }, + graphPositions: { + [MODE]: [ + { + ...createDefaultKeyPosition(206.25, 232.5), + id: 'dbcb3015-e149-442d-820f-65fc193e3bb2', + width: 90, + height: 45, + rotation: -30, + statType: 'kps', + graphType: 'bar', + graphSpeed: 1000, + graphColor: '#86EFAC', + }, + ], + }, + knobPositions: { + [MODE]: [ + { + ...createDefaultKeyPosition(120, 157.5), + id: 'de18be4f-188d-42e1-acce-91bde017056b', + width: 45, + height: 45, + rotation: 10, + axisId: '', + sensitivity: 1, + reverse: true, + }, + ], + }, + spritePositions: { + [MODE]: [ + makeCanonicalSpritePosition({ + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + dx: 123.75, + dy: 243.75, + width: 60, + height: 60, + rotation: 170, + pivot: { x: 0.25, y: 0.75 }, + idleTransform: { rotation: 170, scale: 1, x: 15, y: 0 }, + baseImage: 'sprite.png', + referenceNaturalSize: { source: 'sprite.png', width: 256, height: 256 }, + poses: [ + makeSpritePose({ + poseId: '11111111-1111-4111-8111-111111111111', + triggers: [KEY_IDS[2]], + transform: { rotation: -170, scale: 1.2, x: 30, y: -10 }, + }), + ], + }), + ], + }, + layerGroups: {}, +}); + +const loadHarness = async () => { + const [ + react, + dom, + state, + gesture, + preview, + geometry, + frame, + keys, + selection, + reference, + ] = await Promise.all([ + import('react'), + import('react-dom/client'), + import('@src/renderer/editor/runtime/coordinator/editorStateCoordinator'), + import('@src/renderer/editor/runtime/gesture/editGestureController'), + import('@src/renderer/editor/runtime/gesture/previewOverlay'), + import('@utils/element/selectionRotation'), + import('@hooks/Grid/selection/useSelectionRotationFrame'), + import('@stores/data/useKeyStore'), + import('@stores/grid/useGridSelectionStore'), + import('@stores/grid/useSelectionRotationStore'), + ]); + const [input, boundary, scope, pending] = await Promise.all([ + import('./SelectionRotationInput'), + import('../../selection/EditSessionBoundary'), + import('@contexts/EditSessionScope'), + import('@hooks/pendingOptimisticCommits'), + ]); + return { + ...react, + ...dom, + ...state, + ...gesture, + ...preview, + ...geometry, + ...frame, + ...keys, + ...selection, + ...reference, + ...scope, + ...pending, + EditSessionBoundary: boundary.default, + SelectionRotationInput: input.default, + }; +}; +type Harness = Awaited>; +let harness: Harness; +let host: HTMLDivElement; +let root: Root; +let currentFrame: SelectionRotationFrame | null; +let targets: SelectionRotationTarget[]; +let inputTree: ReactNode; + +const requestAt = async (count: number) => { + await harness.act(async () => { + await vi.waitFor(() => + expect(transport.commit).toHaveBeenCalledTimes(count), + ); + }); + return transport.commit.mock.calls[count - 1][0]; +}; +const enterRotation = async (rotation: number) => { + const input = host.querySelector('input')!; + harness.act(() => input.focus()); + harness.act(() => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, String(rotation)); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await harness.act(async () => { + await vi.waitFor(() => expect(currentFrame?.rotation).toBe(rotation)); + }); + harness.act(() => + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ), + ); +}; + +const previewRotation = (rotation: number) => { + const input = host.querySelector('input')!; + harness.act(() => { + input.focus(); + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, String(rotation)); + input.dispatchEvent(new Event('input', { bubbles: true })); + harness.drainPendingOptimisticCommits(); + }); + return input; +}; + +const applyExternalPositions = async ( + revision: number, + keyPositions: CanonicalEditorDocumentV1['keyPositions'], +) => { + await harness.act(async () => { + transport.emit({ + schemaVersion: 1, + revision, + mutationId: `external-${revision}`, + origin: 'overlay', + changedFields: ['keyPositions'], + patch: { schemaVersion: 1, keyPositions }, + }); + await vi.waitFor(() => + expect(harness.editorCoordinator.getState().revision).toBe(revision), + ); + }); +}; + +describe('혼합 선택 숫자 회전의 네이티브 저장 정산', () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + window.__dmn_window_type = 'main'; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const document = initialDocument(); + targets = FIELDS.flatMap((field, index) => + document[field][MODE].map(({ id }) => ({ type: TYPES[index], id })), + ); + transport.get.mockResolvedValue({ revision: 0, document }); + harness = await loadHarness(); + harness.useKeyStore.setState({ selectedKeyType: MODE }); + harness.useGridSelectionStore.setState({ selectedElements: targets }); + harness.useSelectionRotationStore.setState({ + selectionKey: null, + referenceRotation: 0, + }); + await harness.editorCoordinator.start(); + host = window.document.createElement('div'); + window.document.body.appendChild(host); + root = harness.createRoot(host); + const { createElement, useLayoutEffect } = harness; + const TestInput = () => { + const frame = harness.useSelectionRotationFrame(); + useLayoutEffect(() => { + currentFrame = frame; + }, [frame]); + return createElement(harness.EditSessionScope, { + children: createElement(harness.EditSessionBoundary, { + children: createElement(harness.SelectionRotationInput, { + label: '회전', + }), + }), + }); + }; + inputTree = createElement(TestInput); + harness.act(() => root.render(inputTree)); + }); + afterEach(() => { + harness.act(() => { + root.unmount(); + harness.editGestureController.cancel(); + harness.previewOverlay.clearAll(); + }); + host.remove(); + harness.editorCoordinator.stop(); + vi.restoreAllMocks(); + delete window.__dmn_window_type; + }); + + it.each(['before-ack', 'after-ack'] as const)( + '정확 왕복 이벤트가 %s에 도착해도 45° 뒤 90°를 저장하고 표시한다', + async (order) => { + expect(targets).toHaveLength(13); + expect(currentFrame?.rotation).toBe(0); + await enterRotation(45); + const first = await requestAt(1); + expect(first.changes!.keyPositions![MODE][4].dy).toBe(209.77425768631718); + expect(first.changes!.spritePositions![MODE][0].dx).toBe( + 114.55722993194729, + ); + const event: EditorCommittedV1 = { + schemaVersion: 1, + revision: 1, + mutationId: first.mutationId, + origin: 'main', + gestureId: first.gestureId, + changedFields: [...FIELDS], + patch: JSON.parse(JSON.stringify(first.changes)), + }; + if (order === 'before-ack') + await harness.act(async () => { + transport.emit(event); + }); + await harness.act(async () => { + transport.resolve({ revision: 1, changedFields: [...FIELDS] }); + }); + if (order === 'after-ack') + await harness.act(async () => { + transport.emit(event); + }); + await harness.act(async () => { + await vi.waitFor(() => + expect(harness.editorCoordinator.getState()).toMatchObject({ + revision: 1, + phase: 'idle', + }), + ); + }); + expect(harness.editGestureController.activeGestureId()).toBeNull(); + const acknowledged = harness.editorCoordinator.getState().lastAck!; + const canonicalSnapshot = harness.createSelectionRotationSnapshot( + acknowledged, + targets, + MODE, + )!; + expect(currentFrame!.snapshot.geometrySignature).toBe( + canonicalSnapshot.geometrySignature, + ); + const expected = harness.rotateSelection( + { + ...canonicalSnapshot, + center: { + x: currentFrame!.bounds.x + currentFrame!.bounds.width / 2, + y: currentFrame!.bounds.y + currentFrame!.bounds.height / 2, + }, + }, + 45, + )!; + + await enterRotation(90); + const second = await requestAt(2); + for (const { type, id, patch } of expected) { + const field = FIELDS[TYPES.indexOf(type)]; + expect( + second.changes![field]![MODE].find((position) => position.id === id), + ).toMatchObject(patch); + } + await harness.act(async () => { + transport.resolve({ revision: 2, changedFields: [...FIELDS] }); + }); + await harness.act(async () => { + await vi.waitFor(() => + expect(harness.editorCoordinator.getState()).toMatchObject({ + revision: 2, + phase: 'idle', + }), + ); + }); + expect(currentFrame!.rotation).toBe(90); + expect(host.querySelector('input')!.value).toBe('90°'); + expect( + harness.captureEditorDocument().spritePositions[MODE][0].poses, + ).toEqual(initialDocument().spritePositions[MODE][0].poses); + }, + ); + + it('외부 변경으로 취소된 세션은 첫 거절 뒤 최신 틀로 입력을 재개한다', async () => { + const input = previewRotation(45); + expect(currentFrame?.rotation).toBe(45); + const external = structuredClone(initialDocument().keyPositions); + external[MODE][0].dx += 20; + harness.act(() => harness.editGestureController.cancel()); + await applyExternalPositions(1, external); + + previewRotation(60); + expect(currentFrame?.rotation).toBe(0); + expect(harness.editGestureController.activeGestureId()).toBeNull(); + previewRotation(75); + expect(currentFrame?.rotation).toBe(75); + harness.act(() => input.blur()); + const request = await requestAt(1); + expect(request.baseRevision).toBe(1); + expect(request.changes!.keyPositions![MODE][0].rotation).toBe(75); + await harness.act(async () => { + transport.resolve({ revision: 2, changedFields: [...FIELDS] }); + await vi.waitFor(() => + expect(harness.editorCoordinator.getState()).toMatchObject({ + revision: 2, + phase: 'idle', + }), + ); + }); + expect(input.value).toBe('75°'); + }); + + it('첫 프리뷰가 좌표 한계로 거절된 뒤 같은 선택의 외부 이동을 받아 재시도한다', async () => { + const edge = structuredClone(initialDocument().keyPositions); + edge[MODE][0].dx = 32768; + edge[MODE][0].dy = 32768; + await applyExternalPositions(1, edge); + previewRotation(45); + expect(currentFrame?.rotation).toBe(0); + expect(harness.editGestureController.activeGestureId()).toBeNull(); + expect(transport.commit).not.toHaveBeenCalled(); + + await applyExternalPositions(2, initialDocument().keyPositions); + const input = previewRotation(60); + expect(currentFrame?.rotation).toBe(60); + harness.act(() => input.blur()); + const request = await requestAt(1); + expect(request.baseRevision).toBe(2); + expect(request.changes!.keyPositions![MODE][0].rotation).toBe(60); + await harness.act(async () => { + transport.resolve({ revision: 3, changedFields: [...FIELDS] }); + await vi.waitFor(() => + expect(harness.editorCoordinator.getState()).toMatchObject({ + revision: 3, + phase: 'idle', + }), + ); + }); + }); + + it('선택 변경은 기존 패널 경계에서 draft를 버리고 늦은 blur도 저장하지 않는다', () => { + const input = previewRotation(45); + harness.act(() => + harness.useGridSelectionStore.setState({ + selectedElements: targets.slice(0, 2), + }), + ); + expect(input.isConnected).toBe(false); + harness.act(() => input.blur()); + expect(harness.editGestureController.activeGestureId()).toBeNull(); + expect(transport.commit).not.toHaveBeenCalled(); + expect(harness.captureEditorDocument()).toEqual(initialDocument()); + }); + + it.each(['selection', 'mode', 'panelClose'] as const)( + '%s 전환은 첫 프레임을 기다리던 프리뷰가 입력 수명 뒤 되살아나는 것을 막는다', + (boundary) => { + const input = host.querySelector('input')!; + harness.act(() => { + input.focus(); + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, '45'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(harness.editGestureController.activeGestureId()).toBeNull(); + harness.act(() => { + if (boundary === 'selection') { + harness.useGridSelectionStore.setState({ + selectedElements: targets.slice(0, 2), + }); + } else if (boundary === 'mode') { + harness.useKeyStore.setState({ selectedKeyType: '5key' }); + } else { + root.render(null); + } + }); + expect(input.isConnected).toBe(false); + harness.act(() => { + input.blur(); + harness.drainPendingOptimisticCommits(); + }); + expect(harness.editGestureController.activeGestureId()).toBeNull(); + expect( + harness.composePreviewPositions( + 'keyPosition', + harness.captureEditorDocument().keyPositions, + ), + ).toEqual(initialDocument().keyPositions); + expect(transport.commit).not.toHaveBeenCalled(); + expect(harness.captureEditorDocument()).toEqual(initialDocument()); + }, + ); + + it('StrictMode 수명 재설치 뒤 취소와 새 회전 저장이 모두 동작한다', async () => { + harness.act(() => + root.render(harness.createElement(harness.StrictMode, {}, inputTree)), + ); + const input = previewRotation(45); + expect(currentFrame?.rotation).toBe(45); + harness.act(() => + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + }), + ), + ); + expect(currentFrame?.rotation).toBe(0); + expect(harness.editGestureController.activeGestureId()).toBeNull(); + previewRotation(60); + expect(currentFrame?.rotation).toBe(60); + harness.act(() => input.blur()); + const request = await requestAt(1); + expect(request.changes!.keyPositions![MODE][0].rotation).toBe(60); + await harness.act(async () => { + transport.resolve({ revision: 1, changedFields: [...FIELDS] }); + await vi.waitFor(() => + expect(harness.editorCoordinator.getState()).toMatchObject({ + revision: 1, + phase: 'idle', + }), + ); + }); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/SelectionRotationInput.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/SelectionRotationInput.tsx new file mode 100644 index 000000000..d5690ba25 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/SelectionRotationInput.tsx @@ -0,0 +1,55 @@ +import { useLayoutEffect, useRef } from 'react'; +import { useSelectionRotationFrame } from '@hooks/Grid/selection/useSelectionRotationFrame'; +import { createSelectionRotationGesture } from '../../../handles/selectionRotationGesture'; +import type { CanvasRotationSession } from '../../../handles/CanvasRotateHandle'; +import RotationInputRow from '../../controls/RotationInputRow'; + +interface SelectionRotationInputProps { + label: string; +} + +const SelectionRotationInput = ({ label }: SelectionRotationInputProps) => { + const frame = useSelectionRotationFrame(); + const sessionRef = useRef(null); + const mountedRef = useRef(false); + const cancel = () => { + sessionRef.current?.cancel(); + sessionRef.current = null; + }; + useLayoutEffect(() => { + mountedRef.current = true; + return () => { + // 자식 숫자 입력의 언마운트 정산이 새 프리뷰 세션을 만들지 못하게 한다 + mountedRef.current = false; + sessionRef.current?.cancel(); + sessionRef.current = null; + }; + }, [frame?.selectionKey]); + + if (!frame) return null; + const preview = (value: number) => { + if (!mountedRef.current) return false; + if (!sessionRef.current) { + sessionRef.current = createSelectionRotationGesture(frame); + } + const applied = sessionRef.current?.preview(value) ?? false; + // 실패한 세션은 버려 다음 입력이 최신 틀로 다시 시작하게 한다 + if (!applied) cancel(); + return applied; + }; + return ( + { + if (!preview(value)) return; + sessionRef.current?.commit(value); + sessionRef.current = null; + }} + onCancel={cancel} + /> + ); +}; + +export default SelectionRotationInput; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/usePropertiesPanelBatchGeometry.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/usePropertiesPanelBatchGeometry.ts index 34694aa88..391e26507 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/usePropertiesPanelBatchGeometry.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/usePropertiesPanelBatchGeometry.ts @@ -8,6 +8,7 @@ import { import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; import { computeBatchGeometryPlan } from '@src/renderer/editor/runtime/geometry/batchGeometryPlan'; import { commitMixedBatchGeometry } from '@src/renderer/editor/runtime/geometry/mixedBatchGeometry'; +import { spriteResizePatch } from '@utils/sprite/resizeProjection'; import type { EditorElementTypeV1 } from '@src/types/editor'; import type { KeyPosition } from '@src/types/key/keys'; @@ -20,7 +21,9 @@ type UsePropertiesPanelBatchGeometryProps = Pick< | 'statItemPositions' | 'graphItemPositions' | 'knobItemPositions' - | 'selectedBatchStyleElements' + | 'canonicalSpritePositions' + | 'spriteItemPositions' + | 'selectedBatchGeometryElements' | 'stableBatchGeometryTargets' | 'stablePluginGeometryElements' | 'stablePluginGeometryTargets' @@ -32,14 +35,16 @@ export const usePropertiesPanelBatchGeometry = ({ statItemPositions, graphItemPositions, knobItemPositions, - selectedBatchStyleElements, + canonicalSpritePositions, + spriteItemPositions, + selectedBatchGeometryElements, stableBatchGeometryTargets, stablePluginGeometryElements, stablePluginGeometryTargets, }: UsePropertiesPanelBatchGeometryProps) => useBatchHandlers({ - selectedKeyLikeElements: selectedBatchStyleElements as { - type: 'key' | 'stat' | 'graph' | 'knob'; + selectedKeyLikeElements: selectedBatchGeometryElements as { + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; id: string; index?: number; }[], @@ -48,6 +53,7 @@ export const usePropertiesPanelBatchGeometry = ({ graphPositions: graphItemPositions, selectedKeyType, knobPositions: knobItemPositions, + spritePositions: spriteItemPositions, pluginLayoutElements: stablePluginGeometryElements, onStableGeometryPreview: (operation) => { if (!stableBatchGeometryTargets) return; @@ -56,7 +62,7 @@ export const usePropertiesPanelBatchGeometry = ({ string, { type: EditorElementTypeV1; - position: KeyPosition; + position: Pick; } >(); for (const target of stableBatchGeometryTargets) { @@ -69,7 +75,9 @@ export const usePropertiesPanelBatchGeometry = ({ ? statItemPositions : target.type === 'graph' ? graphItemPositions - : knobItemPositions; + : target.type === 'knob' + ? knobItemPositions + : spriteItemPositions; const position = record[selectedKeyType]?.[locator.index]; if (!position || position.id !== target.id) return; targetsByKey.set(`${target.type}:${target.id}`, { @@ -102,6 +110,27 @@ export const usePropertiesPanelBatchGeometry = ({ operation, ); if (!plan) return; + // 스프라이트는 bounds와 콘텐츠 스케일이 한 몸이라 커밋이 resizeSprite를 낸다. + // 미리보기가 원시 bounds만 얹으면 드래그 중엔 활동 영역만 줄다가 놓는 순간 + // 이미지와 자세가 한꺼번에 축소된다 - 커밋과 같은 투영을 여기서도 쓴다. + // 기준은 canonical: 합성된 spriteItemPositions를 넣으면 이전 프레임의 배율 + // 위에 다시 배율이 얹혀 누적된다 + const spriteBoundsByKey = new Map( + plan.bounds.map(({ key, bounds }) => [key, bounds] as const), + ); + const spritePreviewPatch = ( + key: string, + id: string, + ): Record | null => { + const bounds = spriteBoundsByKey.get(key); + if (!bounds) return null; + const canonical = ( + canonicalSpritePositions[selectedKeyType] ?? [] + ).find((position) => position.id === id); + return canonical + ? (spriteResizePatch(canonical, bounds) as Record) + : null; + }; const byType = new Map< EditorElementTypeV1, Array<{ id: string; patch: Record }> @@ -111,9 +140,14 @@ export const usePropertiesPanelBatchGeometry = ({ const target = targetsByKey.get(update.key); if (!target) return; const entries = byType.get(target.type) ?? []; + // 이동 계열은 배율 1이라 커밋도 setBounds와 같다 - 원시 patch 유지 + const projected = + target.type === 'sprite' && operation.kind === 'resize' + ? spritePreviewPatch(update.key, target.position.id) + : null; entries.push({ id: target.position.id, - patch: update.patch, + patch: projected ?? update.patch, }); byType.set(target.type, entries); } @@ -126,7 +160,9 @@ export const usePropertiesPanelBatchGeometry = ({ ? 'statPosition' : type === 'graph' ? 'graphPosition' - : 'knobPosition', + : type === 'knob' + ? 'knobPosition' + : 'spritePosition', }); } }, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/graph/BatchGraphOnlyPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/graph/BatchGraphOnlyPanel.tsx index 30874d57e..d74f4b86a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/graph/BatchGraphOnlyPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/graph/BatchGraphOnlyPanel.tsx @@ -63,6 +63,8 @@ interface BatchGraphOnlyPanelProps { options?: { gestureId?: string }, ) => void; handleGraphBatchSharedSetting: (updates: Partial) => void; + /** 크기 표시 전용 - resize 대상 집합(스프라이트 포함)에서 파생된다 */ + getMixedValueGeometry: MixedValueGetter; getMixedValueGraphs: MixedValueGetter; getMixedValueGraphsAsKey: MixedValueGetter; getSelectedGraphsData: () => KeyData[]; @@ -99,6 +101,7 @@ const BatchGraphOnlyPanel: React.FC = ({ handleBatchResizePreview, onElementPropertyCommit, handleGraphBatchSharedSetting, + getMixedValueGeometry, getMixedValueGraphs, getMixedValueGraphsAsKey, getSelectedGraphsData, @@ -192,6 +195,7 @@ const BatchGraphOnlyPanel: React.FC = ({ /> } getMixedValue={getMixedValueGraphsAsKey} + getMixedValueSize={getMixedValueGeometry} getSelectedKeysData={getSelectedGraphsData} handleBatchAlign={handleBatchAlign} handleBatchDistribute={handleBatchDistribute} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/style/BatchStyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/style/BatchStyleTabContent.tsx index 883c65214..821803da4 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/style/BatchStyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/style/BatchStyleTabContent.tsx @@ -8,7 +8,7 @@ import { ColorInput, TextInput, PropertySection, -} from '../../controls/PropertyInputs'; +} from '../../index'; import Checkbox from '@components/main/common/checkbox/Checkbox'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; @@ -30,7 +30,10 @@ import { } from '@hooks/pickers/useBatchElementBinding'; import BatchGeometrySection from '../geometry/BatchGeometrySection'; import { editGestureController } from '@src/renderer/editor/runtime/gesture/editGestureController'; -import { AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; +import { + ACTION_BUTTON_CHROME_CLASS, + AXIS_FIELD_WIDTH, +} from '@utils/cardRecipes'; import type { EditorPaintPropertyPatchV1, EditorPreviewStylePropertyPatchV1, @@ -43,6 +46,7 @@ import BatchShadowSection from './BatchShadowSection'; import BatchTypographySection, { type BatchTypographyKeyData, } from './BatchTypographySection'; +import SelectionRotationInput from '../geometry/SelectionRotationInput'; export { BATCH_STYLE_SOUND_PAGE_KEY } from './BatchSoundSection'; @@ -79,6 +83,12 @@ interface BatchStyleTabContentProps { getter: (pos: KeyPosition) => T | undefined, defaultValue: T, ) => { isMixed: boolean; value: T }; + // 크기 전용 - 스타일 필드와 조절 대상이 다르다 (스프라이트는 스타일이 없지만 + // 크기는 조절된다). 표시와 조절이 갈리면 대표값에 가린 채 덮인다 + getMixedValueSize: ( + getter: (pos: KeyPosition) => T | undefined, + defaultValue: T, + ) => { isMixed: boolean; value: T }; // getSelectedKeysData 함수 (displayText Mixed 판단용) getSelectedKeysData: () => BatchTypographyKeyData[]; // 핸들러 @@ -148,6 +158,7 @@ const BatchStyleTabContent: React.FC = ({ imageSuppressesDefaultBorder = true, afterSizeContent, getMixedValue, + getMixedValueSize, getSelectedKeysData, handleBatchAlign, handleBatchDistribute, @@ -261,7 +272,7 @@ const BatchStyleTabContent: React.FC = ({ {/* 크기 */} pos.width, 60).value} + value={getMixedValueSize((pos) => pos.width, 60).value} onChange={(value) => handleBatchResize('width', value)} onPreview={(value) => handleBatchResizePreview('width', value)} onCancel={() => editGestureController.cancel()} @@ -271,10 +282,10 @@ const BatchStyleTabContent: React.FC = ({ max={9999} allowDecimal decimalScale={1} - isMixed={getMixedValue((pos) => pos.width, 60).isMixed} + isMixed={getMixedValueSize((pos) => pos.width, 60).isMixed} /> pos.height, 60).value} + value={getMixedValueSize((pos) => pos.height, 60).value} onChange={(value) => handleBatchResize('height', value)} onPreview={(value) => handleBatchResizePreview('height', value)} onCancel={() => editGestureController.cancel()} @@ -284,9 +295,13 @@ const BatchStyleTabContent: React.FC = ({ max={9999} allowDecimal decimalScale={1} - isMixed={getMixedValue((pos) => pos.height, 60).isMixed} + isMixed={getMixedValueSize((pos) => pos.height, 60).isMixed} /> + + {afterSizeContent ? ( @@ -551,7 +566,7 @@ const BatchStyleTabContent: React.FC = ({ + ))} + + ) : null, +})); + import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; @@ -191,6 +226,7 @@ describe('LayerTab rename input contract', () => { useStatItemStore.setState({ positions: {} }); useGraphItemStore.setState({ positions: {} }); useKnobItemStore.setState({ positions: {} }); + useSpriteStore.setState({ positions: {} }); useLayerGroupStore.setState({ layerGroups: { '4key': [{ id: GROUP_ID, name: 'Group A' }] }, collapsedGroups: new Set(), @@ -205,6 +241,7 @@ describe('LayerTab rename input contract', () => { afterEach(async () => { if (root) await act(async () => root.unmount()); host?.remove(); + mocks.actions = undefined; vi.clearAllMocks(); vi.unstubAllGlobals(); }); @@ -336,3 +373,145 @@ describe('LayerTab rename input contract', () => { }, ); }); + +describe('레이어 이름 입력의 취소와 저장 경계', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(async () => { + mocks.actions = undefined; + mocks.renameLayer.mockClear(); + mocks.renameGroup.mockClear(); + const positions = { + '4key': [ + { + id: ITEM_ID, + dx: 0, + dy: 0, + width: 60, + height: 60, + groupId: GROUP_ID, + layerName: 'Key A', + }, + ], + }; + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: { '4key': ['A'] }, + positions: positions as never, + canonicalPositions: positions as never, + }); + useStatItemStore.setState({ positions: {} }); + useGraphItemStore.setState({ positions: {} }); + useKnobItemStore.setState({ positions: {} }); + useSpriteStore.setState({ positions: {} }); + useLayerGroupStore.setState({ + layerGroups: { '4key': [{ id: GROUP_ID, name: 'Group A' }] }, + collapsedGroups: new Set(), + }); + usePluginDisplayElementStore.setState({ + elements: [ + { + id: 'item', + fullId: 'plugin:item', + pluginId: 'plugin', + definitionId: 'Plugin A', + html: '', + position: { x: 0, y: 0 }, + }, + ], + }); + useGridSelectionStore.setState({ + selectedElements: [], + selectedGroupIds: [], + }); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => root.render()); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + const openMenu = async (name: string) => { + const row = [...host.querySelectorAll('.dmn-row-grabbable')].find( + (element) => element.textContent === name, + ); + expect(row).toBeDefined(); + await act(async () => { + row!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + }); + return document.querySelector('[data-testid="layer-menu"]')!; + }; + + const startRename = async (type: 'layer' | 'group') => { + const menu = await openMenu(type === 'layer' ? 'Key A' : 'Group A'); + const label = + type === 'layer' ? 'contextMenu.rename' : 'contextMenu.renameGroup'; + const button = [...menu.querySelectorAll('button')].find( + (element) => element.textContent === label, + ); + expect(button).toBeDefined(); + await act(async () => button!.click()); + const input = host.querySelector('input')!; + expect(input).not.toBeNull(); + await act(async () => input.focus()); + return input; + }; + + const typeName = async (input: HTMLInputElement, value: string) => { + await act(async () => { + setNativeValue(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + const pressKey = async (input: HTMLInputElement, key: string) => { + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + }); + }; + + it.each(['layer', 'group'] as const)( + '%s 이름을 Escape로 취소한 뒤 새 이름을 Enter로 저장한다', + async (type) => { + const cancelled = await startRename(type); + await typeName(cancelled, 'Cancelled'); + const blur = vi.fn(); + cancelled.addEventListener('blur', blur); + await pressKey(cancelled, 'Escape'); + + expect(host.querySelector('input')).toBeNull(); + expect(blur).not.toHaveBeenCalled(); + expect(mocks.renameLayer).not.toHaveBeenCalled(); + expect(mocks.renameGroup).not.toHaveBeenCalled(); + + const input = await startRename(type); + await typeName(input, 'Saved'); + await pressKey(input, 'Enter'); + + if (type === 'layer') { + expect(mocks.renameLayer).toHaveBeenCalledExactlyOnceWith( + 'key', + ITEM_ID, + 'Saved', + ); + } else { + expect(mocks.renameGroup).toHaveBeenCalledExactlyOnceWith( + '4key', + GROUP_ID, + 'Saved', + ); + } + }, + ); + + it('인스턴스 이름 저장을 지원하지 않는 플러그인에는 이름 변경을 제공하지 않는다', async () => { + const menu = await openMenu('Plugin A'); + expect(menu.textContent).not.toContain('contextMenu.rename'); + expect(menu.textContent).toContain('propertiesPanel.delete'); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index 933b7e3bf..a80b027b2 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -8,6 +8,7 @@ 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 { useSpriteStore } from '@stores/data/useSpriteStore'; import { selectPropertyPanelPluginElements, usePluginDisplayElementStore, @@ -30,6 +31,7 @@ import { PluginIcon, StatIcon, GraphIcon, + SpriteIcon, } from './LayerIcons'; import { useLayerActions } from './useLayerActions'; import { useLayerDnD } from './useLayerDnD'; @@ -148,6 +150,7 @@ const LayerTabContent: React.FC = ({ const statPositions = useStatItemStore((state) => state.positions); const graphPositions = useGraphItemStore((state) => state.positions); const knobPositions = useKnobItemStore((state) => state.positions); + const spritePositions = useSpriteStore((state) => state.positions); const pluginElements = usePluginDisplayElementStore( selectPropertyPanelPluginElements, ); @@ -214,6 +217,7 @@ const LayerTabContent: React.FC = ({ statPositions, graphPositions, knobPositions, + spritePositions, pluginElements, layerGroupsForMode, }); @@ -289,6 +293,7 @@ const LayerTabContent: React.FC = ({ statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, // 패널 창의 elements는 항상 비어 있으므로 창별 미러 셀렉터를 경유한다 pluginElements: selectPropertyPanelPluginElements( usePluginDisplayElementStore.getState(), @@ -646,6 +651,8 @@ const LayerTabContent: React.FC = ({ ) : item.type === 'knob' ? ( + ) : item.type === 'sprite' ? ( + ) : ( )} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerActionMutations.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerActionMutations.ts index cf924afa8..c305e4720 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerActionMutations.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerActionMutations.ts @@ -3,6 +3,7 @@ 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 { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import type { LayerGroups } from '@src/types/layerGroups'; @@ -41,7 +42,8 @@ export const stableSelectionGroupId = ( element.type === 'key' || element.type === 'stat' || element.type === 'graph' || - element.type === 'knob', + element.type === 'knob' || + element.type === 'sprite', ); const plugin = elements.filter((element) => element.type === 'plugin'); if ( @@ -66,7 +68,9 @@ export const stableSelectionGroupId = ( ? useStatItemStore.getState().positions : element.type === 'graph' ? useGraphItemStore.getState().positions - : useKnobItemStore.getState().positions; + : element.type === 'knob' + ? useKnobItemStore.getState().positions + : useSpriteStore.getState().positions; const groupId = record[mode]?.[locator.index]?.groupId; if (groupId) groupIds.add(groupId); } @@ -224,7 +228,8 @@ export const createLayerActionMutations = ({ element.type === 'key' || element.type === 'stat' || element.type === 'graph' || - element.type === 'knob' + element.type === 'knob' || + element.type === 'sprite' ? [{ elementType: element.type, id: element.id }] : [], ); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.test.ts index 34836ce36..42c263f8c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.test.ts @@ -24,6 +24,7 @@ describe('layer panel plugin mode scope', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, pluginElements: [ plugin('same', '4key'), plugin('global'), @@ -43,6 +44,7 @@ describe('layer panel plugin mode scope', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, pluginElements: [ plugin('grouped', '4key', 'group-a'), plugin('dangling', '4key', 'group-missing'), diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts index 0df5eed7f..80b1638b1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts @@ -22,6 +22,7 @@ interface BuildLayerItemsParams { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; pluginElements: PluginPanelElementView[]; layerGroupsForMode: LayerGroupDef[]; } @@ -33,6 +34,7 @@ export function buildLayerItems({ statPositions, graphPositions, knobPositions, + spritePositions, pluginElements, layerGroupsForMode, }: BuildLayerItemsParams): LayerItem[] { @@ -113,6 +115,20 @@ export function buildLayerItems({ }); }); + // 스프라이트 아이템 + const currentSpritePositions = spritePositions[selectedKeyType] || []; + currentSpritePositions.forEach((pos, index) => { + items.push({ + type: 'sprite', + id: pos.id, + index, + name: pos.layerName || `Sprite ${index + 1}`, + zIndex: pos.zIndex ?? index, + hidden: !!pos.hidden, + groupId: pos.groupId ?? undefined, + }); + }); + // 플러그인 아이템 - 그룹은 모드 스코프라 현재 모드에 def가 있는 groupId만 // 노출 (dangling group_id의 유령 헤더 방지) const validGroupIds = new Set(layerGroupsForMode.map((group) => group.id)); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.test.ts index 229644d07..9e2d66a16 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.test.ts @@ -48,6 +48,7 @@ const documentWith = ( statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: groups.length > 0 ? { '4key': groups } : {}, } as never); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.ts index 3b73b21e0..7a995a8b9 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.ts @@ -1,6 +1,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; @@ -33,7 +34,7 @@ import type { import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import type { DisplayItem, LayerItem } from '../types'; -type NativeType = 'key' | 'stat' | 'graph' | 'knob'; +type NativeType = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; export interface DropAnchors { toDisplayIndex: number; @@ -274,6 +275,7 @@ const modelFrom = ( document.statPositions, document.graphPositions, document.knobPositions, + document.spritePositions, [...pluginElements], ).map( (item): LayerItem => ({ @@ -602,6 +604,7 @@ const localDocument = (): CanonicalEditorDocumentV1 => ({ statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx index 370ebdc46..05d690d33 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx @@ -175,6 +175,24 @@ describe('useLayerActions visibility routing', () => { }); }; + it('미선택 스프라이트 행 우클릭은 그 행을 선택한다', async () => { + const item: LayerItem = { + type: 'sprite', + id: STABLE_ID, + index: 0, + name: '스프라이트 1', + zIndex: 0, + hidden: false, + }; + + await act(async () => actions.handleContextMenu(click, item, 0)); + + // 종류 분기 누락 시 clearSelection만 남아 Delete가 조용히 무시된다 + expect(useGridSelectionStore.getState().selectedElements).toEqual([ + { type: 'sprite', id: STABLE_ID, index: 0 }, + ]); + }); + it('stable native는 index가 틀려도 ID와 literal hidden으로 semantic op를 호출한다', async () => { const item: LayerItem = { type: 'key', @@ -243,6 +261,21 @@ describe('useLayerActions visibility routing', () => { expect(mocks.reportSkipped).not.toHaveBeenCalled(); }); + it('플러그인 이름 변경은 직접 메뉴 액션으로도 시작하지 않는다', async () => { + const item: LayerItem = { + type: 'plugin', + id: 'plugin-a:one', + name: 'plugin', + zIndex: 0, + hidden: false, + }; + await act(async () => actions.handleContextMenu(click, item, 0)); + await act(async () => actions.handleContextMenuSelect('rename')); + + expect(actions.renamingItemId).toBeNull(); + expect(mocks.patchLayerName).not.toHaveBeenCalled(); + }); + it('main stable group은 allHidden에서 계산한 absolute literal만 semantic helper에 넘긴다', async () => { await exposeWithItems([ { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerContextMenuRuntime.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerContextMenuRuntime.ts index f7adf225f..c23f97bf3 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerContextMenuRuntime.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerContextMenuRuntime.ts @@ -14,6 +14,7 @@ import { createLayerActionMutations, stableSelectionGroupId, } from './layerActionMutations'; +import { layerItemToSelectedElement } from './layerSelectionIntent'; export interface UseLayerContextMenuRuntimeParams { selectedKeyType: string; @@ -84,9 +85,11 @@ export const useLayerContextMenuRuntime = ({ } const selectedElements = useGridSelectionStore.getState().selectedElements; - const items: ListItem[] = [ - { id: 'rename', label: t('contextMenu.rename') || 'Rename' }, - ]; + // 플러그인 인스턴스는 이름 저장 경로가 없다 - 항목 자체를 내지 않는다 + const items: ListItem[] = + contextMenuItem?.type === 'plugin' + ? [] + : [{ id: 'rename', label: t('contextMenu.rename') || 'Rename' }]; if (selectedElements.length >= 2 && !contextMenuItem?.groupId) { items.push({ @@ -127,17 +130,7 @@ export const useLayerContextMenuRuntime = ({ const { clearSelection, toggleSelection } = useGridSelectionStore.getState(); clearSelection(); - if (item.type === 'key' && item.index !== undefined) { - toggleSelection({ type: 'key', id: item.id, index: item.index }); - } else if (item.type === 'stat' && item.index !== undefined) { - toggleSelection({ type: 'stat', id: item.id, index: item.index }); - } else if (item.type === 'graph' && item.index !== undefined) { - toggleSelection({ type: 'graph', id: item.id, index: item.index }); - } else if (item.type === 'knob' && item.index !== undefined) { - toggleSelection({ type: 'knob', id: item.id, index: item.index }); - } else if (item.type === 'plugin') { - toggleSelection({ type: 'plugin', id: item.id }); - } + toggleSelection(layerItemToSelectedElement(item)); setLastClickedIndex(index); const displayIdx = displayItemsRef.current.findIndex( (di) => di.displayType === 'layer' && di.item.id === item.id, @@ -171,6 +164,8 @@ export const useLayerContextMenuRuntime = ({ const groupDef = layerGroupsForMode.find( (g) => g.id === contextMenuGroupId, ); + // Escape로 언마운트된 입력은 blur가 오지 않아 취소 플래그가 남는다 + renameCancelledRef.current = false; setRenamingItemId(`group:${contextMenuGroupId}`); setRenameValue(groupDef?.name || ''); setContextMenuOpen(false); @@ -208,7 +203,8 @@ export const useLayerContextMenuRuntime = ({ } if (itemId === 'rename') { - if (contextMenuItem) { + if (contextMenuItem && contextMenuItem.type !== 'plugin') { + renameCancelledRef.current = false; setRenamingItemId(contextMenuItem.id); setRenameValue(contextMenuItem.name); setContextMenuOpen(false); @@ -286,9 +282,11 @@ export const useLayerContextMenuRuntime = ({ if (itemId === 'delete') { const selectedElements = useGridSelectionStore.getState().selectedElements; - if (selectedElements.length === 0) return; - onSelectionFromPanel?.(); - await deleteFrozenSelection(selectedElements); + // 빈 선택이어도 메뉴는 닫는다 + if (selectedElements.length > 0) { + onSelectionFromPanel?.(); + await deleteFrozenSelection(selectedElements); + } } setContextMenuOpen(false); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/navigation/usePropertiesPanelRename.ts b/src/renderer/components/main/Grid/PropertiesPanel/navigation/usePropertiesPanelRename.ts index 8daa22593..c20f22616 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/navigation/usePropertiesPanelRename.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/navigation/usePropertiesPanelRename.ts @@ -19,6 +19,7 @@ interface UsePropertiesPanelRenameOptions { singleStatPosition: PropertiesPanelSelection['singleStatPosition']; singleGraphPosition: PropertiesPanelSelection['singleGraphPosition']; singleKnobPosition: PropertiesPanelSelection['singleKnobPosition']; + singleSpritePosition: PropertiesPanelSelection['singleSpritePosition']; singleKeyInfo: PropertiesPanelSelection['singleKeyInfo']; singleKeyCode: PropertiesPanelSelection['singleKeyCode']; setPanelMode: (mode: 'layer' | 'property') => void; @@ -32,6 +33,7 @@ export const usePropertiesPanelRename = ({ singleStatPosition, singleGraphPosition, singleKnobPosition, + singleSpritePosition, singleKeyInfo, singleKeyCode, setPanelMode, @@ -50,6 +52,7 @@ export const usePropertiesPanelRename = ({ if (singleStatPosition) return singleStatPosition.layerName || ''; if (singleGraphPosition) return singleGraphPosition.layerName || ''; if (singleKnobPosition) return singleKnobPosition.layerName || ''; + if (singleSpritePosition) return singleSpritePosition.layerName || ''; return ''; }; @@ -65,6 +68,7 @@ export const usePropertiesPanelRename = ({ return `${getStatTypeLabel(singleGraphPosition.statType ?? null)} Graph`; } if (singleKnobPosition) return 'Knob'; + if (singleSpritePosition) return 'Sprite'; return ''; }; @@ -89,6 +93,8 @@ export const usePropertiesPanelRename = ({ const handleRenameStartImpl = useRef<() => void>(() => {}); // eslint-disable-next-line react-hooks/refs -- 안정 래퍼의 최신 이름 변경 콜백 유지 handleRenameStartImpl.current = () => { + // Escape로 언마운트된 입력은 blur가 오지 않아 취소 플래그가 남는다 - 다음 입력의 커밋이 삼켜지지 않게 초기화 + renameCancelledRef.current = false; const current = getCurrentLayerName(); setRenameValue(current || getCurrentDefaultTitle()); setIsRenaming(true); @@ -151,7 +157,8 @@ export const usePropertiesPanelRename = ({ singleKeyPosition || singleStatPosition || singleGraphPosition || - singleKnobPosition + singleKnobPosition || + singleSpritePosition ) { setPanelMode('property'); handleRenameStart(); @@ -164,6 +171,7 @@ export const usePropertiesPanelRename = ({ singleStatPosition, singleGraphPosition, singleKnobPosition, + singleSpritePosition, setPanelMode, ]); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/navigation/usePropertiesPanelVisibility.ts b/src/renderer/components/main/Grid/PropertiesPanel/navigation/usePropertiesPanelVisibility.ts index af1714665..b84f1d99b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/navigation/usePropertiesPanelVisibility.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/navigation/usePropertiesPanelVisibility.ts @@ -13,7 +13,7 @@ interface UsePropertiesPanelVisibilityOptions { selectedElements: readonly unknown[]; selectedKeyElementsLength: number; selectedKeyLikeElementsLength: number; - selectedBatchStyleElementsLength: number; + selectedBatchGeometryElementsLength: number; selectedPluginElementsLength: number; pluginSettingsPanel: object | null; closePage: () => void; @@ -34,7 +34,7 @@ export const usePropertiesPanelVisibility = ({ selectedElements, selectedKeyElementsLength, selectedKeyLikeElementsLength, - selectedBatchStyleElementsLength, + selectedBatchGeometryElementsLength, selectedPluginElementsLength, pluginSettingsPanel, closePage, @@ -142,7 +142,7 @@ export const usePropertiesPanelVisibility = ({ useEffect(() => { if ( - selectedBatchStyleElementsLength + selectedPluginElementsLength > 1 && + selectedBatchGeometryElementsLength + selectedPluginElementsLength > 1 && !isPanelVisible && !manuallyClosedRef.current ) { @@ -150,7 +150,7 @@ export const usePropertiesPanelVisibility = ({ setIsPanelVisible(true); } }, [ - selectedBatchStyleElementsLength, + selectedBatchGeometryElementsLength, selectedPluginElementsLength, isPanelVisible, setIsPanelVisible, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsForm.tsx b/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsForm.tsx index 2a4b3142b..5aea1251a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsForm.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/plugin/PluginSettingsForm.tsx @@ -139,7 +139,7 @@ const PluginSettingsForm = ({ : 0; const stepString = schemaValue.step != null ? String(schemaValue.step) : ''; - const dotIndex = stepString.indexOf('../index'); + const dotIndex = stepString.indexOf('.'); const hasDecimal = dotIndex !== -1; const decimalScale = hasDecimal ? stepString.length - dotIndex - 1 : 0; control = ( diff --git a/src/renderer/components/main/Grid/PropertiesPanel/plugin/usePluginSettingsPanelController.ts b/src/renderer/components/main/Grid/PropertiesPanel/plugin/usePluginSettingsPanelController.ts index 4cfd3d46f..22736f1ff 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/plugin/usePluginSettingsPanelController.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/plugin/usePluginSettingsPanelController.ts @@ -1,7 +1,13 @@ import { useEffect, useRef, useState } from 'react'; import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; -export const usePluginSettingsPanelController = () => { +interface UsePluginSettingsPanelControllerOptions { + t: (key: string) => string; +} + +export const usePluginSettingsPanelController = ({ + t, +}: UsePluginSettingsPanelControllerOptions) => { const pluginSettingsPanel = usePropertiesPanelStore( (state) => state.pluginSettingsPanel, ); @@ -43,12 +49,21 @@ export const usePluginSettingsPanelController = () => { ); pluginSettingsPanel.resolve(true); } catch (error) { + // settle은 세션이 1회 소유한다 - 여기서는 안내만 console.error('[Plugin Settings] Failed to apply settings:', error); - pluginSettingsPanel.resolve(false); + void window.api.ui.dialog + .alert(t('common.saveFailed'), { confirmText: t('common.ok') }) + .catch(() => {}); } finally { pluginSettingsSavingRef.current = false; setIsPluginSettingsSaving(false); - closePluginSettingsPanel(); + // 세션이 이미 뷰를 닫았거나 다른 세션이 올라왔으면 남의 패널을 닫지 않는다 + if ( + usePropertiesPanelStore.getState().pluginSettingsPanel === + pluginSettingsPanel + ) { + closePluginSettingsPanel(); + } } }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/selection/previewPatchForwarders.ts b/src/renderer/components/main/Grid/PropertiesPanel/selection/previewPatchForwarders.ts index 5386bdd8a..937aee377 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/selection/previewPatchForwarders.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/selection/previewPatchForwarders.ts @@ -120,6 +120,8 @@ export const previewSingleStyleProperty = ( id: string, patch: EditorStylePropertyPreviewPatchV1, ): void => { + // 스프라이트는 스타일 프리뷰 대상이 아니다 + if (type === 'sprite') return; const locator = resolveElementById(type, id); if (!locator) return; if (isShadowPreviewPatch(patch)) { @@ -261,10 +263,12 @@ export const previewBatchPaint = ( }; export const previewSinglePaint = ( - type: PreviewTargetType, + type: EditorElementTypeV1, id: string, patch: EditorPaintPropertyPatchV1, ): void => { + // 스프라이트는 페인트 프리뷰 대상이 아니다 + if (type === 'sprite') return; const locator = resolveElementById(type, id); if (!locator) return; previewBatchPaint([{ elementType: type, id }], locator.mode, patch); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/selection/selectionPanelRoute.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/selection/selectionPanelRoute.test.ts index b7e69bf81..cbc2e418f 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/selection/selectionPanelRoute.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/selection/selectionPanelRoute.test.ts @@ -11,11 +11,13 @@ const input = ( keyLikeCount: 0, graphCount: 0, knobCount: 0, + spriteCount: 0, pluginCount: 0, hasSingleKeyPosition: false, hasSingleStatPosition: false, hasSingleGraphPosition: false, hasSingleKnobPosition: false, + hasSingleSpritePosition: false, ...overrides, }); @@ -159,3 +161,63 @@ describe('selectionPanelRoute 혼합 선택(네이티브+플러그인)', () => { ).toEqual({ kind: 'batchKnobOnly' }); }); }); + +describe('selectionPanelRoute 스프라이트 선택', () => { + it('단일 스프라이트는 singleSprite', () => { + expect( + resolveSelectionPanelRoute( + input({ spriteCount: 1, hasSingleSpritePosition: true }), + ), + ).toEqual({ kind: 'singleSprite' }); + }); + + it('위치 미해결 단일 스프라이트는 none', () => { + expect(resolveSelectionPanelRoute(input({ spriteCount: 1 }))).toEqual({ + kind: 'none', + }); + }); + + it('스프라이트만 다중이면 경량 기하 배치', () => { + expect(resolveSelectionPanelRoute(input({ spriteCount: 2 }))).toEqual({ + kind: 'pluginBatch', + }); + }); + + it('스프라이트+플러그인은 경량 기하 배치', () => { + expect( + resolveSelectionPanelRoute( + input({ + spriteCount: 1, + hasSingleSpritePosition: true, + pluginCount: 1, + }), + ), + ).toEqual({ kind: 'pluginBatch' }); + }); + + it('스프라이트+키는 batchKeyLike', () => { + expect( + resolveSelectionPanelRoute(input({ spriteCount: 1, keyLikeCount: 1 })), + ).toEqual({ kind: 'batchKeyLike' }); + }); + + it('스프라이트+노브는 batchKnobOnly', () => { + expect( + resolveSelectionPanelRoute(input({ spriteCount: 1, knobCount: 1 })), + ).toEqual({ kind: 'batchKnobOnly' }); + }); + + it('스프라이트+그래프는 batchGraphOnly', () => { + expect( + resolveSelectionPanelRoute(input({ spriteCount: 1, graphCount: 1 })), + ).toEqual({ kind: 'batchGraphOnly' }); + }); + + it('스프라이트가 섞여도 단일 노브 라우트로 새지 않는다', () => { + expect( + resolveSelectionPanelRoute( + input({ spriteCount: 1, knobCount: 1, hasSingleKnobPosition: true }), + ), + ).toEqual({ kind: 'batchKnobOnly' }); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/selection/selectionPanelRoute.ts b/src/renderer/components/main/Grid/PropertiesPanel/selection/selectionPanelRoute.ts index 04afd4d59..8b5fd983c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/selection/selectionPanelRoute.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/selection/selectionPanelRoute.ts @@ -11,17 +11,20 @@ export type SelectionPanelRouteKind = | 'singleKnob' | 'singleGraph' | 'singleKeyStat' + | 'singleSprite' | 'none'; export interface SelectionPanelRouteInput { keyLikeCount: number; graphCount: number; knobCount: number; + spriteCount: number; pluginCount: number; hasSingleKeyPosition: boolean; hasSingleStatPosition: boolean; hasSingleGraphPosition: boolean; hasSingleKnobPosition: boolean; + hasSingleSpritePosition: boolean; } export interface SelectionPanelRoute { @@ -35,13 +38,15 @@ export const resolveSelectionPanelRoute = ( keyLikeCount, graphCount, knobCount, + spriteCount, pluginCount, hasSingleKeyPosition, hasSingleStatPosition, hasSingleGraphPosition, hasSingleKnobPosition, + hasSingleSpritePosition, } = input; - const nativeCount = keyLikeCount + graphCount + knobCount; + const nativeCount = keyLikeCount + graphCount + knobCount + spriteCount; // native 없음 - 플러그인 단독 경로 (단일 편집 또는 경량 기하 배치) if (nativeCount === 0) { @@ -57,7 +62,21 @@ export const resolveSelectionPanelRoute = ( return { kind: 'batchKeyLike' }; } if (knobCount > 0) return { kind: 'batchKnobOnly' }; - return { kind: 'batchGraphOnly' }; + if (graphCount > 0) return { kind: 'batchGraphOnly' }; + // 스프라이트 단독 다중(또는 스프라이트+플러그인)은 전용 스타일 배치가 없어 + // 정렬·분배·간격만 있는 경량 기하 배치로 + return { kind: 'pluginBatch' }; + } + + // 단일 스프라이트 + if ( + spriteCount === 1 && + hasSingleSpritePosition && + keyLikeCount === 0 && + graphCount === 0 && + knobCount === 0 + ) { + return { kind: 'singleSprite' }; } // 단일 노브 diff --git a/src/renderer/components/main/Grid/PropertiesPanel/selection/singleSelectionHandlers.ts b/src/renderer/components/main/Grid/PropertiesPanel/selection/singleSelectionHandlers.ts index a3e3636a7..a1bccd7e6 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/selection/singleSelectionHandlers.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/selection/singleSelectionHandlers.ts @@ -77,7 +77,8 @@ const stableGeometryPreviewHandler = ( type: EditorElementTypeV1, id: string | undefined, ) => - id && isNativeElementId(id) + // 스프라이트 geometry는 전용 패널 경로만 쓴다 - knob 도메인으로 새지 않게 차단 + id && isNativeElementId(id) && type !== 'sprite' ? (field: GeometryField, value: number) => { const locator = resolveElementById(type, id); if (!locator) return; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/selection/usePropertiesPanelSelection.ts b/src/renderer/components/main/Grid/PropertiesPanel/selection/usePropertiesPanelSelection.ts index 22cc9c545..1a4527424 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/selection/usePropertiesPanelSelection.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/selection/usePropertiesPanelSelection.ts @@ -3,6 +3,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { @@ -15,7 +16,10 @@ import { getPreviewOverlayVersion, subscribePreviewOverlay, } from '@src/renderer/editor/runtime/gesture/previewOverlay'; -import type { EditorElementTypeV1 } from '@src/types/editor'; +import type { + CanonicalReactiveSpritePosition, + EditorElementTypeV1, +} from '@src/types/editor'; import type { BatchGeometryTarget } from '@src/renderer/editor/runtime/operations/elementOps'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; @@ -42,6 +46,7 @@ export const usePropertiesPanelSelection = () => { const canonicalKnobItemPositions = useKnobItemStore( (state) => state.positions, ); + const canonicalSpritePositions = useSpriteStore((state) => state.positions); useSyncExternalStore( subscribePreviewOverlay, getPreviewOverlayVersion, @@ -60,6 +65,10 @@ export const usePropertiesPanelSelection = () => { 'knobPosition', canonicalKnobItemPositions, ); + const spriteItemPositions = composePreviewPositions( + 'spritePosition', + canonicalSpritePositions, + ); const pluginElements = usePluginDisplayElementStore( selectPropertyPanelPluginElements, ); @@ -79,6 +88,9 @@ export const usePropertiesPanelSelection = () => { const selectedKnobElements = selectedElements.filter( (element) => element.type === 'knob', ); + const selectedSpriteElements = selectedElements.filter( + (element) => element.type === 'sprite', + ); const selectedKeyLikeElements = selectedElements.filter( (element) => element.type === 'key' || element.type === 'stat', ); @@ -89,6 +101,16 @@ export const usePropertiesPanelSelection = () => { element.type === 'graph' || element.type === 'knob', ); + // 기하 대상은 스타일 대상과 별개 - sprite는 배치 스타일이 없지만 + // 정렬·분배·간격·이동에는 참여한다 + const selectedBatchGeometryElements = selectedElements.filter( + (element) => + element.type === 'key' || + element.type === 'stat' || + element.type === 'graph' || + element.type === 'knob' || + element.type === 'sprite', + ); const selectedPluginElements = selectedElements.filter( (element) => element.type === 'plugin', ); @@ -104,10 +126,10 @@ export const usePropertiesPanelSelection = () => { // invalid native ID 또는 모드에서 사라진 plugin이 섞이면 fail-closed const stableBatchGeometryTargets: BatchGeometryTarget[] | null = - selectedBatchStyleElements.every( + selectedBatchGeometryElements.every( (element) => element.id.length > 0 && isNativeElementId(element.id), ) - ? selectedBatchStyleElements.map((element) => ({ + ? selectedBatchGeometryElements.map((element) => ({ type: element.type as EditorElementTypeV1, id: element.id, })) @@ -227,6 +249,14 @@ export const usePropertiesPanelSelection = () => { singleKnobIndex >= 0 ? knobItemPositions[selectedKeyType]?.[singleKnobIndex] ?? null : null; + const singleSpriteId = + selectedSpriteElements.length === 1 ? selectedSpriteElements[0].id : null; + const singleSpritePosition: CanonicalReactiveSpritePosition | null = + singleSpriteId + ? (spriteItemPositions[selectedKeyType] ?? []).find( + (position) => position.id === singleSpriteId, + ) ?? null + : null; const allLayerGroups = useLayerGroupStore((state) => state.layerGroups); const layerGroupsForMode = allLayerGroups[selectedKeyType] || []; @@ -237,6 +267,7 @@ export const usePropertiesPanelSelection = () => { const statModePositions = statItemPositions[selectedKeyType] || []; const graphModePositions = graphItemPositions[selectedKeyType] || []; const knobModePositions = knobItemPositions[selectedKeyType] || []; + const spriteModePositions = spriteItemPositions[selectedKeyType] || []; const modeGroupIds = new Set(layerGroupsForMode.map((group) => group.id)); let groupId: string | undefined; @@ -258,6 +289,10 @@ export const usePropertiesPanelSelection = () => { currentGroupId = knobModePositions.find( (position) => position.id === element.id, )?.groupId; + } else if (element.type === 'sprite') { + currentGroupId = + spriteModePositions.find((position) => position.id === element.id) + ?.groupId ?? undefined; } else if (element.type === 'plugin') { const pluginGroupId = pluginElements.find( (candidate) => candidate.fullId === element.id, @@ -285,6 +320,8 @@ export const usePropertiesPanelSelection = () => { .length + knobModePositions.filter((position) => position?.groupId === groupId) .length + + spriteModePositions.filter((position) => position?.groupId === groupId) + .length + pluginElements.filter( (element) => isPluginVisibleInMode(element, selectedKeyType) && @@ -314,13 +351,17 @@ export const usePropertiesPanelSelection = () => { statItemPositions, graphItemPositions, knobItemPositions, + canonicalSpritePositions, + spriteItemPositions, pluginElements, selectedKeyElements, selectedStatElements, selectedGraphElements, selectedKnobElements, + selectedSpriteElements, selectedKeyLikeElements, selectedBatchStyleElements, + selectedBatchGeometryElements, selectedPluginElements, selectedPluginElement, stableBatchGeometryTargets, @@ -343,6 +384,7 @@ export const usePropertiesPanelSelection = () => { singleGraphIndex, singleGraphPosition, singleKnobPosition, + singleSpritePosition, selectedGroupInfo, }; }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx index 136d29990..f320f9490 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/CounterTabContent.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-hooks/set-state-in-effect */ import React, { useEffect, useRef, useState } from 'react'; +import { ACTION_BUTTON_CHROME_CLASS } from '@utils/cardRecipes'; import { createPortal } from 'react-dom'; import type { CounterTabContentProps } from '../types'; import type { KeyCounterAnimationSettings } from '@src/types/key/keys'; @@ -424,7 +425,7 @@ const CounterTabContent: React.FC = ({ + + ); + })} + {displayPoses.length < SPRITE_CONSTRAINTS.maxPoses ? ( + addPose(event.currentTarget)} + /> + ) : null} + + + {/* 전환·반응 */} + + + + commitFields({ activation: value as SpriteActivation }) + } + align="right" + /> + + {position.activation === 'onPress' ? ( + + + commitFields({ + // Rust u32 계약 - 스크럽 소수값을 정수로 고정 + pressDurationMs: Math.round( + clamp( + value, + pressDurationMs.min, + pressDurationMs.max, + ), + ), + }) + } + onPreview={(value) => + previewFields({ + pressDurationMs: Math.round( + clamp( + value, + pressDurationMs.min, + pressDurationMs.max, + ), + ), + }) + } + onCancel={() => editGestureController.cancel()} + suffix="ms" + min={pressDurationMs.min} + max={pressDurationMs.max} + /> + + ) : ( + + + commitFields({ + // Rust u32 계약 - 스크럽 소수값을 정수로 고정 + transitionMs: Math.round( + clamp(value, transitionMs.min, transitionMs.max), + ), + }) + } + onPreview={(value) => + previewFields({ + transitionMs: Math.round( + clamp(value, transitionMs.min, transitionMs.max), + ), + }) + } + onCancel={() => editGestureController.cancel()} + suffix="ms" + min={transitionMs.min} + max={transitionMs.max} + /> + + )} + + + + commitFields({ transitionEasing: value }) + } + align="right" + /> + + + + + + + {/* 상태 편집 팝업 */} + + {editingPose ? ( + + togglePoseTrigger(editingPoseIndex, keyId), + onImagePick: () => void handlePoseImageSelect(editingPose.poseId), + // 이미지를 지우면 그 이미지에 매인 크기도 함께 지운다 + onImageReset: () => + replacePose(editingPoseIndex, { + imageOverride: null, + imageOverrideMetrics: null, + }), + }} + // 콜백 부재는 접두 스크럽 자체를 꺼 버린다 - 항상 넘기고 안에서 분기 + onTransformCommit={handleTransformCommit} + onTransformPreview={handleTransformPreview} + onTransformCancel={handleTransformCancel} + onPivotCommit={handlePosePivotCommit} + onPivotPreview={handlePosePivotPreview} + onPivotLinkChange={handlePosePivotLinkChange} + onClose={() => setEditorTarget(null)} + t={t} + /> + ) : null} + + + {/* 행 ⋮ 메뉴 - 퇴장 모션 동안 renderKey가 유지된다 */} + {poseMenu.renderKey !== null && ( + + )} + + ); +}; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSurfaceSection.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSurfaceSection.tsx index 76542a6a5..03a31964b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSurfaceSection.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSurfaceSection.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { ACTION_BUTTON_CHROME_CLASS } from '@utils/cardRecipes'; import type { KeyPosition } from '@src/types/key/keys'; import type { GradientSpec } from '@src/types/color'; import type { @@ -161,7 +162,7 @@ const SingleSurfaceSection = ({ + ) : null} + + ); +}; + +export default SpriteImagePreviewCard; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.history.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.history.test.tsx new file mode 100644 index 000000000..9ea2a843d --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.history.test.tsx @@ -0,0 +1,137 @@ +import React, { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; +import { drainPendingOptimisticCommits } from '@hooks/pendingOptimisticCommits'; +import SpritePoseEditorPopup from './SpritePoseEditorPopup'; +import { + acquireHistoryEditorFlushLock, + resetHistoryEditorFlushLock, +} from '@src/renderer/editor/runtime/lifecycle/historyEditorFlushLock'; + +vi.mock('@components/main/Grid/PropertiesPanel/controls/PickerSurface', () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock( + '@components/main/Grid/PropertiesPanel/controls/PropertyInputs', + () => ({ + PropertySection: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }), +); +vi.mock('@components/main/common/checkbox/Checkbox', () => ({ + default: () => null, +})); +vi.mock('@components/main/common/dropdown/Dropdown', () => ({ + default: () => null, +})); +vi.mock('./SpriteImagePreviewCard', () => ({ default: () => null })); + +describe('자세 팝업에서 숫자 스크럽의 history 취소 전달', () => { + let host: HTMLDivElement; + let root: Root; + const onTransformPreview = vi.fn(); + const onTransformCommit = vi.fn(); + const onTransformCancel = vi.fn(); + const onPivotPreview = vi.fn(); + const onPivotCommit = vi.fn(); + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + act(() => + root.render( + key} + />, + ), + ); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + resetHistoryEditorFlushLock(); + }); + + it.each( + [ + 'propertiesPanel.spriteScale', + 'propertiesPanel.spriteStatePivot X', + ].flatMap((ariaLabel) => + ['applied', 'locked'].map((boundary) => ({ ariaLabel, boundary })), + ), + )( + '$ariaLabel, $boundary 취소 사유를 자세 프리뷰 소유자에게 전달한다', + ({ ariaLabel, boundary }) => { + const input = host.querySelector( + `input[aria-label="${ariaLabel}"]`, + )!; + const handle = input.closest('label')!.querySelector('span')!; + const pointer = (type: string, clientX: number) => + handle.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + pointerId: 1, + clientX, + }), + ); + act(() => { + pointer('pointerdown', 0); + pointer('pointermove', 5); + drainPendingOptimisticCommits(); + }); + expect( + onTransformPreview.mock.calls.length + onPivotPreview.mock.calls.length, + ).toBe(1); + onTransformPreview.mockClear(); + onPivotPreview.mockClear(); + act(() => { + if (boundary === 'applied') + useCommittedApplyStore.getState().bump('historyUndo'); + else acquireHistoryEditorFlushLock('popup-release'); + pointer('pointerup', 5); + drainPendingOptimisticCommits(); + }); + expect(onTransformCancel).toHaveBeenCalledExactlyOnceWith('history'); + expect(onTransformPreview).not.toHaveBeenCalled(); + expect(onPivotPreview).not.toHaveBeenCalled(); + expect(onTransformCommit).not.toHaveBeenCalled(); + expect(onPivotCommit).not.toHaveBeenCalled(); + if (boundary === 'locked') { + act(() => useCommittedApplyStore.getState().bump('historyUndo')); + expect(onTransformCancel).toHaveBeenCalledOnce(); + } + }, + ); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx new file mode 100644 index 000000000..8e384e2ad --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx @@ -0,0 +1,319 @@ +import React from 'react'; +import PickerSurface from '@components/main/Grid/PropertiesPanel/controls/PickerSurface'; +import { PropertySection } from '@components/main/Grid/PropertiesPanel/controls/PropertyInputs'; +import Dropdown from '@components/main/common/dropdown/Dropdown'; +import Checkbox from '@components/main/common/checkbox/Checkbox'; +import { NumberInput } from '@components/main/common/numberInput/NumberInput'; +import { + AngleGlyph, + ScaleGlyph, +} from '@components/main/common/TransformGlyphs'; +import { + SPRITE_CONSTRAINTS, + type SpriteAnchor, + type SpriteTransform, +} from '@src/types/key/sprites'; +import { FORM_LABEL_CLASS, FORM_ROW_CLASS } from '@utils/cardRecipes'; +import { clamp } from '@utils/number/clamp'; +import SpriteImagePreviewCard from './SpriteImagePreviewCard'; + +// 담당 키·이미지 오버라이드 컨트롤 묶음 (삭제·이름 변경은 행 메뉴가 맡는다) +interface SpritePoseControls { + keyOptions: ReadonlyArray<{ id: string; label: string }>; + triggers: readonly string[]; + isDuplicate: boolean; + imageOverride: string | null; + onToggleTrigger: (keyId: string) => void; + onImagePick: () => void; + onImageReset: () => void; +} + +interface SpritePoseEditorPopupProps { + open: boolean; + ariaLabel: string; + // 편집 세션 신원 - 내부 subtree 재마운트와 앵커 재측정의 기준 + poseId: string; + transform: SpriteTransform; + pivot: SpriteAnchor; + followsBasePivot: boolean; + referenceRef: React.RefObject; + panelElement: HTMLElement | null; + poseControls: SpritePoseControls; + // 행 전환 시 바깥닫힘을 거치지 않는 영역 (상태 목록 well) + interactiveRefs?: React.RefObject[]; + onTransformCommit: (patch: Partial) => void; + onTransformPreview: (next: SpriteTransform) => void; + onTransformCancel: () => void; + onPivotCommit: (patch: Partial) => void; + onPivotPreview: (next: SpriteAnchor) => void; + onPivotLinkChange: () => void; + onClose: () => void; + t: (key: string) => string; +} + +// 상태 하나의 편집 팝업 - 상태 이미지·변환 수치·담당 키. 위치·회전·배율은 캔버스 +// 핸들(본체 드래그·모서리 바깥 회전·모서리 배율)과 같은 값이라 여기서는 수치 입력만 맡는다. +// 셸(PickerSurface)은 행 전환 동안 유지하고 편집 subtree만 poseId로 재마운트해 +// 입력 draft·포커스는 대상별로 끊고 전환 자체는 이어지게 한다 +const SpritePoseEditorPopup: React.FC = ({ + open, + ariaLabel, + poseId, + transform, + pivot, + followsBasePivot, + referenceRef, + panelElement, + poseControls, + interactiveRefs, + onTransformCommit, + onTransformPreview, + onTransformCancel, + onPivotCommit, + onPivotPreview, + onPivotLinkChange, + onClose, + t, +}) => { + const { offset, rotation, scale, anchor } = SPRITE_CONSTRAINTS; + // 커밋은 바뀐 축만 올린다 - 전체를 펼쳐 보내면 저장 큐에 대기 중인 직전 편집을 덮는다 + const commitField = (patch: Partial) => + onTransformCommit(patch); + const previewField = (patch: Partial) => + onTransformPreview({ ...transform, ...patch }); + const commitPivotField = (patch: Partial) => + onPivotCommit(patch); + const previewPivotField = (patch: Partial) => + onPivotPreview({ ...pivot, ...patch }); + + const keyOptionIds = new Set( + poseControls.keyOptions.map((option) => option.id), + ); + const deadTriggers = poseControls.triggers.filter( + (id) => !keyOptionIds.has(id), + ); + + return ( + + {/* poseId 경계 - 행 전환마다 편집 subtree만 새 세션으로 */} + + {/* 상태 이미지 - 비우면 캔버스가 기본 이미지를 그대로 쓴다 */} + + + {/* 위치·회전·배율 - 라벨 대신 접두 글리프가 의미를 맡는다 */} +
+
+ + commitField({ x: clamp(value, offset.min, offset.max) }) + } + onPreview={(value) => + previewField({ x: clamp(value, offset.min, offset.max) }) + } + onCancel={onTransformCancel} + prefix="X" + ariaLabel={`${t('propertiesPanel.position') || '위치'} X`} + width="100%" + min={offset.min} + max={offset.max} + allowDecimal + decimalScale={1} + /> + + commitField({ y: clamp(value, offset.min, offset.max) }) + } + onPreview={(value) => + previewField({ y: clamp(value, offset.min, offset.max) }) + } + onCancel={onTransformCancel} + prefix="Y" + ariaLabel={`${t('propertiesPanel.position') || '위치'} Y`} + width="100%" + min={offset.min} + max={offset.max} + allowDecimal + decimalScale={1} + /> +
+
+ + commitField({ + rotation: clamp(value, rotation.min, rotation.max), + }) + } + onPreview={(value) => + previewField({ + rotation: clamp(value, rotation.min, rotation.max), + }) + } + onCancel={onTransformCancel} + prefix={} + ariaLabel={t('propertiesPanel.spriteRotation') || '회전'} + suffix="°" + width="100%" + min={rotation.min} + max={rotation.max} + allowDecimal + decimalScale={1} + /> + {/* 배율은 이미지 피커와 같은 정수 % 표기 - 저장은 배수 그대로 */} + + commitField({ scale: clamp(value / 100, scale.min, scale.max) }) + } + onPreview={(value) => + previewField({ + scale: clamp(value / 100, scale.min, scale.max), + }) + } + onCancel={onTransformCancel} + prefix={} + ariaLabel={t('propertiesPanel.spriteScale') || '배율'} + suffix="%" + width="100%" + min={scale.min * 100} + max={scale.max * 100} + /> +
+
+ + {/* 기준점 - 기본을 그대로 쓰거나 이 상태만의 축을 둔다. 토글은 이미지 피커의 + 설정 카드와 같은 행 문법, X·Y 쌍은 라벨을 옆에 둘 폭이 없어 한 행을 통째로 쓴다 */} + +
+

+ {t('propertiesPanel.spriteFollowBasePivot') || '기본 기준점 사용'} +

+ +
+ {!followsBasePivot ? ( +
+ + commitPivotField({ + x: clamp(value / 100, anchor.min, anchor.max), + }) + } + onPreview={(value) => + previewPivotField({ + x: clamp(value / 100, anchor.min, anchor.max), + }) + } + onCancel={onTransformCancel} + prefix="X" + ariaLabel={`${ + t('propertiesPanel.spriteStatePivot') || '상태 기준점' + } X`} + suffix="%" + width="100%" + min={anchor.min * 100} + max={anchor.max * 100} + allowDecimal + decimalScale={1} + /> + + commitPivotField({ + y: clamp(value / 100, anchor.min, anchor.max), + }) + } + onPreview={(value) => + previewPivotField({ + y: clamp(value / 100, anchor.min, anchor.max), + }) + } + onCancel={onTransformCancel} + prefix="Y" + ariaLabel={`${ + t('propertiesPanel.spriteStatePivot') || '상태 기준점' + } Y`} + suffix="%" + width="100%" + min={anchor.min * 100} + max={anchor.max * 100} + allowDecimal + decimalScale={1} + /> +
+ ) : null} +
+ + {/* 담당 키 - 미리보기·변환 아래가 정위치, 신규 상태도 카드가 작아 한눈에 보인다 */} + {poseControls.keyOptions.length === 0 && deadTriggers.length === 0 ? ( +

+ {t('propertiesPanel.spriteNoKeys') || + '이 모드에 키 요소가 없습니다'} +

+ ) : ( + ({ + label: option.label, + value: option.id, + })), + // 죽은 참조도 항목으로 노출해 토글로 제거 + ...deadTriggers.map((id) => ({ + label: t('propertiesPanel.spriteMissingKey') || '삭제된 키', + value: id, + danger: true, + })), + ]} + multiple + values={[...poseControls.triggers]} + onChange={poseControls.onToggleTrigger} + ariaLabel={t('propertiesPanel.spriteTriggerKeys') || '담당 키'} + placeholder={ + t('propertiesPanel.spriteTriggerPlaceholder') || '키 선택' + } + fullWidth + // 중복 조합은 배너 대신 트리거 자체가 짧은 라벨 + 위험 톤 + 사유 툴팁 + danger={poseControls.isDuplicate} + dangerLabel={t('propertiesPanel.spriteDuplicateShort') || '중복 키'} + title={ + poseControls.isDuplicate + ? t('propertiesPanel.spriteDuplicateTriggers') || + '중복된 키 조합입니다' + : undefined + } + /> + )} +
+
+ ); +}; + +export default SpritePoseEditorPopup; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx index 074a91fdf..07848f869 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx @@ -200,6 +200,16 @@ const StyleTabContent: React.FC = ({ onLocalHeightChange={onLocalHeightChange} onGeometryPreview={onGeometryPreview} onGeometryCommit={onGeometryCommit} + onRotationPreview={ + onStylePropertyPreview + ? (value) => onStylePropertyPreview({ property: 'rotation', value }) + : undefined + } + onRotationCommit={ + onStylePropertyCommit + ? (value) => onStylePropertyCommit({ property: 'rotation', value }) + : undefined + } t={t} /> diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/spriteCompilerGuard.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/single/spriteCompilerGuard.test.ts new file mode 100644 index 000000000..9ea00f194 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/spriteCompilerGuard.test.ts @@ -0,0 +1,41 @@ +/** + * React Compiler 회귀 가드 + * 컴포넌트 안의 try/catch나 react-hooks eslint-disable 한 줄이면 그 컴포넌트가 + * 통째로 최적화에서 빠지는데 빌드는 조용히 성공한다 - 실제 플러그인으로 컴파일해 확인한다 + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { transformSync } from '@babel/core'; +import { describe, expect, it } from 'vitest'; + +const compile = (relativePath: string): string => { + const filename = resolve(process.cwd(), relativePath); + const result = transformSync(readFileSync(filename, 'utf8'), { + filename, + configFile: false, + babelrc: false, + parserOpts: { plugins: ['jsx', 'typescript'] }, + plugins: [['babel-plugin-react-compiler', {}]], + }); + return result?.code ?? ''; +}; + +const COMPILED = [ + 'src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx', + 'src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx', +]; + +describe('스프라이트 편집 컴파일러 가드', () => { + it.each(COMPILED)('%s는 컴파일 대상으로 남는다', (path) => { + expect(compile(path)).toContain('react/compiler-runtime'); + }); + + it("SpriteCanvasHandles는 'use no memo'로 제외를 명시한다", () => { + const path = + 'src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx'; + expect(readFileSync(resolve(process.cwd(), path), 'utf8')).toMatch( + /^'use no memo';/, + ); + expect(compile(path)).not.toContain('react/compiler-runtime'); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/types.ts b/src/renderer/components/main/Grid/PropertiesPanel/types.ts index ea9c8dbdc..9ec527058 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/types.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/types.ts @@ -244,7 +244,7 @@ export interface KeyData { // ============================================================================ export interface LayerItem { - type: 'key' | 'stat' | 'graph' | 'knob' | 'plugin'; + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite' | 'plugin'; id: string; index?: number; name: string; diff --git a/src/renderer/components/main/Grid/core/DuplicateElementGhost.test.tsx b/src/renderer/components/main/Grid/core/DuplicateElementGhost.test.tsx index f3fe46cfd..8ac5a38b2 100644 --- a/src/renderer/components/main/Grid/core/DuplicateElementGhost.test.tsx +++ b/src/renderer/components/main/Grid/core/DuplicateElementGhost.test.tsx @@ -1,11 +1,23 @@ // @vitest-environment jsdom import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DuplicateState } from '@hooks/Grid/contextMenu/useGridCanvasActions'; import DuplicateElementGhost from './DuplicateElementGhost'; +// 스프라이트 고스트는 전용 컴포넌트 계약(SpriteDuplicateGhost.test)이 따로 잡는다 +vi.mock('../layers/SpriteDuplicateGhost', async () => { + const { createElement } = await import('react'); + return { + default: (props: Record) => + createElement('div', { + 'data-sprite-ghost': String(props.zoom), + 'data-cursor': JSON.stringify(props.cursor), + }), + }; +}); + const duplicate = ( value: Partial & Pick, ): DuplicateState => @@ -37,11 +49,23 @@ describe('DuplicateElementGhost', () => { ) => { act(() => { root.render( - , + , ); }); }; + it('스프라이트 복제는 전용 고스트에 커서와 배율을 넘긴다', () => { + renderGhost(duplicate({ elementType: 'sprite' }), { x: 12, y: 34 }); + + const ghost = host.querySelector('[data-sprite-ghost]') as HTMLElement; + expect(ghost.getAttribute('data-sprite-ghost')).toBe('2'); + expect(ghost.getAttribute('data-cursor')).toBe('{"x":12,"y":34}'); + }); + it('복제 상태나 커서가 없으면 고스트를 그리지 않는다', () => { renderGhost(null, { x: 10, y: 20 }); expect(host.childElementCount).toBe(0); diff --git a/src/renderer/components/main/Grid/core/DuplicateElementGhost.tsx b/src/renderer/components/main/Grid/core/DuplicateElementGhost.tsx index 22a0cd06d..02aaf21fc 100644 --- a/src/renderer/components/main/Grid/core/DuplicateElementGhost.tsx +++ b/src/renderer/components/main/Grid/core/DuplicateElementGhost.tsx @@ -19,10 +19,14 @@ import { elementShadowToCss, resolveElementShadow, } from '@src/types/key/shadows'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; +import DuplicateGhostHost from '../layers/DuplicateGhostHost'; +import SpriteDuplicateGhost from '../layers/SpriteDuplicateGhost'; interface DuplicateElementGhostProps { duplicate: DuplicateState | null; cursor: { x: number; y: number } | null; + zoom: number; } const GhostBorderRing = ({ suppressDefault }: { suppressDefault: boolean }) => { @@ -43,50 +47,57 @@ const GhostBorderRing = ({ suppressDefault }: { suppressDefault: boolean }) => { const DuplicateElementGhost = ({ duplicate, cursor, + zoom, }: DuplicateElementGhostProps) => { if (!duplicate || !cursor) return null; + if (duplicate.elementType === 'sprite') { + return ( + + ); + } + if (duplicate.elementType === 'graph') { - const width = duplicate.position?.width || 200; - const height = duplicate.position?.height || 100; - const offsetX = cursor.x - width / 2; - const offsetY = cursor.y - height / 2; return ( -
-
+ ); } + // sprite는 위에서 분기 완료 - 남은 타입은 키형 공통 필드를 공유한다 const { - position: { - width = 60, - height = 60, - inactiveImage, - activeImage, - imageFit, - idleImageFit, - imageMode, - idleImageTransform, - className, - shadow, - activeShadow, - }, - keyName, - } = duplicate; + width = 60, + height = 60, + inactiveImage, + activeImage, + imageFit, + idleImageFit, + imageMode, + idleImageTransform, + className, + shadow, + activeShadow, + } = duplicate.position as Exclude< + DuplicateState['position'], + ReactiveSpritePosition + >; + const { keyName } = duplicate; const previewImage = resolveImageSource(inactiveImage) || resolveImageSource(activeImage) || ''; const ghostImageReplaces = @@ -104,23 +115,20 @@ const DuplicateElementGhost = ({ suppressDefault: ghostImageReplaces, }), ); - const offsetX = cursor.x - width / 2; - const offsetY = cursor.y - height / 2; return ( -
@@ -155,7 +163,7 @@ const DuplicateElementGhost = ({ {keyName || ''}
)} - + ); }; diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index ca35a15b1..f528c5a10 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -20,7 +20,10 @@ import { reportElementOpError, reportElementOpSkipped, } from '@src/renderer/editor/runtime/intent/elementIntent'; -import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; +import { + resolveElementById, + type NativeElementType, +} from '@src/renderer/editor/model/elementIdMap'; import { isNativeElementId } from '@src/renderer/editor/model/elementId'; import TabCssModal from '../../Modal/content/editors/TabCssModal'; import TabNoteSettingModal from '../../Modal/content/editors/TabNoteSettingModal'; @@ -29,6 +32,7 @@ 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 { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { PluginElementsRenderer } from '@components/shared/plugin/PluginElementsRenderer'; @@ -44,6 +48,7 @@ import GridBackground from './GridBackground'; import SmartGuidesOverlay from '../overlays/SmartGuidesOverlay'; import MarqueeSelectionOverlay from '../overlays/MarqueeSelectionOverlay'; import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; +import { useSpritePoseHandleStore } from '@stores/grid/useSpritePoseHandleStore'; import { getGridViewportLayerStyles } from '@utils/grid/gridViewportStyles'; import KeyCounterPreviewLayer from '../layers/KeyCounterPreviewLayer'; import StatCounterLayer from '../layers/StatCounterLayer'; @@ -94,7 +99,7 @@ import { useSelectedElementDragLifecycle } from '@hooks/Grid/drag/useSelectedEle type ToolbarAddRequest = { id: number; - type: 'key' | 'stat' | 'graph' | 'knob'; + type: NativeElementType; } | null; interface GridProps { @@ -188,6 +193,7 @@ const Grid = ({ getStatMenuItems, getGraphMenuItems, getKnobMenuItems, + getSpriteMenuItems, getGridMenuItems, pluginKeyMenuItems, pluginGridMenuItems, @@ -205,6 +211,10 @@ const Grid = ({ const hasGradientEditSession = useGradientEditStore( (state) => state.session !== null, ); + // 스프라이트 자세 편집 중 여부 - 자세 프레임이 리사이즈 핸들을 대신한다 + const hasSpritePoseSession = useSpritePoseHandleStore( + (state) => state.session !== null, + ); const selectedElements = useGridSelectionStore( (state) => state.selectedElements, ); @@ -233,6 +243,7 @@ const Grid = ({ const canonicalStatPositions = useStatItemStore((state) => state.positions); const canonicalGraphPositions = useGraphItemStore((state) => state.positions); const canonicalKnobPositions = useKnobItemStore((state) => state.positions); + const canonicalSpritePositions = useSpriteStore((state) => state.positions); useSyncExternalStore( subscribePreviewOverlay, getPreviewOverlayVersion, @@ -250,6 +261,10 @@ const Grid = ({ 'knobPosition', canonicalKnobPositions, ); + const spritePositions = composePreviewPositions( + 'spritePosition', + canonicalSpritePositions, + ); // 선택 관련 로직 훅 사용 const { @@ -268,9 +283,12 @@ const Grid = ({ const { beginSelectedElementsDrag: beginSelectedPluginInstancesDrag, commitSelectedElementsDrag, + moveSelectedElementsDrag, } = useSelectedElementDragLifecycle({ freezeSelectionForGesture, syncSelectedElementsToOverlay, + moveSelectedElements: (deltaX, deltaY) => + moveSelectedElements(deltaX, deltaY, undefined, false), }); // 마퀴 선택 훅 사용 @@ -280,6 +298,7 @@ const Grid = ({ statPositions, graphPositions, knobPositions, + spritePositions, selectedKeyType, pluginElements, clientToGridCoords, @@ -379,6 +398,8 @@ const Grid = ({ useGraphItemStore.getState().positions[selectedKeyType] || [], knobPositions: useKnobItemStore.getState().positions[selectedKeyType] || [], + spritePositions: + useSpriteStore.getState().positions[selectedKeyType] || [], pluginElements: usePluginDisplayElementStore.getState().elements, modeGroups: useLayerGroupStore.getState().layerGroups[selectedKeyType] || [], @@ -428,6 +449,13 @@ const Grid = ({ setDuplicateCursor(null); }; + const beginDuplicateSprite = (sourceIndex: number) => { + const result = canvasActions.beginDuplicateSprite(sourceIndex); + if (!result) return; + setDuplicateState(result); + setDuplicateCursor(null); + }; + const beginDuplicateKey = (sourceIndex: number) => { const sourceSlot = useKeyStore.getState().keyMappings[selectedKeyType]?.[sourceIndex]; @@ -493,7 +521,7 @@ const Grid = ({ mode: selectedKeyType, targets: orderStableZTargetsForBatch( selectedElements.map((element) => ({ - type: element.type as 'key' | 'stat' | 'graph' | 'knob' | 'plugin', + type: element.type as NativeElementType | 'plugin', id: element.id, })), ), @@ -518,7 +546,7 @@ const Grid = ({ mode: selectedKeyType, targets: orderStableZTargetsForBatch( selectedElements.map((element) => ({ - type: element.type as 'key' | 'stat' | 'graph' | 'knob' | 'plugin', + type: element.type as NativeElementType | 'plugin', id: element.id, })), ), @@ -557,6 +585,8 @@ const Grid = ({ const defaultSize = toolbarAddRequest.type === 'graph' ? { width: 120, height: 60 } + : toolbarAddRequest.type === 'sprite' + ? { width: 200, height: 200 } : { width: 60, height: 60 }; const targetPos = getViewportCenterSnappedPosition( defaultSize.width, @@ -596,10 +626,16 @@ const Grid = ({ ); const previousSelectedKeyTypeRef = useRef(selectedKeyType); - // 탭 변경 시 선택 해제 + // 탭 변경 시 선택 해제. 대기 중인 스프라이트 복제는 함께 취소한다 - 원본 탭 + // 요소를 다른 탭에 놓으면 자세 트리거가 원본 탭 키를 가리켜 죽는다. 다른 + // 종류는 탭을 넘어 배치할 수 있으므로 그대로 둔다. + // 탭 바는 그리드 밖이라 배치 취소용 onMouseDownCapture도 타지 않는다 useEffect(() => { if (previousSelectedKeyTypeRef.current !== selectedKeyType) { clearSelection(); + setDuplicateState((prev) => + prev?.elementType === 'sprite' ? null : prev, + ); previousSelectedKeyTypeRef.current = selectedKeyType; } }, [selectedKeyType, clearSelection]); @@ -625,10 +661,7 @@ const Grid = ({ }, []); // 요소 클릭 시 그룹 멤버 자동 선택 - const selectElementWithGroup = ( - type: 'key' | 'stat' | 'graph' | 'knob', - index: number, - ) => { + const selectElementWithGroup = (type: NativeElementType, index: number) => { if (isContextOpen) { setIsContextOpen(false); setContextPosition(null); @@ -638,10 +671,11 @@ const Grid = ({ stat: useStatItemStore.getState().positions[selectedKeyType] || [], graph: useGraphItemStore.getState().positions[selectedKeyType] || [], knob: useKnobItemStore.getState().positions[selectedKeyType] || [], + sprite: useSpriteStore.getState().positions[selectedKeyType] || [], } as const; const clicked = collections[type][index]; - // stat·graph·knob은 렌더 클로저가 아니라 live 스토어를 읽는다. 렌더와 + // stat·graph·knob·sprite는 렌더 클로저가 아니라 live 스토어를 읽는다. 렌더와 // 클릭 사이에 배열이 줄면 대상이 없을 수 있어 fail-closed로 닫는다 if (!clicked) { reportElementOpSkipped('group selection (target missing)'); @@ -656,6 +690,7 @@ const Grid = ({ statPositions: collections.stat, graphPositions: collections.graph, knobPositions: collections.knob, + spritePositions: collections.sprite, pluginElements: usePluginDisplayElementStore.getState().elements, modeGroups: useLayerGroupStore.getState().layerGroups[selectedKeyType] || [], @@ -666,7 +701,7 @@ const Grid = ({ }; const toggleNativeElementSelection = ( - type: 'key' | 'stat' | 'graph' | 'knob', + type: NativeElementType, index: number, ) => { const elementId = @@ -676,16 +711,15 @@ const Grid = ({ ? useStatItemStore.getState().positions[selectedKeyType][index].id : type === 'graph' ? useGraphItemStore.getState().positions[selectedKeyType][index].id + : type === 'sprite' + ? useSpriteStore.getState().positions[selectedKeyType][index].id : useKnobItemStore.getState().positions[selectedKeyType][index].id; toggleSelection({ type, id: elementId, index }); }; // 더블클릭 편집 진입 — 대상이 다중 선택의 멤버면 선택을 보존해 배치 편집으로, // 아니면 해당 요소(+그룹)만 선택해 단일 편집으로 property 페이지를 연다 - const openElementEditor = ( - type: 'key' | 'stat' | 'graph' | 'knob', - index: number, - ) => { + const openElementEditor = (type: NativeElementType, index: number) => { const { selectedElements: currentSelection } = useGridSelectionStore.getState(); const positionsForType = @@ -695,6 +729,8 @@ const Grid = ({ ? statPositions[selectedKeyType] : type === 'graph' ? graphPositions[selectedKeyType] + : type === 'sprite' + ? spritePositions[selectedKeyType] : knobPositions[selectedKeyType]; const targetId = positionsForType?.[index]?.id; const isMultiMember = @@ -709,7 +745,7 @@ const Grid = ({ // 요소 컨텍스트 메뉴 열기 const openElementContextMenu = ( - type: 'key' | 'stat' | 'graph' | 'knob', + type: NativeElementType, index: number, clientX: number, clientY: number, @@ -726,6 +762,8 @@ const Grid = ({ ? useStatItemStore.getState().positions[selectedKeyType]?.[index] : type === 'graph' ? useGraphItemStore.getState().positions[selectedKeyType]?.[index] + : type === 'sprite' + ? useSpriteStore.getState().positions[selectedKeyType]?.[index] : useKnobItemStore.getState().positions[selectedKeyType]?.[index]; // 조회가 옵셔널인데 여기서 무방비로 풀면, 렌더와 우클릭 사이에 배열이 줄었을 때 // TypeError가 나고 에러 바운더리가 없어 앱이 통째로 언마운트된다. @@ -860,6 +898,7 @@ const Grid = ({ statPositions={statPositions?.[selectedKeyType] || []} graphPositions={graphPositions?.[selectedKeyType] || []} knobPositions={knobPositions?.[selectedKeyType] || []} + spritePositions={spritePositions?.[selectedKeyType] || []} pluginElements={pluginElements} selectedElements={selectedElements} activeTool={activeTool} @@ -874,9 +913,7 @@ const Grid = ({ onClearSelection={clearSelection} onSetSelectedElements={setSelectedElements} onSetLastSelectedKeyBounds={setLastSelectedKeyBounds} - onMoveSelection={(deltaX, deltaY) => - moveSelectedElements(deltaX, deltaY, undefined, false) - } + onMoveSelection={moveSelectedElementsDrag} onMultiDragStart={beginSelectedPluginInstancesDrag} onMultiDragEnd={commitSelectedElementsDrag} onOpenElementEditor={openElementEditor} @@ -897,6 +934,7 @@ const Grid = ({ - moveSelectedElements(deltaX, deltaY, undefined, false) - } + onMultiDrag={moveSelectedElementsDrag} onMultiDragStart={beginSelectedPluginInstancesDrag} onMultiDragEnd={commitSelectedElementsDrag} /> @@ -942,12 +978,14 @@ const Grid = ({ statPositions={statPositions} graphPositions={graphPositions} knobPositions={knobPositions} + spritePositions={spritePositions} mode={selectedKeyType} pluginElements={pluginElements} zoom={zoom} panX={panX} panY={panY} hasGradientEditSession={hasGradientEditSession} + hasSpritePoseSession={hasSpritePoseSession} previewBounds={previewBounds} previewGroupBounds={previewGroupBounds} previewElementBounds={previewElementBounds} @@ -978,6 +1016,8 @@ const Grid = ({ ? getGraphMenuItems(contextIndex) : contextType === 'knob' ? getKnobMenuItems(contextIndex) + : contextType === 'sprite' + ? getSpriteMenuItems(contextIndex) : getKeyMenuItems(contextIndex, contextElementId) } onSelect={async (id: string) => { @@ -1013,7 +1053,7 @@ const Grid = ({ // 메뉴가 열린 동안의 재정렬·삭제를 액션 시점에 재해석. // 모드 밖으로 이동한 대상은 소실로 취급한다 const resolveContextTarget = ( - targetType: 'key' | 'stat' | 'graph' | 'knob', + targetType: NativeElementType, ): number | null => { if (!contextElementId) return null; const locator = resolveElementById(targetType, contextElementId); @@ -1025,7 +1065,8 @@ const Grid = ({ if ( contextType === 'stat' || contextType === 'graph' || - contextType === 'knob' + contextType === 'knob' || + contextType === 'sprite' ) { const targetType = contextType; const targetIndex = resolveContextTarget(targetType); @@ -1039,6 +1080,8 @@ const Grid = ({ if (targetType === 'stat') beginDuplicateStat(resolvedIndex); else if (targetType === 'graph') beginDuplicateGraph(resolvedIndex); + else if (targetType === 'sprite') + beginDuplicateSprite(resolvedIndex); else beginDuplicateKnob(resolvedIndex); }, }); @@ -1234,6 +1277,7 @@ const Grid = ({ statPositions={statPositions?.[selectedKeyType] || []} graphPositions={graphPositions?.[selectedKeyType] || []} knobPositions={knobPositions?.[selectedKeyType] || []} + spritePositions={spritePositions?.[selectedKeyType] || []} zoom={zoom} panX={panX} panY={panY} diff --git a/src/renderer/components/main/Grid/core/GridMinimap.tsx b/src/renderer/components/main/Grid/core/GridMinimap.tsx index 8f20e325a..13e2815e2 100644 --- a/src/renderer/components/main/Grid/core/GridMinimap.tsx +++ b/src/renderer/components/main/Grid/core/GridMinimap.tsx @@ -31,6 +31,7 @@ interface GridMinimapProps { statPositions?: Position[]; graphPositions?: Position[]; knobPositions?: Position[]; + spritePositions?: Position[]; zoom: number; panX: number; panY: number; @@ -87,6 +88,7 @@ const GridMinimap = ({ statPositions = [], graphPositions = [], knobPositions = [], + spritePositions = [], zoom, panX, panY, @@ -197,6 +199,7 @@ const GridMinimap = ({ statPositions.length === 0 && graphPositions.length === 0 && knobPositions.length === 0 && + spritePositions.length === 0 && pluginPositions.length === 0 ) { return { minX: 0, minY: 0, maxX: 100, maxY: 100 }; @@ -263,6 +266,20 @@ const GridMinimap = ({ maxY = Math.max(maxY, y + h); }); + // 스프라이트 요소 bounds + spritePositions.forEach((pos) => { + if (pos.hidden) return; + const x = pos.dx || 0; + const y = pos.dy || 0; + const w = pos.width || 200; + const h = pos.height || 200; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + w); + maxY = Math.max(maxY, y + h); + }); + // 플러그인 요소들의 바운딩 박스 계산 pluginPositions.forEach((pos) => { minX = Math.min(minX, pos.x); @@ -404,6 +421,7 @@ const GridMinimap = ({ statPositions.length === 0 && graphPositions.length === 0 && knobPositions.length === 0 && + spritePositions.length === 0 && pluginPositions.length === 0 ) { return null; @@ -572,6 +590,11 @@ const GridMinimap = ({ })} {/* 노브 요소 표시 */} {renderElementRects('knob', knobPositions, { round: true })} + {/* 스프라이트 요소 표시 */} + {renderElementRects('sprite', spritePositions, { + defaultW: 200, + defaultH: 200, + })} {/* 플러그인 요소 표시 */} {renderElementRects( 'plugin', diff --git a/src/renderer/components/main/Grid/core/NativeGridElements.test.tsx b/src/renderer/components/main/Grid/core/NativeGridElements.test.tsx index de432d6c2..0ae29dfce 100644 --- a/src/renderer/components/main/Grid/core/NativeGridElements.test.tsx +++ b/src/renderer/components/main/Grid/core/NativeGridElements.test.tsx @@ -6,6 +6,7 @@ import type { CanonicalGraphItemPosition, CanonicalKeyPosition, CanonicalKnobItemPosition, + CanonicalReactiveSpritePosition, CanonicalStatItemPosition, } from '@src/types/editor'; import NativeGridElements from './NativeGridElements'; @@ -75,6 +76,7 @@ async function createElementRenderer(kind: string) { vi.mock('@components/shared/key/Key', () => createElementRenderer('key-like')); vi.mock('../layers/GraphItem', () => createElementRenderer('graph')); vi.mock('../layers/KnobItem', () => createElementRenderer('knob')); +vi.mock('../layers/SpriteItem', () => createElementRenderer('sprite')); const keyPosition = ( id: string, @@ -87,6 +89,7 @@ const KEY_ID = '00000000-0000-4000-8000-000000000101'; const STAT_ID = '00000000-0000-4000-8000-000000000102'; const GRAPH_ID = '00000000-0000-4000-8000-000000000103'; const KNOB_ID = '00000000-0000-4000-8000-000000000104'; +const SPRITE_ID = '00000000-0000-4000-8000-000000000105'; describe('NativeGridElements', () => { let host: HTMLDivElement; @@ -141,6 +144,12 @@ describe('NativeGridElements', () => { reverse: false, } as CanonicalKnobItemPosition, ]} + spritePositions={[ + { + ...keyPosition(SPRITE_ID, 80), + poses: [], + } as unknown as CanonicalReactiveSpritePosition, + ]} pluginElements={[]} selectedElements={[{ type: 'graph', id: GRAPH_ID, index: 0 }]} activeTool="select" @@ -194,7 +203,7 @@ describe('NativeGridElements', () => { host.remove(); }); - it('키·통계·그래프·노브를 기존 순서와 표시 모델로 렌더한다', () => { + it('키·통계·그래프·노브·스프라이트를 기존 순서와 표시 모델로 렌더한다', () => { renderScene(); const elements = [...host.querySelectorAll('[data-native-kind]')]; @@ -203,10 +212,39 @@ describe('NativeGridElements', () => { 'Total', 'graph', 'knob', + 'sprite', ]); expect( elements.map((element) => element.getAttribute('data-selected')), - ).toEqual(['false', 'false', 'true', 'false']); + ).toEqual(['false', 'false', 'true', 'false', 'false']); + }); + + it('스프라이트도 같은 공통 핸들러로 위치 커밋·삭제·메뉴를 라우팅한다', () => { + renderScene(); + const sprite = host.querySelector(`[data-element-id="${SPRITE_ID}"]`)!; + + dispatchMouse(sprite, 'mousedown'); + expect(operationMocks.commitPosition).toHaveBeenCalledWith( + 'sprite', + SPRITE_ID, + 12, + 34, + ); + + dispatchMouse(sprite, 'contextmenu', { clientX: 3, clientY: 4 }); + expect(callbacks.openContext).toHaveBeenCalledWith( + 'sprite', + 0, + 3, + 4, + sprite, + ); + + dispatchMouse(sprite, 'mouseup', { button: 1 }); + expect(operationMocks.deleteElement).toHaveBeenCalledWith( + 'sprite', + SPRITE_ID, + ); }); it('선택·범위 선택·위치 커밋·컨텍스트 메뉴 동작을 유형별로 라우팅한다', () => { @@ -268,6 +306,7 @@ describe('NativeGridElements', () => { `stat:${STAT_ID}`, `graph:${GRAPH_ID}`, `knob:${KNOB_ID}`, + `sprite:${SPRITE_ID}`, ]); }); }); diff --git a/src/renderer/components/main/Grid/core/NativeGridElements.tsx b/src/renderer/components/main/Grid/core/NativeGridElements.tsx index c8816420b..ad6ab23dc 100644 --- a/src/renderer/components/main/Grid/core/NativeGridElements.tsx +++ b/src/renderer/components/main/Grid/core/NativeGridElements.tsx @@ -2,6 +2,7 @@ import { useState, type MouseEvent } from 'react'; import DraggableKey from '@components/shared/key/Key'; import GraphItem from '../layers/GraphItem'; import KnobItem from '../layers/KnobItem'; +import SpriteItem from '../layers/SpriteItem'; import { commitElementPosition } from '@hooks/Grid/elementPositionCommit'; import { useStableHandlerSlots } from '@hooks/shared/useStableHandlerSlots'; import { deleteElementById } from '@src/renderer/editor/runtime/operations/elementOps'; @@ -11,6 +12,7 @@ import type { CanonicalGraphItemPosition, CanonicalKeyPosition, CanonicalKnobItemPosition, + CanonicalReactiveSpritePosition, CanonicalStatItemPosition, } from '@src/types/editor'; import type { KeySlot } from '@src/types/key/keys'; @@ -35,6 +37,7 @@ interface NativeGridElementsProps { statPositions: readonly CanonicalStatItemPosition[]; graphPositions: readonly CanonicalGraphItemPosition[]; knobPositions: readonly CanonicalKnobItemPosition[]; + spritePositions: readonly CanonicalReactiveSpritePosition[]; pluginElements: readonly PluginDisplayElementInternal[]; selectedElements: SelectedElement[]; activeTool: string; @@ -69,6 +72,7 @@ const NativeGridElements = ({ statPositions, graphPositions, knobPositions, + spritePositions, pluginElements, selectedElements, activeTool, @@ -168,6 +172,7 @@ const NativeGridElements = ({ statPositions, graphPositions, knobPositions, + spritePositions, }), ); }, @@ -270,12 +275,36 @@ const NativeGridElements = ({ /> )); + const spriteElements = spritePositions.map((position, index) => ( + element.type === 'sprite' && element.id === position.id, + )} + selectedElements={selectedElements} + activeTool={activeTool} + zoom={zoom} + panX={panX} + panY={panY} + isViewportTransforming={isViewportTransforming} + {...stableHandlers( + position.id, + createCommonHandlers('sprite', index, position.id), + )} + /> + )); + return ( <> {keyElements} {statElements} {graphElements} {knobElements} + {spriteElements} ); }; diff --git a/src/renderer/components/main/Grid/core/gridContextMenuModel.test.ts b/src/renderer/components/main/Grid/core/gridContextMenuModel.test.ts index 97e2c5dab..65b38b64c 100644 --- a/src/renderer/components/main/Grid/core/gridContextMenuModel.test.ts +++ b/src/renderer/components/main/Grid/core/gridContextMenuModel.test.ts @@ -19,6 +19,7 @@ const source = ( statPositions: [], graphPositions: [], knobPositions: [], + spritePositions: [], pluginElements: [], modeGroups: [{ id: 'group-a' }, { id: 'group-b' }], ...overrides, @@ -175,6 +176,9 @@ describe('gridContextMenuModel', () => { it('native 안정 ID와 그리드 추가 메뉴 ID를 fail-closed로 분류한다', () => { const nativeId = '11111111-1111-4111-8111-111111111111'; expect(isStableNativeSelection({ type: 'key', id: nativeId })).toBe(true); + expect(isStableNativeSelection({ type: 'sprite', id: nativeId })).toBe( + true, + ); expect(isStableNativeSelection({ type: 'plugin', id: nativeId })).toBe( false, ); @@ -185,6 +189,7 @@ describe('gridContextMenuModel', () => { expect(gridAddTypeForMenuItem('addStat')).toBe('stat'); expect(gridAddTypeForMenuItem('addGraph')).toBe('graph'); expect(gridAddTypeForMenuItem('addKnob')).toBe('knob'); + expect(gridAddTypeForMenuItem('addSprite')).toBe('sprite'); expect(gridAddTypeForMenuItem('addPlugin')).toBeNull(); }); }); diff --git a/src/renderer/components/main/Grid/core/gridContextMenuModel.ts b/src/renderer/components/main/Grid/core/gridContextMenuModel.ts index 0576c4ab4..5d4c33d8b 100644 --- a/src/renderer/components/main/Grid/core/gridContextMenuModel.ts +++ b/src/renderer/components/main/Grid/core/gridContextMenuModel.ts @@ -65,7 +65,8 @@ export const isStableNativeSelection = (element: { (element.type === 'key' || element.type === 'stat' || element.type === 'graph' || - element.type === 'knob') && + element.type === 'knob' || + element.type === 'sprite') && element.id.length > 0 && isNativeElementId(element.id); @@ -76,5 +77,6 @@ export const gridAddTypeForMenuItem = ( if (id === 'addStat') return 'stat'; if (id === 'addGraph') return 'graph'; if (id === 'addKnob') return 'knob'; + if (id === 'addSprite') return 'sprite'; return null; }; diff --git a/src/renderer/components/main/Grid/handles/CanvasRotateHandle.tsx b/src/renderer/components/main/Grid/handles/CanvasRotateHandle.tsx new file mode 100644 index 000000000..de33c204b --- /dev/null +++ b/src/renderer/components/main/Grid/handles/CanvasRotateHandle.tsx @@ -0,0 +1,342 @@ +import React, { + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { flushSync } from 'react-dom'; +import { I18nContext } from '@contexts/I18nContextDef'; +import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; +import { subscribeHistoryEditorFlushStart } from '@src/renderer/editor/runtime/lifecycle/historyEditorFlushLock'; +import { + releaseDragSession, + tryAcquireDragSession, +} from '@hooks/Grid/drag/dragSession'; +import { createRafLatestScheduler } from '@utils/animation/rafLatestScheduler'; +import { beginDragCursor, endDragCursor } from '@utils/dom/dragCursor'; +import { + getCursor, + lockCustomCursor, + unlockCustomCursor, + type RotationCursorType, +} from '@utils/grid/cursorUtils'; +import { + resolveRotationDrag, + rotatedRectCorners, +} from '@utils/element/rotation'; +import { getActiveElement } from '@utils/dom/activeElement'; +import { isHTMLElementNode } from '@utils/dom/isElementNode'; +import { suppressNextClick } from '@utils/dom/suppressNextClick'; +import type { Bounds } from './groupResizeUtils'; +import RotateCornerHandles from './RotateCornerHandles'; +import { SELECTION_BORDER_CENTER } from './selectionOutline'; + +export interface CanvasRotationSession { + preview: (rotation: number) => boolean; + commit: (rotation: number) => void; + cancel: () => void; +} + +interface CanvasRotateHandleProps { + bounds: Bounds; + rotation: number; + zoom: number; + panX: number; + panY: number; + sessionKey: string; + kind?: 'native' | 'selection'; + start: (rotation: number) => CanvasRotationSession | null; +} + +interface DragState { + pointerId: number; + session: CanvasRotationSession; + base: number; + startAngle: number; + // 마지막으로 프리뷰한 각도. null이면 움직인 적이 없다 + last: number | null; + sawPressedMove: boolean; +} + +// 모서리 바깥 회전 드래그의 입력·커서·중단 수명 +const CanvasRotateHandle = ({ + sessionKey, + kind = 'native', + start, + bounds, + rotation, + zoom, + panX, + panY, +}: CanvasRotateHandleProps) => { + // 분리 패널·테스트처럼 프로바이더 밖에서도 그려지므로 번역은 선택적으로 읽는다 + const translate = useContext(I18nContext)?.t; + const rootRef = useRef(null); + const dragRef = useRef(null); + const [dragRotation, setDragRotation] = useState(null); + const detachRef = useRef<(() => void) | null>(null); + const grabbedRef = useRef<{ el: Element; pointerId: number } | null>(null); + const moveSchedulerRef = useRef + > | null>(null); + const cancelActiveDragRef = useRef<() => void>(() => {}); + + // 드래그 중 팬·줌·상자 변경을 최신값으로 읽는다 - 핸들러는 커밋 뒤에만 돌므로 충분 + const latestRef = useRef({ bounds, rotation, zoom, panX, panY, start }); + useLayoutEffect(() => { + latestRef.current = { bounds, rotation, zoom, panX, panY, start }; + }); + + const centerClient = (): { x: number; y: number } => { + const rect = rootRef.current?.getBoundingClientRect(); + const live = latestRef.current; + return { + x: + (rect?.left ?? 0) + + (live.bounds.x + live.bounds.width / 2) * live.zoom + + live.panX, + y: + (rect?.top ?? 0) + + (live.bounds.y + live.bounds.height / 2) * live.zoom + + live.panY, + }; + }; + + const angleAt = (clientX: number, clientY: number): number => { + const center = centerClient(); + return Math.atan2(clientY - center.y, clientX - center.x); + }; + + const releaseGrabbed = () => { + const grabbed = grabbedRef.current; + if (!grabbed) return; + endDragCursor(grabbed.el.ownerDocument); + unlockCustomCursor(); + try { + grabbed.el.releasePointerCapture(grabbed.pointerId); + } catch { + // 이미 해제됐으면 무시 + } + grabbedRef.current = null; + }; + + const finishDrag = () => { + if (!dragRef.current) return; + releaseDragSession(); + moveSchedulerRef.current?.cancel(); + moveSchedulerRef.current = null; + detachRef.current?.(); + detachRef.current = null; + releaseGrabbed(); + dragRef.current = null; + setDragRotation(null); + suppressNextClick(); + }; + + // 프리뷰를 되돌리고 종료 - 외부 중단(undo·flush·선택 변경·창 블러)의 공통 경로 + const cancelActiveDrag = () => { + const drag = dragRef.current; + if (!drag) return; + if (drag.last !== null) drag.session.cancel(); + finishDrag(); + }; + useLayoutEffect(() => { + cancelActiveDragRef.current = cancelActiveDrag; + }); + + useEffect(() => () => cancelActiveDragRef.current(), []); + useEffect(() => () => cancelActiveDragRef.current(), [sessionKey]); + useEffect( + () => subscribeHistoryEditorFlushStart(() => cancelActiveDragRef.current()), + [], + ); + + // undo/redo가 저장값을 되돌린 뒤 늦은 pointerup이 낡은 각도를 다시 커밋하지 않게 + const historyTick = useCommittedApplyStore((state) => state.historyTick); + const historyTickRef = useRef(historyTick); + useEffect(() => { + if (historyTickRef.current === historyTick) return; + historyTickRef.current = historyTick; + cancelActiveDragRef.current(); + }, [historyTick]); + + const applyMove = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return; + const next = resolveRotationDrag({ + base: drag.base, + startAngle: drag.startAngle, + angle: angleAt(event.clientX, event.clientY), + snap: event.shiftKey, + }); + if (drag.last === next) return; + if (!drag.session.preview(next)) return; + drag.last = next; + setDragRotation(next); + }; + + const settleActiveDrag = (pointerId: number) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== pointerId) return; + // 같은 프레임의 마지막 move가 rAF에 남아 있으면 커밋 전에 반영한다 + moveSchedulerRef.current?.flush(); + const value = drag.last; + if (value === null) { + finishDrag(); + return; + } + drag.session.commit(value); + finishDrag(); + }; + + const handleWindowUp = (event: PointerEvent) => { + settleActiveDrag(event.pointerId); + }; + const handleWindowMove = (event: PointerEvent) => { + const drag = dragRef.current; + if (event.pointerId !== drag?.pointerId) return; + if (event.buttons !== 0) drag.sawPressedMove = true; + moveSchedulerRef.current?.push(event); + // 일부 WebView 합성 입력은 up 없이 마지막 move의 buttons만 0으로 바뀐다 + if (event.buttons === 0 && drag.sawPressedMove) { + settleActiveDrag(event.pointerId); + } + }; + const handleWindowCancel = (event: PointerEvent) => { + if (event.pointerId !== dragRef.current?.pointerId) return; + cancelActiveDrag(); + }; + const handleMouseUp = (event: MouseEvent) => { + if (event.button !== 0) return; + const drag = dragRef.current; + if (!drag) return; + settleActiveDrag(drag.pointerId); + }; + const handleLostPointerCapture = (event: Event) => { + const pointerEvent = event as PointerEvent; + if (pointerEvent.pointerId !== dragRef.current?.pointerId) return; + settleActiveDrag(pointerEvent.pointerId); + }; + const handleWindowBlur = () => cancelActiveDrag(); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + cancelActiveDrag(); + }; + + const attachWindowListeners = () => { + const captureTarget = grabbedRef.current?.el ?? null; + const ownerDocument = captureTarget?.ownerDocument ?? document; + const ownerWindow = ownerDocument.defaultView ?? window; + ownerWindow.addEventListener('pointermove', handleWindowMove, true); + ownerWindow.addEventListener('pointerup', handleWindowUp, true); + ownerWindow.addEventListener('pointercancel', handleWindowCancel, true); + ownerWindow.addEventListener('mouseup', handleMouseUp, true); + ownerWindow.addEventListener('blur', handleWindowBlur); + ownerWindow.addEventListener('keydown', handleKeyDown, true); + captureTarget?.addEventListener( + 'lostpointercapture', + handleLostPointerCapture, + ); + detachRef.current = () => { + ownerWindow.removeEventListener('pointermove', handleWindowMove, true); + ownerWindow.removeEventListener('pointerup', handleWindowUp, true); + ownerWindow.removeEventListener( + 'pointercancel', + handleWindowCancel, + true, + ); + ownerWindow.removeEventListener('mouseup', handleMouseUp, true); + ownerWindow.removeEventListener('blur', handleWindowBlur); + ownerWindow.removeEventListener('keydown', handleKeyDown, true); + captureTarget?.removeEventListener( + 'lostpointercapture', + handleLostPointerCapture, + ); + }; + return ownerWindow; + }; + + // 열린 패널 입력을 먼저 정산해 드래그 시작 각도가 입력값 위에 얹히게 한다 + const settleFocusedField = () => { + const active = getActiveElement(); + if ( + isHTMLElementNode(active) && + active.matches('input, textarea, [contenteditable="true"]') + ) { + flushSync(() => active.blur()); + } + }; + + const beginDrag = ( + event: React.PointerEvent, + cursor: RotationCursorType, + ) => { + if (event.button !== 0 || dragRef.current) return; + event.preventDefault(); + event.stopPropagation(); + if (!tryAcquireDragSession()) return; + settleFocusedField(); + const session = latestRef.current.start(latestRef.current.rotation); + if (!session) { + releaseDragSession(); + return; + } + const el = event.currentTarget; + try { + el.setPointerCapture(event.pointerId); + } catch { + // 미지원 환경은 커서 고정 없이 동작만 유지 + } + beginDragCursor(getCursor(cursor), el.ownerDocument); + lockCustomCursor(cursor, event.nativeEvent); + grabbedRef.current = { el, pointerId: event.pointerId }; + dragRef.current = { + pointerId: event.pointerId, + session, + base: latestRef.current.rotation, + startAngle: angleAt(event.clientX, event.clientY), + last: null, + sawPressedMove: false, + }; + const ownerWindow = attachWindowListeners(); + moveSchedulerRef.current = createRafLatestScheduler( + applyMove, + 'frame', + ownerWindow, + ); + }; + + const shownRotation = dragRotation ?? rotation; + // 크기 조절 핸들과 같은 테두리 중심을 기준으로 바깥 영역 배치 + const corners = rotatedRectCorners( + bounds.x * zoom + panX - SELECTION_BORDER_CENTER, + bounds.y * zoom + panY - SELECTION_BORDER_CENTER, + bounds.width * zoom + SELECTION_BORDER_CENTER * 2, + bounds.height * zoom + SELECTION_BORDER_CENTER * 2, + shownRotation, + ); + const label = translate?.('propertiesPanel.rotation') || '회전'; + + return ( +
+ +
+ ); +}; + +export default CanvasRotateHandle; diff --git a/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx b/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx index fdd9bb2dd..e998aefd1 100644 --- a/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx +++ b/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx @@ -115,7 +115,7 @@ const GradientAxisOverlay = ({ if (!session || !bounds || !geometry) return null; - const { cx, cy, halfLine, endX, endY } = geometry; + const { cx, cy, halfLine, endX, endY, screenAngle } = geometry; // pos(0~1) → 축 위 화면 좌표 const stopPoint = (pos: number) => gradientAxisStopPoint(geometry, pos); @@ -127,7 +127,7 @@ const GradientAxisOverlay = ({ className="absolute inset-0 pointer-events-none" // 리사이즈 핸들 위, 사이드 패널 아래에 두는 내부 편집 층 - 패널 위로 새지 않게 style={{ zIndex: 'var(--z-canvas-gradient-editor)' }} - data-dmn-gradient-overlay="true" + data-dmn-canvas-editor-overlay="true" > {/* 조작 UI 묶음 - 핸들 드래그나 피커 색 드래그 동안 흐려져 가려진 대상의 실제 색이 보인다. 값 배지는 묶음 밖이라 선명 유지 */} @@ -148,7 +148,7 @@ const GradientAxisOverlay = ({ width: halfLine * 2, height: 0, borderTop: '1.5px solid var(--ui-selection-border-strong)', - transform: `translate(-50%, -50%) rotate(${angle - 90}deg)`, + transform: `translate(-50%, -50%) rotate(${screenAngle - 90}deg)`, opacity: 0.9, pointerEvents: 'none', }} @@ -173,7 +173,7 @@ const GradientAxisOverlay = ({ top: cy, width: halfLine * 2, height: AXIS_HIT_THICKNESS, - transform: `translate(-50%, -50%) rotate(${angle - 90}deg)`, + transform: `translate(-50%, -50%) rotate(${screenAngle - 90}deg)`, cursor: isRotating ? 'grabbing' : 'default', pointerEvents: 'auto', touchAction: 'none', diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx index 55c0a88a3..6d2fb12a8 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx @@ -5,8 +5,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; import type { ElementBounds as SmartGuideElementBounds } from '@utils/grid/smartGuides'; +import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; import GroupResizeHandles from './GroupResizeHandles'; import type { GroupResizeHandle, GroupResizeResult } from './groupResizePlan'; +import { isBoundsWithinEditorLimits } from './resizeLimits'; +import type { GroupRotationFrame } from './rotatedGroupResize'; +import { projectSpriteResize } from '@utils/sprite/resizeProjection'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -130,6 +135,8 @@ describe('GroupResizeHandles 세션', () => { onResize = vi.fn<(result: GroupResizeResult) => void>(), onEnd = vi.fn<() => void>(), getOtherElements, + rotationFrame, + previewGroupBounds, }: { strategy?: 'legacy' | 'frame'; zoom?: number; @@ -137,6 +144,8 @@ describe('GroupResizeHandles 세션', () => { onResize?: (result: GroupResizeResult) => void; onEnd?: () => void; getOtherElements?: (excludeIds: string[]) => SmartGuideElementBounds[]; + rotationFrame?: GroupRotationFrame; + previewGroupBounds?: GroupRotationFrame['bounds']; } = {}) => { act(() => { root.render( @@ -152,6 +161,8 @@ describe('GroupResizeHandles 세션', () => { selectedKeyType="mode" pluginElements={[]} zoom={zoom} + rotationFrame={rotationFrame} + previewGroupBounds={previewGroupBounds} onGroupResizeStart={onStart} onGroupResize={onResize} onGroupResizeEnd={onEnd} @@ -169,6 +180,73 @@ describe('GroupResizeHandles 세션', () => { act(() => handle.dispatchEvent(mouse('mousedown', 100, 100))); }; + it('회전 프레임의 틀·핸들·커서와 동결한 로컬 드래그 축이 함께 돈다', () => { + const rotationFrame = { + bounds: { x: 0, y: 0, width: 100, height: 80 }, + rotation: 90, + }; + const onResize = vi.fn(); + const getOtherElements = vi.fn(() => []); + renderHandles({ rotationFrame, zoom: 2, onResize, getOtherElements }); + const outline = host.querySelector( + '[data-group-resize-outline]', + )!; + const east = host.querySelector( + '[data-group-resize-handle="e"]', + )!; + expect(outline.style.transform).toBe('rotate(90deg)'); + expect(Number.parseFloat(east.style.left) + 9).toBeCloseTo(100, 8); + expect(Number.parseFloat(east.style.top) + 9).toBeCloseTo(180.5, 8); + expect(east.firstElementChild?.getAttribute('style')).toContain( + 'rotate(90deg)', + ); + startResize('e'); + expect(mocks.lockCustomCursor).toHaveBeenCalledWith( + 'ns-resize', + expect.any(MouseEvent), + ); + + renderHandles({ + rotationFrame: { ...rotationFrame, rotation: 0 }, + zoom: 2, + onResize, + getOtherElements, + }); + act(() => document.dispatchEvent(mouse('mousemove', 100, 140))); + const result = onResize.mock.calls.at(-1)![0] as GroupResizeResult; + expect(result.groupBounds.x).toBeCloseTo(-10, 8); + expect(result.groupBounds.y).toBeCloseTo(2, 8); + expect(result.groupBounds.width).toBe(120); + expect(result.groupBounds.height).toBe(96); + expect( + result.elementBounds.map(({ bounds }) => [bounds.width, bounds.height]), + ).toEqual([ + [48, 48], + [48, 48], + ]); + expect(getOtherElements).not.toHaveBeenCalled(); + expect(mocks.smartGuidesState.clearGuides).toHaveBeenCalled(); + act(() => document.dispatchEvent(mouse('mouseup', 100, 140))); + }); + + it('회전 프레임의 프리뷰는 world 논리 bounds를 다시 AABB로 바꾸지 않고 표시한다', () => { + renderHandles({ + rotationFrame: { + bounds: { x: 0, y: 0, width: 100, height: 80 }, + rotation: 45, + }, + previewGroupBounds: { x: -10, y: 2, width: 120, height: 96 }, + }); + const outline = host.querySelector( + '[data-group-resize-outline]', + )!; + expect(outline.style.left).toBe('-12px'); + expect(outline.style.top).toBe('0px'); + expect(outline.style.width).toBe('124px'); + expect(outline.style.height).toBe('100px'); + expect(outline.style.transform).toBe('rotate(45deg)'); + }); + it('move마다 최신 store를 읽고 zoom·grid snap을 적용하며 primary modifier는 smart snap을 막는다', () => { const onResize = vi.fn(); const getOtherElements = vi.fn(() => []); @@ -262,3 +340,634 @@ describe('GroupResizeHandles 세션', () => { expect(onEnd).toHaveBeenCalledTimes(1); }); }); + +const SPRITE_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const SPRITE_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +type SpritePositions = Record< + string, + ReturnType[] +>; + +// 폭 28 요소 - 그룹 폭이 30까지 줄면 size matching 임계값(4px) 안에 들어온다. +// 가장자리는 멀리 둬 정렬 스냅은 걸리지 않게 한다 +const sizeMatchTarget = (): SmartGuideElementBounds => ({ + id: 'other', + left: 1000, + top: 600, + right: 1028, + bottom: 1100, + centerX: 1014, + centerY: 850, + width: 28, + height: 500, +}); + +describe('GroupResizeHandles 스프라이트 최소 크기·비율 고정', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.settingsState.gridSettings.gridSnapSize = 5; + mocks.settingsState.gridSettings.alignmentGuides = true; + mocks.settingsState.gridSettings.spacingGuides = true; + mocks.settingsState.gridSettings.sizeMatchGuides = true; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + }); + + const renderSprites = ( + spritePositions: SpritePositions, + onGroupResize: (result: GroupResizeResult) => void, + getOtherElements: () => SmartGuideElementBounds[] = () => [], + rotationFrame?: GroupRotationFrame, + ) => { + act(() => { + root.render( + , + ); + }); + }; + + const pressHandle = (handleId: string, x: number, y: number) => { + const handle = host.querySelector( + `[data-group-resize-handle="${handleId}"]`, + )!; + act(() => handle.dispatchEvent(mouse('mousedown', x, y))); + }; + + it('회전한 그룹은 스프라이트 자세 이동값이 잘리기 전에 전체 배율을 제한한다', () => { + const first = makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 100, + height: 50, + idleTransform: { x: 1500, y: -100, rotation: 179, scale: 1 }, + poses: [ + { + poseId: 'pose', + triggers: [], + transform: { x: 20, y: -1800, rotation: -179, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, + }, + ], + }); + const second = makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 150, + dy: 30, + width: 50, + height: 50, + }); + const unrelated = makeCanonicalSpritePosition({ + id: 'unselected', + idleTransform: { x: 2000, y: 0, rotation: 0, scale: 1 }, + }); + const onResize = vi.fn<(result: GroupResizeResult) => void>(); + renderSprites({ '4key': [first, second, unrelated] }, onResize, () => [], { + bounds: { x: 0, y: 0, width: 200, height: 100 }, + rotation: 90, + }); + pressHandle('e', 100, 100); + act(() => document.dispatchEvent(mouse('mousemove', 100, 300))); + act(() => document.dispatchEvent(mouse('mouseup', 100, 300))); + const result = onResize.mock.lastCall![0]; + const scale = 2000 / 1800; + result.elementBounds.forEach(({ bounds }, index) => { + const position = [first, second][index]; + expect(bounds.width / position.width).toBeCloseTo(scale, 12); + expect(bounds.height / position.height).toBeCloseTo(scale, 12); + const projected = projectSpriteResize(position, { + dx: bounds.x, + dy: bounds.y, + width: bounds.width, + height: bounds.height, + }); + expect(projected.idleTransform.x).toBeCloseTo( + position.idleTransform.x * scale, + 9, + ); + expect(projected.poses[0]?.transform.y ?? 0).toBeCloseTo( + (position.poses[0]?.transform.y ?? 0) * scale, + 9, + ); + expect(projected.idleTransform.rotation).toBe( + position.idleTransform.rotation, + ); + }); + }); + + // 그룹 리사이즈 한 번을 돌리고 마지막 onGroupResize 결과를 돌려준다 + const dragGroup = ( + spritePositions: SpritePositions, + handleId: string, + from: { x: number; y: number }, + to: { x: number; y: number }, + getOtherElements: () => SmartGuideElementBounds[] = () => [], + ) => { + const onGroupResize = vi.fn<(result: GroupResizeResult) => void>(); + renderSprites(spritePositions, onGroupResize, getOtherElements); + pressHandle(handleId, from.x, from.y); + act(() => document.dispatchEvent(mouse('mousemove', to.x, to.y))); + act(() => document.dispatchEvent(mouse('mouseup', to.x, to.y))); + const result = onGroupResize.mock.lastCall?.[0]; + return { + groupBounds: result?.groupBounds, + elementBounds: + result?.elementBounds ?? + spritePositions['4key'].map((position) => ({ + bounds: { + x: position.dx, + y: position.dy, + width: position.width, + height: position.height, + }, + })), + previewCount: onGroupResize.mock.calls.length, + }; + }; + + it('크기 스냅이 그룹을 더 줄여도 요소는 10px 밑으로 내려가지 않는다', () => { + // 100x100 스프라이트 둘이 (0,0)·(200,0)에 놓인 300x100 그룹. + // 그룹 하한은 30까지만 허용하지만 size matching이 28로 다시 당긴다 + const onGroupResize = vi.fn<(result: GroupResizeResult) => void>(); + renderSprites( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 100, + height: 100, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 200, + dy: 0, + width: 100, + height: 100, + }), + ], + }, + onGroupResize, + () => [sizeMatchTarget()], + ); + pressHandle('e', 300, 50); + act(() => document.dispatchEvent(mouse('mousemove', 0, 50))); + + expect(onGroupResize).toHaveBeenCalledTimes(1); + const result = onGroupResize.mock.calls[0][0]; + expect(result.groupBounds.width).toBe(30); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.height).toBeGreaterThanOrEqual(10); + } + // 화면 가이드도 실제 결과를 따른다 - 성립하지 않은 28px 크기 일치가 남으면 안 되고, + // 되돌린 뒤 남는 스냅이 없으니 가이드는 통째로 비운다 + expect(mocks.smartGuidesState.setSizeMatchGuides).not.toHaveBeenCalled(); + expect(mocks.smartGuidesState.setDraggedBounds).not.toHaveBeenCalled(); + expect(mocks.smartGuidesState.clearGuides).toHaveBeenCalled(); + act(() => document.dispatchEvent(mouse('mouseup', 0, 50))); + }); + + it('그룹 축소도 투영 반올림으로 요소 하한 아래에 저장하지 않는다', () => { + const result = dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 77, + height: 77, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 308, + dy: 0, + width: 77, + height: 77, + }), + ], + }, + 'e', + { x: 385, y: 0 }, + { x: 0, y: 0 }, + ); + expect(result.previewCount).toBe(1); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.height).toBeGreaterThanOrEqual(10); + expect(bounds.width).toBeCloseTo(10, 9); + } + }); + + it('한 축이 얇아도 그룹 축소는 스프라이트의 정상 축 하한을 지킨다', () => { + const result = dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 400, + height: 0.1, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 1600, + dy: 0, + width: 400, + height: 0.1, + }), + ], + }, + 'e', + { x: 2000, y: 0 }, + { x: 0, y: 0 }, + ); + expect(result.previewCount).toBe(1); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.width).toBeCloseTo(10, 9); + expect(bounds.width / 400).toBeCloseTo(bounds.height / 0.1, 9); + } + }); + + it('8방향과 스냅별 연속 축소는 최소 크기와 고정 가장자리를 보존한다', () => { + const handles = [ + { id: 'nw', dx: -1, dy: -1 }, + { id: 'n', dx: 0, dy: -1 }, + { id: 'ne', dx: 1, dy: -1 }, + { id: 'w', dx: -1, dy: 0 }, + { id: 'e', dx: 1, dy: 0 }, + { id: 'sw', dx: -1, dy: 1 }, + { id: 's', dx: 0, dy: 1 }, + { id: 'se', dx: 1, dy: 1 }, + ]; + for (const snapSize of [0, 1, 5, 10]) { + mocks.settingsState.gridSettings.gridSnapSize = snapSize; + for (const handle of handles) { + let sprites = [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 3, + dy: 7, + width: 77, + height: 77, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 311, + dy: 315, + width: 77, + height: 77, + }), + ]; + for (let round = 0; round < 3; round += 1) { + const result = dragGroup( + { '4key': sprites }, + handle.id, + { x: 0, y: 0 }, + { x: -1000 * handle.dx, y: -1000 * handle.dy }, + ); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.height).toBeGreaterThanOrEqual(10); + expect(bounds.width).toBe(bounds.height); + } + if (result.groupBounds && handle.dx !== 0 && handle.dy !== 0) { + const beforeX = Math.min(...sprites.map((sprite) => sprite.dx)); + const beforeY = Math.min(...sprites.map((sprite) => sprite.dy)); + const beforeRight = Math.max( + ...sprites.map((sprite) => sprite.dx + sprite.width), + ); + const beforeBottom = Math.max( + ...sprites.map((sprite) => sprite.dy + sprite.height), + ); + expect( + handle.dx === 1 + ? result.groupBounds.x + : result.groupBounds.x + result.groupBounds.width, + ).toBeCloseTo(handle.dx === 1 ? beforeX : beforeRight, 9); + expect( + handle.dy === 1 + ? result.groupBounds.y + : result.groupBounds.y + result.groupBounds.height, + ).toBeCloseTo(handle.dy === 1 ? beforeY : beforeBottom, 9); + } + sprites = sprites.map((sprite, index) => ({ + ...sprite, + dx: result.elementBounds[index].bounds.x, + dy: result.elementBounds[index].bounds.y, + width: result.elementBounds[index].bounds.width, + height: result.elementBounds[index].bounds.height, + })); + } + } + } + }); + + it('이미 두 축이 얇은 그룹을 줄여도 강제로 키우거나 더 줄이지 않는다', () => { + const result = dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 1, + height: 1, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 2, + dy: 0, + width: 1, + height: 1, + }), + ], + }, + 'e', + { x: 3, y: 0 }, + { x: -100, y: 0 }, + ); + expect(result.previewCount).toBe(0); + }); + + it.each([ + { id: 'e', dx: 1, dy: 0 }, + { id: 's', dx: 0, dy: 1 }, + { id: 'se', dx: 1, dy: 1 }, + ])( + '두 축이 얇은 스프라이트와 큰 키를 함께 $id 방향으로 줄이면 크기를 유지하고 확대는 허용한다', + ({ id, dx, dy }) => { + mocks.settingsState.gridSettings.gridSnapSize = 0; + const onGroupResize = vi.fn<(result: GroupResizeResult) => void>(); + const onGroupResizeStart = vi.fn(); + const onGroupResizeEnd = vi.fn(); + const sprite = makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 1, + height: 2, + }); + const key = { + ...createDefaultKeyPosition(100, 100), + id: SPRITE_B, + width: 100, + height: 100, + }; + act(() => { + root.render( + []} + onGroupResize={onGroupResize} + onGroupResizeStart={onGroupResizeStart} + onGroupResizeEnd={onGroupResizeEnd} + continuousInputStrategy="legacy" + />, + ); + }); + pressHandle(id, 0, 0); + const move = (distance: number) => { + act(() => + document.dispatchEvent( + mouse('mousemove', dx * distance, dy * distance), + ), + ); + }; + + move(-100); + expect(onGroupResize).not.toHaveBeenCalled(); + expect(onGroupResizeStart).not.toHaveBeenCalled(); + expect(onGroupResizeEnd).not.toHaveBeenCalled(); + + move(50); + expect(onGroupResize).toHaveBeenCalledTimes(1); + expect(onGroupResizeStart).toHaveBeenCalledTimes(1); + const result = onGroupResize.mock.lastCall![0]; + const spriteBounds = result.elementBounds.find( + ({ element }) => element.id === SPRITE_A, + )!.bounds; + const keyBounds = result.elementBounds.find( + ({ element }) => element.id === SPRITE_B, + )!.bounds; + expect(spriteBounds.width).toBe(1.25); + expect(spriteBounds.height).toBe(2.5); + expect(keyBounds.width).toBe(dx === 0 ? 100 : 125); + expect(keyBounds.height).toBe(dy === 0 ? 100 : 125); + expect( + result.elementBounds.every(({ bounds }) => + isBoundsWithinEditorLimits(bounds), + ), + ).toBe(true); + act(() => document.dispatchEvent(mouse('mouseup', 0, 0))); + expect(onGroupResizeEnd).toHaveBeenCalledTimes(1); + }, + ); + + it.each(['s', 'e'])( + '얇은 그룹의 %s 핸들도 정상 축이 하한에 닿기 전까지 축소한다', + (handleId) => { + mocks.settingsState.gridSettings.gridSnapSize = 0; + const vertical = handleId === 's'; + const result = dragGroup( + { + '4key': [SPRITE_A, SPRITE_B].map((id, index) => + makeCanonicalSpritePosition({ + id, + dx: vertical ? 0 : index * 0.1, + dy: vertical ? index * 0.1 : 0, + width: vertical ? 400 : 0.1, + height: vertical ? 0.1 : 400, + }), + ), + }, + handleId, + { x: 0, y: 0 }, + { x: vertical ? 0 : -0.1, y: vertical ? -0.1 : 0 }, + ); + expect(result.previewCount).toBe(1); + for (const { bounds } of result.elementBounds) { + expect(vertical ? bounds.width : bounds.height).toBeCloseTo(200, 9); + expect(vertical ? bounds.height : bounds.width).toBeCloseTo(0.05, 9); + } + }, + ); + + it('극소 두께 그룹도 하한 반올림 보정이 멈추지 않고 끝난다', () => { + mocks.settingsState.gridSettings.gridSnapSize = 0; + const result = dragGroup( + { + '4key': [SPRITE_A, SPRITE_B].map((id, index) => + makeCanonicalSpritePosition({ + id, + dx: 0, + dy: index * 1e-320, + width: 400, + height: 1e-320, + }), + ), + }, + 'e', + { x: 0, y: 0 }, + { x: -390, y: 0 }, + ); + expect(result.previewCount).toBe(1); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.height).toBeGreaterThan(0); + } + }); + + it('얇은 스프라이트 그룹의 세로 확대는 파생 폭 상한에서 멈춘다', () => { + // 400x0.1 둘이 세로로 붙은 0.2 높이 그룹 - 아래 핸들 100px는 배율 500 + const result = dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 400, + height: 0.1, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 0, + dy: 0.1, + width: 400, + height: 0.1, + }), + ], + }, + 's', + { x: 200, y: 0 }, + { x: 200, y: 100 }, + ); + expect(result.groupBounds!.height).toBeLessThan(100); + for (const { bounds } of result.elementBounds) { + expect(isBoundsWithinEditorLimits(bounds)).toBe(true); + expect(bounds.width / 400).toBeCloseTo(bounds.height / 0.1, 6); + } + expect( + Math.max(...result.elementBounds.map(({ bounds }) => bounds.width)), + ).toBeGreaterThan(32000); + }); + + it('가로 핸들은 그룹 높이 크기 일치를 받지 않는다', () => { + // 높이 102는 임계값 안, 폭 1000은 밖 - 예전에는 가로 핸들인데 높이가 102로 바뀌었다 + const heightTarget = (): SmartGuideElementBounds => ({ + id: 'other', + left: 1000, + top: 600, + right: 2000, + bottom: 702, + centerX: 1500, + centerY: 651, + width: 1000, + height: 102, + }); + const result = dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 100, + height: 100, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 200, + dy: 0, + width: 100, + height: 100, + }), + ], + }, + 'e', + { x: 300, y: 50 }, + { x: 290, y: 50 }, + () => [heightTarget()], + ); + expect(result.groupBounds!.height).toBe(100); + expect(result.groupBounds!.width).toBe(290); + }); + + it('가로 핸들은 얇은 그룹의 높이를 10으로 키우지 않는다', () => { + // 400x0.1 둘이 세로로 붙은 0.2 높이 그룹 - 잡지 않은 세로축은 시작값 그대로 + const result = dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 400, + height: 0.1, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 0, + dy: 0.1, + width: 400, + height: 0.1, + }), + ], + }, + 'e', + { x: 400, y: 0 }, + { x: 440, y: 0 }, + ); + // 그룹 높이는 자란 스프라이트(0.11 둘)를 감싼 0.21 - 10으로 뛰지 않는다 + expect(result.groupBounds!.height).toBeCloseTo(0.21, 9); + expect(result.groupBounds!.width).toBeCloseTo(440, 9); + // 배율 1.1 로 폭 440, 높이 0.11 - 중심은 그룹 세로 배율 1 이라 y가 거의 제자리 + for (const { bounds } of result.elementBounds) { + expect(Math.abs(bounds.y)).toBeLessThan(0.2); + expect(bounds.width / 400).toBeCloseTo(bounds.height / 0.1, 6); + } + }); +}); diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx index 7beff8e58..32438df67 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx @@ -1,12 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React from 'react'; import type { ElementBounds as SmartGuideElementBounds } from '@utils/grid/smartGuides'; -import { - clearPendingCustomCursorHover, - getCursor, - isCustomCursorHoverSuspended, - setCustomCursorHover, - setPendingCustomCursorHover, -} from '@utils/grid/cursorUtils'; +import { getCursor } from '@utils/grid/cursorUtils'; +import { useCustomCursorHover } from '@hooks/Grid/viewport/useCustomCursorHover'; import type { ContinuousInputStrategy } from '@utils/animation/rafLatestScheduler'; import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; @@ -19,6 +14,9 @@ import { } from './groupResizeUtils'; import type { GroupResizeHandle, GroupResizeResult } from './groupResizePlan'; import { useGroupResizeSession } from './useGroupResizeSession'; +import { rotatePointAround } from '@utils/element/rotation'; +import { resizeCursorForHandle } from './rotatedResize'; +import type { GroupRotationFrame } from './rotatedGroupResize'; /** * 다중 선택 시 그룹 전체를 감싸는 리사이즈 핸들을 표시하는 컴포넌트 @@ -31,6 +29,7 @@ interface HandleProps { handle: GroupResizeHandle; centerX: number; centerY: number; + rotation: number; onMouseDown: (e: React.MouseEvent, handle: GroupResizeHandle) => void; } @@ -40,12 +39,15 @@ interface GroupResizeHandlesProps { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + // Grid 배선 전에도 컴포넌트가 동작하도록 선택 prop - 미전달 시 스프라이트만 제외 + spritePositions?: CanonicalEditorDocumentV1['spritePositions']; selectedKeyType: string; pluginElements: PluginDisplayElementInternal[]; zoom?: number; panX?: number; panY?: number; previewGroupBounds?: Bounds | null; + rotationFrame?: GroupRotationFrame; onGroupResizeStart?: (handle: GroupResizeHandle) => void; onGroupResize?: (result: GroupResizeResult) => void; onGroupResizeEnd?: () => void; @@ -102,13 +104,11 @@ const HANDLES: GroupResizeHandle[] = [ // 핸들 시각적 스타일 반환 const getHandleStyle = ( type: GroupResizeHandle['type'], - isHovered: boolean, ): React.CSSProperties => { const baseStyle: React.CSSProperties = { - backgroundColor: isHovered ? 'var(--ui-selection)' : 'white', + backgroundColor: 'white', border: '2px solid var(--ui-selection-border-strong)', pointerEvents: 'none', - transition: 'background-color 0.15s ease', }; if (type === 'corner') { @@ -140,27 +140,10 @@ const Handle = ({ handle, centerX, centerY, + rotation, onMouseDown, }: HandleProps): React.ReactElement => { - const [isHovered, setIsHovered] = useState(false); - const hoveredRef = useRef(false); - const pendingApplyRef = useRef<(() => void) | null>(null); - - // 호버 중 unmount로 leave가 유실되면 남는 커서 오버레이·보류 기록 정리 - useEffect(() => { - return () => { - if (pendingApplyRef.current) { - clearPendingCustomCursorHover(pendingApplyRef.current); - pendingApplyRef.current = null; - } - if (hoveredRef.current) setCustomCursorHover(null); - }; - }, []); - - const setHovered = (next: boolean) => { - hoveredRef.current = next; - setIsHovered(next); - }; + const hover = useCustomCursorHover(handle.cursor); const hitX = centerX - HANDLE_HIT_HALF; const hitY = centerY - HANDLE_HIT_HALF; @@ -185,32 +168,15 @@ const Handle = ({ justifyContent: 'center', }} onMouseDown={(e) => onMouseDown(e, handle)} - onPointerEnter={(e) => { - // 드래그 세션 중 enter는 즉시 적용하지 않고 보류 기록 - 릴리즈 후 - // resume 시점에 포인터가 핸들 안이면 그 hover를 적용한다 - if (isCustomCursorHoverSuspended()) { - const apply = () => { - pendingApplyRef.current = null; - setHovered(true); - }; - pendingApplyRef.current = apply; - setPendingCustomCursorHover(handle.cursor, apply, e.nativeEvent); - return; - } - setHovered(true); - setCustomCursorHover(handle.cursor, e.nativeEvent); - }} - onPointerLeave={(e) => { - // 자기 보류 기록만 소거 - 다른 핸들의 pending은 건드리지 않는다 - if (pendingApplyRef.current) { - clearPendingCustomCursorHover(pendingApplyRef.current); - pendingApplyRef.current = null; - } - setHovered(false); - setCustomCursorHover(null, e.nativeEvent); - }} + onPointerEnter={hover.onPointerEnter} + onPointerLeave={hover.onPointerLeave} > -
+
); }; @@ -221,12 +187,14 @@ const GroupResizeHandles = ({ statPositions, graphPositions, knobPositions, + spritePositions, selectedKeyType, pluginElements, zoom = 1, panX = 0, panY = 0, previewGroupBounds, + rotationFrame, onGroupResizeStart, onGroupResize, onGroupResizeEnd, @@ -242,6 +210,7 @@ const GroupResizeHandles = ({ knobPositions, selectedKeyType, pluginElements, + spritePositions, ); // 각 요소가 리사이즈 가능한지 확인 @@ -273,6 +242,8 @@ const GroupResizeHandles = ({ selectedKeyType, pluginElements, zoom, + spritePositions, + rotationFrame, onGroupResizeStart, onGroupResize, onGroupResizeEnd, @@ -282,12 +253,14 @@ const GroupResizeHandles = ({ if (!groupData || selectedElements.length < 2) return null; // 표시할 bounds (프리뷰 또는 실제) - const displayBounds: Bounds = previewGroupBounds || { - x: groupData.x, - y: groupData.y, - width: groupData.width, - height: groupData.height, - }; + const displayBounds: Bounds = previewGroupBounds || + rotationFrame?.bounds || { + x: groupData.x, + y: groupData.y, + width: groupData.width, + height: groupData.height, + }; + const rotation = rotationFrame?.rotation ?? 0; // 그룹 테두리 좌표 계산 - 내부 요소 테두리와 동일한 위치에 겹치게 const selectionLeft = displayBounds.x * zoom + panX - 2; @@ -309,12 +282,14 @@ const GroupResizeHandles = ({ <> {/* 그룹 바운딩 박스 테두리 */}
0 && HANDLES.map((handle) => { // 핸들을 테두리 중앙에 배치 - const centerX = handleAreaLeft + handleAreaWidth * handle.x; - const centerY = handleAreaTop + handleAreaHeight * handle.y; + const center = rotatePointAround( + { + x: handleAreaLeft + handleAreaWidth * handle.x, + y: handleAreaTop + handleAreaHeight * handle.y, + }, + { + x: selectionLeft + selectionWidth / 2, + y: selectionTop + selectionHeight / 2, + }, + rotation, + ); + const rotatedHandle = rotationFrame + ? { + ...handle, + cursor: resizeCursorForHandle(handle.dx, handle.dy, rotation), + } + : handle; return ( ); diff --git a/src/renderer/components/main/Grid/handles/NativeRotateHandle.test.tsx b/src/renderer/components/main/Grid/handles/NativeRotateHandle.test.tsx new file mode 100644 index 000000000..01cfb9d12 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/NativeRotateHandle.test.tsx @@ -0,0 +1,404 @@ +import React, { act, type ComponentProps } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; +import { + acquireHistoryEditorFlushLock, + releaseHistoryEditorFlushLock, + resetHistoryEditorFlushLock, +} from '@src/renderer/editor/runtime/lifecycle/historyEditorFlushLock'; +import { + releaseDragSession, + tryAcquireDragSession, +} from '@hooks/Grid/drag/dragSession'; +import { endDragCursor } from '@utils/dom/dragCursor'; +import { rotatePointAround } from '@utils/element/rotation'; +import { + getCursor, + setCustomCursorHover, + unlockCustomCursor, +} from '@utils/grid/cursorUtils'; +import NativeRotateHandle from './NativeRotateHandle'; + +const mocks = vi.hoisted(() => ({ + preview: vi.fn(), + patch: vi.fn(async () => {}), + activeGestureId: vi.fn<() => string | undefined>(), + settleCommit: vi.fn(), + cancel: vi.fn(), +})); + +// 저장·프리뷰 경계만 대체하고 포인터·프레임·소유권·커서는 실제 경로 사용 +vi.mock('../PropertiesPanel/selection/previewPatchForwarders', () => ({ + previewSingleStyleProperty: mocks.preview, +})); +vi.mock('@src/renderer/editor/runtime/operations/elementPaintStyleOps', () => ({ + patchStylePropertyById: mocks.patch, +})); +vi.mock('@src/renderer/editor/runtime/gesture/editGestureController', () => ({ + editGestureController: { + activeGestureId: mocks.activeGestureId, + settleCommit: mocks.settleCommit, + cancel: mocks.cancel, + }, +})); +vi.mock('@utils/core/platform', () => ({ isMac: () => true })); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +type Props = ComponentProps; +type PointerPosition = { clientX: number; clientY: number }; + +const ELEMENT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const GESTURE_ID = 'native-rotation-gesture'; +const HOST_ORIGIN = { x: 30, y: 40 }; +const BASE_PROPS: Props = { + elementType: 'key', + elementId: ELEMENT_ID, + bounds: { x: 20, y: 30, width: 100, height: 60 }, + rotation: 30, + zoom: 1.5, + panX: 7, + panY: -3, +}; +const CENTER = { + x: HOST_ORIGIN.x + (20 + 100 / 2) * 1.5 + 7, + y: HOST_ORIGIN.y + (30 + 60 / 2) * 1.5 - 3, +}; + +describe('NativeRotateHandle 모서리 회전', () => { + let container: HTMLDivElement; + let root: Root; + + const corners = () => + container.querySelectorAll('[data-rotate-corner]'); + + const render = (overrides: Partial = {}) => { + act(() => { + root.render(); + }); + const overlay = container.querySelector( + '[data-rotation-handles="native"]', + )!; + vi.spyOn(overlay, 'getBoundingClientRect').mockReturnValue( + new DOMRect(HOST_ORIGIN.x, HOST_ORIGIN.y, 1200, 900), + ); + }; + + const cornerPosition = (corner: HTMLDivElement): PointerPosition => { + const origin = Number.parseFloat(corner.style.transformOrigin); + const pivot = { + x: HOST_ORIGIN.x + Number.parseFloat(corner.style.left) + origin, + y: HOST_ORIGIN.y + Number.parseFloat(corner.style.top) + origin, + }; + const point = rotatePointAround( + { x: pivot.x - 18, y: pivot.y - 18 }, + pivot, + Number.parseFloat(corner.style.transform.slice(7)), + ); + return { clientX: point.x, clientY: point.y }; + }; + + const turnedPosition = ( + start: PointerPosition, + degrees: number, + ): PointerPosition => { + const angle = (degrees * Math.PI) / 180; + const x = start.clientX - CENTER.x; + const y = start.clientY - CENTER.y; + return { + clientX: CENTER.x + x * Math.cos(angle) - y * Math.sin(angle), + clientY: CENTER.y + x * Math.sin(angle) + y * Math.cos(angle), + }; + }; + + const pointer = ( + type: string, + target: EventTarget, + init: PointerEventInit = {}, + ) => { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + pointerId: 1, + ...init, + }); + act(() => { + target.dispatchEvent(event); + }); + return event; + }; + + const flushFrame = () => { + act(() => { + vi.advanceTimersByTime(16); + }); + }; + + const expectReleased = (corner: HTMLDivElement) => { + expect(corner.hasPointerCapture(1)).toBe(false); + expect(document.documentElement.classList.contains('dmn-drag-cursor')).toBe( + false, + ); + expect( + document.documentElement.style.getPropertyValue('--dmn-drag-cursor'), + ).toBe(''); + expect(document.body.classList.contains('dmn-custom-cursor')).toBe(false); + expect(tryAcquireDragSession()).toBe(true); + releaseDragSession(); + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 16), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + mocks.activeGestureId.mockReturnValue(undefined); + mocks.preview.mockImplementation(() => { + mocks.activeGestureId.mockReturnValue(GESTURE_ID); + }); + mocks.cancel.mockImplementation(() => { + mocks.activeGestureId.mockReturnValue(undefined); + }); + resetHistoryEditorFlushLock(); + releaseDragSession(); + useCommittedApplyStore.setState({ commitTick: 0, historyTick: 0 }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + resetHistoryEditorFlushLock(); + releaseDragSession(); + unlockCustomCursor(); + setCustomCursorHover(null); + endDragCursor(); + act(() => vi.runOnlyPendingTimers()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it.each(['key', 'stat', 'graph', 'knob'] as const)( + '%s의 모서리에서 시작해 마지막 프레임을 한 번 커밋하고 커서를 해제한다', + (elementType) => { + render({ elementType }); + const index = ['key', 'stat', 'graph', 'knob'].indexOf(elementType); + const corner = corners()[index]; + const cursor = getCursor( + (['rotate-45', 'rotate-135', 'rotate-225', 'rotate-315'] as const)[ + index + ], + ); + const start = cornerPosition(corner); + const bubbledDown = vi.fn(); + document.body.addEventListener('pointerdown', bubbledDown); + + expect(corners()).toHaveLength(4); + expect(container.querySelector('[data-native-rotate-knob]')).toBeNull(); + expect( + corner.closest('[data-dmn-canvas-editor-overlay="true"]'), + ).not.toBeNull(); + expect(pointer('pointerdown', corner, start).defaultPrevented).toBe(true); + document.body.removeEventListener('pointerdown', bubbledDown); + expect(bubbledDown).not.toHaveBeenCalled(); + expect(corner.hasPointerCapture(1)).toBe(true); + expect( + document.documentElement.style.getPropertyValue('--dmn-drag-cursor'), + ).toBe(cursor); + expect(document.body.classList.contains('dmn-custom-cursor')).toBe(true); + + pointer('pointermove', window, turnedPosition(start, 20)); + flushFrame(); + expect(mocks.preview).toHaveBeenLastCalledWith(elementType, ELEMENT_ID, { + property: 'rotation', + value: expect.closeTo(50, 6), + }); + expect(corners()[index]).toBe(corner); + expect(corner.hasPointerCapture(1)).toBe(true); + + expect( + document.documentElement.style.getPropertyValue('--dmn-drag-cursor'), + ).toBe(cursor); + + pointer('pointermove', window, turnedPosition(start, 90)); + expect(mocks.patch).not.toHaveBeenCalled(); + pointer('pointerup', window, turnedPosition(start, 90)); + pointer('pointerup', window, turnedPosition(start, 90)); + act(() => window.dispatchEvent(new MouseEvent('mouseup', { button: 0 }))); + flushFrame(); + + expect(mocks.preview).toHaveBeenCalledTimes(2); + expect(mocks.patch).toHaveBeenCalledExactlyOnceWith( + elementType, + ELEMENT_ID, + { property: 'rotation', value: expect.closeTo(120, 6) }, + { gestureId: GESTURE_ID }, + ); + expect(mocks.settleCommit).toHaveBeenCalledExactlyOnceWith( + mocks.patch.mock.results[0].value, + ); + expect(mocks.cancel).not.toHaveBeenCalled(); + expectReleased(corner); + }, + ); + + it('Shift 회전은 절대각을 15°에 스냅하고 up의 보조키와 무관하게 저장한다', () => { + render({ rotation: 7 }); + const corner = corners()[2]; + const start = cornerPosition(corner); + pointer('pointerdown', corner, start); + pointer('pointermove', window, { + ...turnedPosition(start, 22), + shiftKey: true, + }); + pointer('pointerup', window, turnedPosition(start, 22)); + + expect(mocks.patch).toHaveBeenCalledExactlyOnceWith( + 'key', + ELEMENT_ID, + { property: 'rotation', value: 30 }, + { gestureId: GESTURE_ID }, + ); + expectReleased(corner); + }); + + it.each([ + 'historyUndo', + 'historyFlush', + 'pointercancel', + 'escape', + 'blur', + 'targetChange', + 'unmount', + ] as const)( + '%s 뒤 늦은 move·up은 취소한 회전을 다시 저장하지 않는다', + (reason) => { + render(); + const corner = corners()[0]; + const start = cornerPosition(corner); + pointer('pointerdown', corner, start); + pointer('pointermove', window, turnedPosition(start, 45)); + flushFrame(); + pointer('pointermove', window, turnedPosition(start, 90)); + + switch (reason) { + case 'historyUndo': + act(() => useCommittedApplyStore.getState().bump('historyUndo')); + break; + case 'historyFlush': + act(() => { + acquireHistoryEditorFlushLock('rotation-history-flush'); + }); + releaseHistoryEditorFlushLock('rotation-history-flush'); + break; + case 'pointercancel': + pointer('pointercancel', window); + break; + case 'escape': + act(() => + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape' }), + ), + ); + break; + case 'blur': + act(() => window.dispatchEvent(new Event('blur'))); + break; + case 'targetChange': + render({ elementId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' }); + break; + case 'unmount': + act(() => root.render(null)); + break; + } + + pointer('pointermove', window, turnedPosition(start, 120)); + pointer('pointerup', window, turnedPosition(start, 120)); + flushFrame(); + + expect(mocks.preview).toHaveBeenCalledOnce(); + expect(mocks.cancel).toHaveBeenCalledOnce(); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(mocks.settleCommit).not.toHaveBeenCalled(); + expectReleased(corner); + }, + ); + + it('움직이지 않은 클릭은 편집을 만들지 않고 캡처와 커서만 해제한다', () => { + render(); + const corner = corners()[0]; + const start = cornerPosition(corner); + pointer('pointerdown', corner, start); + pointer('pointerup', window, start); + + expect(mocks.preview).not.toHaveBeenCalled(); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(mocks.cancel).not.toHaveBeenCalled(); + expectReleased(corner); + }); + + it('다른 포인터는 시작한 드래그를 변경하거나 끝내지 못한다', () => { + render(); + const corner = corners()[1]; + const start = cornerPosition(corner); + pointer('pointerdown', corner, start); + pointer('pointermove', window, { + ...turnedPosition(start, 90), + pointerId: 2, + }); + pointer('pointerup', window, { ...start, pointerId: 2 }); + flushFrame(); + + expect(mocks.preview).not.toHaveBeenCalled(); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(corner.hasPointerCapture(1)).toBe(true); + expect(tryAcquireDragSession()).toBe(false); + + pointer('pointercancel', window); + expectReleased(corner); + }); + + it('모서리 호버는 회전 커서를 켜고 드래그 중에는 영역을 벗어나도 유지한다', () => { + render(); + const corner = corners()[0]; + const start = cornerPosition(corner); + pointer('pointerover', corner, start); + expect(document.body.classList.contains('dmn-custom-cursor')).toBe(true); + pointer('pointerout', corner, { relatedTarget: document.body }); + expect(document.body.classList.contains('dmn-custom-cursor')).toBe(false); + + pointer('pointerover', corner, start); + pointer('pointerdown', corner, start); + pointer('pointerout', corner, { relatedTarget: document.body }); + expect(document.body.classList.contains('dmn-custom-cursor')).toBe(true); + pointer('pointermove', window, turnedPosition(start, 45)); + pointer('pointerup', window, turnedPosition(start, 45)); + + expect(mocks.patch).toHaveBeenCalledOnce(); + expectReleased(corner); + }); + + it('호버 중 선택 핸들이 사라지면 회전 커서도 남지 않는다', () => { + render(); + const corner = corners()[3]; + pointer('pointerover', corner, cornerPosition(corner)); + expect(document.body.classList.contains('dmn-custom-cursor')).toBe(true); + + act(() => root.render(null)); + + expect(mocks.patch).not.toHaveBeenCalled(); + expectReleased(corner); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/NativeRotateHandle.tsx b/src/renderer/components/main/Grid/handles/NativeRotateHandle.tsx new file mode 100644 index 000000000..9cae291b8 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/NativeRotateHandle.tsx @@ -0,0 +1,61 @@ +import { editGestureController } from '@src/renderer/editor/runtime/gesture/editGestureController'; +import { patchStylePropertyById } from '@src/renderer/editor/runtime/operations/elementPaintStyleOps'; +import { previewSingleStyleProperty } from '../PropertiesPanel/selection/previewPatchForwarders'; +import CanvasRotateHandle from './CanvasRotateHandle'; +import type { Bounds } from './groupResizeUtils'; +import type { RotatableElementType } from './rotatableElement'; + +interface NativeRotateHandleProps { + elementType: RotatableElementType; + elementId: string; + bounds: Bounds; + rotation: number; + zoom: number; + panX: number; + panY: number; +} + +const NativeRotateHandle = ({ + elementType, + elementId, + bounds, + rotation, + zoom, + panX, + panY, +}: NativeRotateHandleProps) => ( + ({ + preview: (value) => { + previewSingleStyleProperty(elementType, elementId, { + property: 'rotation', + value, + }); + return true; + }, + commit: (value) => { + const gestureId = editGestureController.activeGestureId(); + if (!gestureId) return; + const persisted = patchStylePropertyById( + elementType, + elementId, + { property: 'rotation', value }, + { gestureId }, + ); + editGestureController.settleCommit(persisted); + void persisted.catch((error) => { + console.error('Failed to rotate element', error); + }); + }, + cancel: () => editGestureController.cancel(), + })} + /> +); + +export default NativeRotateHandle; diff --git a/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx index 08a6407c3..8f51e5de7 100644 --- a/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx +++ b/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx @@ -13,6 +13,9 @@ import { setCustomCursorHover, suspendCustomCursorHover, } from '@utils/grid/cursorUtils'; +import { isSameAspect } from './aspectResize'; +import { isBoundsWithinEditorLimits } from './resizeLimits'; +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; import ResizeHandles from './ResizeHandles'; describe('ResizeHandles frame coalescing', () => { @@ -83,6 +86,65 @@ describe('ResizeHandles frame coalescing', () => { act(() => document.dispatchEvent(new MouseEvent('mouseup'))); }); + it.each([false, true])( + '회전된 리사이즈는 최종 저장 좌표 한계 안에서 멈춘다 (비율 고정 %s)', + async (keepAspect) => { + const onResize = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + const handle = host.querySelector( + '[data-resize-handle="e"]', + )!; + act(() => + handle.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true }), + ), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientY: 100, shiftKey: keepAspect }), + ); + act(() => document.dispatchEvent(new MouseEvent('mouseup'))); + const result = onResize.mock.lastCall?.[0]; + expect(result).toBeDefined(); + expect(isBoundsWithinEditorLimits(result)).toBe(true); + expect(result.x).toBeCloseTo(-EDITOR_BOUNDS_LIMITS.maxAbsCoordinate); + expect(result.width).toBeCloseTo(120); + expect(result.height).toBeCloseTo(keepAspect ? 120 : 100); + expect(result.x + result.width / 2).toBeCloseTo( + -EDITOR_BOUNDS_LIMITS.maxAbsCoordinate + 60, + ); + expect(result.y + result.height / 2 - result.width / 2).toBeCloseTo(0); + onResize.mockClear(); + await act(async () => { + root.render( + , + ); + }); + act(() => + handle.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true }), + ), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientY: 100, shiftKey: keepAspect }), + ); + act(() => document.dispatchEvent(new MouseEvent('mouseup'))); + expect(onResize).not.toHaveBeenCalled(); + }, + ); + it('가로 전용 핸들은 높이·Y를 유지한다 (비배수 높이 1px 밀림 방지)', async () => { const onResize = vi.fn(); await act(async () => { @@ -346,7 +408,7 @@ describe('ResizeHandles 핸들 호버 생명주기', () => { // 포인터가 핸들 안에 머문 릴리즈 - 커서와 하이라이트 모두 복원 expect(hasCursorBodyClass()).toBe(true); const visual = handle.firstElementChild as HTMLElement; - expect(visual.style.backgroundColor).not.toBe('white'); + expect(visual.style.backgroundColor).toBe('white'); }); it('억제 중 enter 후 leave가 오면 resume에도 hover가 없다', async () => { @@ -402,3 +464,229 @@ describe('ResizeHandles 핸들 호버 생명주기', () => { expect(hasCursorBodyClass()).toBe(false); }); }); + +describe('ResizeHandles 자리 양보', () => { + it('occupiedHandle 자리의 핸들은 그리지 않고 나머지 7개만 그린다', async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + await act(async () => { + root.render( + , + ); + }); + expect(host.querySelector('[data-resize-handle="ne"]')).toBeNull(); + expect(host.querySelectorAll('[data-resize-handle]').length).toBe(7); + await act(async () => root.unmount()); + host.remove(); + }); +}); + +describe('ResizeHandles 비율 고정', () => { + let host: HTMLDivElement; + let root: Root; + let callbacks: Map; + + beforeEach(() => { + callbacks = new Map(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.set(1, callback); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => callbacks.delete(id)); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + interface DragOptions { + bounds: { x: number; y: number; width: number; height: number }; + lockAspect?: boolean; + handle: string; + dx: number; + dy: number; + shiftKey?: boolean; + } + + // 그리드 스냅 5(store 기본값), 줌 1. 마지막 프리뷰 bounds를 돌려준다 + const drag = async ({ + bounds, + lockAspect = false, + handle, + dx, + dy, + shiftKey = false, + }: DragOptions) => { + const onResize = vi.fn(); + const onResizeStart = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + const element = host.querySelector( + `[data-resize-handle="${handle}"]`, + )!; + act(() => + element.dispatchEvent( + new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + bubbles: true, + cancelable: true, + }), + ), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: dx, clientY: dy, shiftKey }), + ); + const callback = callbacks.get(1)!; + callbacks.clear(); + act(() => callback(performance.now())); + act(() => document.dispatchEvent(new MouseEvent('mouseup'))); + const last = onResize.mock.calls.at(-1)?.[0] as + | (DragOptions['bounds'] & { aspect?: { primary: string } }) + | undefined; + return { last, onResize, onResizeStart }; + }; + + const thin = { x: 0, y: 0, width: 400, height: 0.1 }; + + it('가로 핸들은 파생 높이에 하한을 먹이지 않고 비율을 지킨다', async () => { + const { last } = await drag({ + bounds: thin, + lockAspect: true, + handle: 'e', + dx: 10, + dy: 0, + }); + expect(last!.width).toBe(410); + expect(last!.height).toBeCloseTo(0.1025, 12); + expect(isSameAspect(thin, last!)).toBe(true); + expect(last!.aspect?.primary).toBe('width'); + }); + + it('세로 핸들 1px는 그리드 스냅이 0으로 무너져 스냅 없이 배율 11', async () => { + const { last } = await drag({ + bounds: thin, + lockAspect: true, + handle: 's', + dx: 0, + dy: 1, + }); + expect(last!.width).toBeCloseTo(4400, 9); + expect(last!.height).toBeCloseTo(1.1, 12); + expect(last!.x).toBeCloseTo(-2000, 9); + expect(isSameAspect(thin, last!)).toBe(true); + }); + + it('세로 핸들 3px는 5로 스냅돼 배율 50', async () => { + const { last } = await drag({ + bounds: thin, + lockAspect: true, + handle: 's', + dx: 0, + dy: 3, + }); + expect(last!.width).toBeCloseTo(20000, 9); + expect(last!.height).toBe(5); + expect(last!.x).toBeCloseTo(-9800, 9); + }); + + it('상한에 닿으면 폭 32768에서 멈추고 백엔드 검증을 통과한다', async () => { + const { last } = await drag({ + bounds: thin, + lockAspect: true, + handle: 's', + dx: 0, + dy: 100, + }); + expect(last!.width).toBeLessThanOrEqual(32768); + expect(last!.width).toBeGreaterThan(32767); + expect(isBoundsWithinEditorLimits(last!)).toBe(true); + expect(isSameAspect(thin, last!)).toBe(true); + }); + + it('왼쪽 핸들은 움직이는 가장자리를 스냅하고 오른쪽 가장자리를 고정한다', async () => { + const start = { x: 3, y: 0, width: 200, height: 150 }; + // 폭 190 후보 → 왼쪽 가장자리 13 → 스냅 15 → 폭 188, 배율 0.94 + const { last } = await drag({ + bounds: start, + lockAspect: true, + handle: 'w', + dx: 10, + dy: 0, + }); + expect(last).toMatchObject({ x: 15, width: 188 }); + expect(last!.height).toBeCloseTo(141, 12); + expect(last!.y).toBeCloseTo(4.5, 12); + expect(last!.x + last!.width).toBe(203); + }); + + it('Shift 비율 유지도 같은 경로를 탄다 (모서리는 변화 큰 축 기준)', async () => { + const start = { x: 0, y: 0, width: 100, height: 100 }; + const { last } = await drag({ + bounds: start, + handle: 'se', + dx: 20, + dy: 30, + shiftKey: true, + }); + expect(last).toMatchObject({ x: 0, y: 0, width: 130, height: 130 }); + }); + + it('이미 상한 밖인 legacy 요소는 확대 드래그에 움직이지 않는다', async () => { + const legacy = { x: 0, y: 0, width: 40000, height: 10 }; + const { onResize, onResizeStart } = await drag({ + bounds: legacy, + lockAspect: true, + handle: 'e', + dx: 10, + dy: 0, + }); + expect(onResize).not.toHaveBeenCalled(); + expect(onResizeStart).not.toHaveBeenCalled(); + }); + + it('연속 축소해도 하한 축이 10 아래로 반올림되지 않는다', async () => { + // 100x77 을 왼쪽 핸들로 크게 줄이면 높이가 10에서 멈춘다. 그 결과를 다시 줄여도 그대로 + const first = await drag({ + bounds: { x: 0, y: 0, width: 100, height: 77 }, + lockAspect: true, + handle: 'w', + dx: 95, + dy: 0, + }); + expect(first.last!.height).toBeGreaterThanOrEqual(10); + let bounds = { ...first.last! }; + for (let round = 0; round < 3; round += 1) { + const next = await drag({ + bounds, + lockAspect: true, + handle: 'w', + dx: 95, + dy: 0, + }); + if (next.last) bounds = { ...next.last }; + expect(bounds.height).toBeGreaterThanOrEqual(10); + expect(bounds.width).toBeGreaterThan(12.9); + } + }); +}); diff --git a/src/renderer/components/main/Grid/handles/ResizeHandles.tsx b/src/renderer/components/main/Grid/handles/ResizeHandles.tsx index 7bb0ae95c..5a4a049c4 100644 --- a/src/renderer/components/main/Grid/handles/ResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/ResizeHandles.tsx @@ -1,21 +1,35 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { useLayoutEffect, useRef } from 'react'; import { isMac } from '@utils/core/platform'; import { snapToGrid } from '@hooks/Grid/utils'; import { useSettingsStore } from '@stores/useSettingsStore'; import { - clearPendingCustomCursorHover, CursorType, getCursor, - isCustomCursorHoverSuspended, lockCustomCursor, - setCustomCursorHover, - setPendingCustomCursorHover, unlockCustomCursor, } from '@utils/grid/cursorUtils'; +import { useCustomCursorHover } from '@hooks/Grid/viewport/useCustomCursorHover'; import { createRafLatestScheduler, type ContinuousInputStrategy, } from '@utils/animation/rafLatestScheduler'; +import { + aspectScaleFromPrimary, + aspectScaleRange, + exactSizeFor, + scaleBoundsAnchored, + settleAspectScale, + type AspectPrimaryAxis, + type ScaleRange, +} from './aspectResize'; +import { SELECTION_BORDER_CENTER } from './selectionOutline'; +import { rotatePointAround } from '@utils/element/rotation'; +import { + anchorRotatedResize, + constrainRotatedResize, + resizeCursorForHandle, + screenDeltaToLocal, +} from './rotatedResize'; /** * 8방향 리사이즈 핸들을 표시하는 컴포넌트 @@ -48,12 +62,20 @@ interface ResizeResult { handle: HandleDef; /** 플랫폼 primary modifier 유지 중 - 스마트 가이드·크기 일치 스냅 일시 해제 */ suppressSmartSnap: boolean; + /** 비율 고정 중 - 스마트 스냅은 기준 축만 받고 반대 축은 같은 배율로 다시 구한다 */ + aspect?: { + start: Bounds; + primary: AspectPrimaryAxis; + range: ScaleRange; + }; } interface HandleProps { handle: HandleDef; centerX: number; centerY: number; + // 요소 회전(도) - 핸들 모양이 틀과 같은 각도로 돈다 + rotation: number; onMouseDown: (e: React.MouseEvent, handle: HandleDef) => void; } @@ -79,6 +101,12 @@ interface ResizeHandlesProps { height: number; }[]; continuousInputStrategy?: ContinuousInputStrategy; + /** 항상 비율 유지 - 그림 레이어(스프라이트)처럼 늘리면 안 되는 요소 */ + lockAspect?: boolean; + /** 다른 표식이 차지한 자리(정규화 좌표) - 그 자리 핸들은 그리지 않는다 */ + occupiedHandle?: { x: number; y: number } | null; + /** 요소 회전(도). 틀·핸들·드래그 축이 함께 돌고 저장 상자는 축 정렬 유지 */ + rotation?: number; } interface ResizeState { @@ -138,15 +166,11 @@ const HANDLES: HandleDef[] = [ ]; // 핸들 시각적 스타일 반환 -const getHandleStyle = ( - type: string, - isHovered: boolean, -): React.CSSProperties => { +const getHandleStyle = (type: string): React.CSSProperties => { const baseStyle: React.CSSProperties = { - backgroundColor: isHovered ? 'var(--ui-selection)' : 'white', + backgroundColor: 'white', border: '1.5px solid var(--ui-selection-border-strong)', pointerEvents: 'none', - transition: 'background-color 0.15s ease', }; if (type === 'corner') { @@ -175,27 +199,15 @@ const getHandleStyle = ( } }; -// 개별 핸들 컴포넌트 (호버 상태 관리) -const Handle = ({ handle, centerX, centerY, onMouseDown }: HandleProps) => { - const [isHovered, setIsHovered] = useState(false); - const hoveredRef = useRef(false); - const pendingApplyRef = useRef<(() => void) | null>(null); - - // 호버 중 unmount로 leave가 유실되면 남는 커서 오버레이·보류 기록 정리 - useEffect(() => { - return () => { - if (pendingApplyRef.current) { - clearPendingCustomCursorHover(pendingApplyRef.current); - pendingApplyRef.current = null; - } - if (hoveredRef.current) setCustomCursorHover(null); - }; - }, []); - - const setHovered = (next: boolean) => { - hoveredRef.current = next; - setIsHovered(next); - }; +// 개별 핸들 컴포넌트 +const Handle = ({ + handle, + centerX, + centerY, + rotation, + onMouseDown, +}: HandleProps) => { + const hover = useCustomCursorHover(handle.cursor); const hitX = centerX - HANDLE_HIT_HALF; const hitY = centerY - HANDLE_HIT_HALF; @@ -220,33 +232,16 @@ const Handle = ({ handle, centerX, centerY, onMouseDown }: HandleProps) => { justifyContent: 'center', }} onMouseDown={(e) => onMouseDown(e, handle)} - onPointerEnter={(e) => { - // 드래그 세션 중 enter는 즉시 적용하지 않고 보류 기록 - 릴리즈 후 - // resume 시점에 포인터가 핸들 안이면 그 hover를 적용한다 - if (isCustomCursorHoverSuspended()) { - const apply = () => { - pendingApplyRef.current = null; - setHovered(true); - }; - pendingApplyRef.current = apply; - setPendingCustomCursorHover(handle.cursor, apply, e.nativeEvent); - return; - } - setHovered(true); - setCustomCursorHover(handle.cursor, e.nativeEvent); - }} - onPointerLeave={(e) => { - // 자기 보류 기록만 소거 - 다른 핸들의 pending은 건드리지 않는다 - if (pendingApplyRef.current) { - clearPendingCustomCursorHover(pendingApplyRef.current); - pendingApplyRef.current = null; - } - setHovered(false); - setCustomCursorHover(null, e.nativeEvent); - }} + onPointerEnter={hover.onPointerEnter} + onPointerLeave={hover.onPointerLeave} > {/* 시각적 핸들 (히트 영역 중앙에 배치) */} -
+
); }; @@ -263,6 +258,9 @@ const ResizeHandles = ({ elementId: _elementId, // 스마트 가이드용 요소 ID getOtherElements: _getOtherElements, // 스마트 가이드용 다른 요소 가져오기 함수 continuousInputStrategy = 'frame', + lockAspect = false, + occupiedHandle = null, + rotation = 0, }: ResizeHandlesProps) => { const resizeRef = useRef({ isResizing: false, @@ -315,9 +313,15 @@ const ResizeHandles = ({ startAspectRatio, } = resizeRef.current; - // 마우스 이동량 계산 (줌 보정) - const rawDeltaX = (moveEvent.clientX - startMouseX) / zoom; - const rawDeltaY = (moveEvent.clientY - startMouseY) / zoom; + // 마우스 이동량 계산 (줌 보정). 회전 요소는 틀의 로컬 축으로 되돌려 + // 아래 축 정렬 수식을 그대로 쓴다 + const screenDelta = screenDeltaToLocal( + (moveEvent.clientX - startMouseX) / zoom, + (moveEvent.clientY - startMouseY) / zoom, + rotation, + ); + const rawDeltaX = screenDelta.x; + const rawDeltaY = screenDelta.y; // store에서 스냅 크기 가져오기 const snapSize = @@ -325,25 +329,8 @@ const ResizeHandles = ({ const snap = (value: number): number => snapToGrid(value, snapSize); - // 새 bounds 계산 (스냅 전) - let nextWidth = startBounds!.width; - let nextHeight = startBounds!.height; - - // 핸들 방향에 따라 크기 조정 (크기만 계산) - if (handle!.dx === -1) { - nextWidth = Math.max(MIN_SIZE, startBounds!.width - rawDeltaX); - } else if (handle!.dx === 1) { - nextWidth = Math.max(MIN_SIZE, startBounds!.width + rawDeltaX); - } - - if (handle!.dy === -1) { - nextHeight = Math.max(MIN_SIZE, startBounds!.height - rawDeltaY); - } else if (handle!.dy === 1) { - nextHeight = Math.max(MIN_SIZE, startBounds!.height + rawDeltaY); - } - const keepAspect = - !!moveEvent.shiftKey && + (lockAspect || !!moveEvent.shiftKey) && Number.isFinite(startAspectRatio) && startAspectRatio > 0 && Number.isFinite(startBounds!.width) && @@ -351,12 +338,31 @@ const ResizeHandles = ({ startBounds!.width > 0 && startBounds!.height > 0; - let newWidth = nextWidth; - let newHeight = nextHeight; + let newX = startBounds!.x; + let newY = startBounds!.y; + let newWidth = startBounds!.width; + let newHeight = startBounds!.height; + let aspect: ResizeResult['aspect']; if (keepAspect) { + // 비율 고정은 배율 하나로만 움직인다. 하한 floor 없는 raw 후보로 기준 축을 + // 고르고(변 핸들은 그 축, 모서리는 상대 변화가 큰 축) 공용 규칙으로 배율을 + // 자른 뒤 잡지 않은 가장자리를 고정한다. 파생 축에 하한을 따로 먹이면 + // 얇은 요소의 비율이 깨지고, 상한이 없으면 백엔드가 커밋을 거부한다 + const rawWidth = + handle!.dx === -1 + ? startBounds!.width - rawDeltaX + : handle!.dx === 1 + ? startBounds!.width + rawDeltaX + : startBounds!.width; + const rawHeight = + handle!.dy === -1 + ? startBounds!.height - rawDeltaY + : handle!.dy === 1 + ? startBounds!.height + rawDeltaY + : startBounds!.height; const isCorner = handle!.dx !== 0 && handle!.dy !== 0; - const primary = + const primary: AspectPrimaryAxis = handle!.dx !== 0 && handle!.dy === 0 ? 'width' : handle!.dy !== 0 && handle!.dx === 0 @@ -364,24 +370,76 @@ const ResizeHandles = ({ : isCorner ? (() => { const relW = - Math.abs(newWidth - startBounds!.width) / startBounds!.width; + Math.abs(rawWidth - startBounds!.width) / startBounds!.width; const relH = - Math.abs(newHeight - startBounds!.height) / + Math.abs(rawHeight - startBounds!.height) / startBounds!.height; return relW >= relH ? 'width' : 'height'; })() : 'width'; + // 그리드 스냅 기준은 방향별 현행 유지 - 오른쪽·아래는 크기, 왼쪽·위는 + // 움직이는 가장자리 좌표 + const snapPrimarySize = (size: number): number => { + if (primary === 'width') { + if (handle!.dx !== -1) return snap(size); + const right = startBounds!.x + startBounds!.width; + return right - snap(right - size); + } + if (handle!.dy !== -1) return snap(size); + const bottom = startBounds!.y + startBounds!.height; + return bottom - snap(bottom - size); + }; + const range = aspectScaleRange( + startBounds!, + handle!, + MIN_SIZE, + rotation, + ); + const primaryScale = aspectScaleFromPrimary( + startBounds!, + primary, + primary === 'width' ? rawWidth : rawHeight, + snapPrimarySize, + range, + ); + const scale = settleAspectScale( + startBounds!, + primaryScale.scale, + handle!, + range, + primaryScale.exact, + rotation, + ); + const scaled = scaleBoundsAnchored( + startBounds!, + scale, + handle!, + exactSizeFor( + startBounds!, + scale, + primaryScale.scale, + primaryScale.exact, + ), + ); + newX = scaled.x; + newY = scaled.y; + newWidth = scaled.width; + newHeight = scaled.height; + aspect = { start: { ...startBounds! }, primary, range }; + } else { + // 핸들 방향에 따라 크기 조정 (크기만 계산) + if (handle!.dx === -1) { + newWidth = Math.max(MIN_SIZE, startBounds!.width - rawDeltaX); + } else if (handle!.dx === 1) { + newWidth = Math.max(MIN_SIZE, startBounds!.width + rawDeltaX); + } - if (primary === 'width') { - newWidth = Math.max(MIN_SIZE, snap(newWidth)); - const scale = newWidth / startBounds!.width; - newHeight = Math.max(MIN_SIZE, startBounds!.height * scale); - } else { - newHeight = Math.max(MIN_SIZE, snap(newHeight)); - const scale = newHeight / startBounds!.height; - newWidth = Math.max(MIN_SIZE, startBounds!.width * scale); + if (handle!.dy === -1) { + newHeight = Math.max(MIN_SIZE, startBounds!.height - rawDeltaY); + } else if (handle!.dy === 1) { + newHeight = Math.max(MIN_SIZE, startBounds!.height + rawDeltaY); } - } else { + // 드래그한 축만 스냅 - 반대 축까지 스냅하면 비배수 크기의 요소가 // 가로/세로 전용 리사이즈마다 1px씩 밀린다 (중앙 보정이 절반을 이동) if (handle!.dx !== 0) { @@ -390,41 +448,45 @@ const ResizeHandles = ({ if (handle!.dy !== 0) { newHeight = Math.max(MIN_SIZE, snap(newHeight)); } - } - // 위치 계산 (앵커 엣지 보존: 드래그하지 않은 쪽은 고정) - let newX = startBounds!.x; - let newY = startBounds!.y; + // 위치 계산 (앵커 엣지 보존: 드래그하지 않은 쪽은 고정) + if (handle!.dx === -1) { + // 좌측 핸들: 우측 엣지가 앵커 - 위치 스냅 후 width를 앵커에서 역산 + const rightAnchor = startBounds!.x + startBounds!.width; + newX = snap(rightAnchor - newWidth); + newWidth = rightAnchor - newX; + } + // dx === 1: newX = startBounds.x (좌측 앵커 유지) - if (handle!.dx === -1) { - // 좌측 핸들: 우측 엣지가 앵커 — 위치 스냅 후 width를 앵커에서 역산 - const rightAnchor = startBounds!.x + startBounds!.width; - newX = snap(rightAnchor - newWidth); - newWidth = rightAnchor - newX; - } else if (handle!.dx === 0) { - // 비례 유지 시 중앙 정렬 (스냅 없이 앵커 보존) - newX = startBounds!.x + (startBounds!.width - newWidth) / 2; - } - // dx === 1: newX = startBounds.x (좌측 앵커 유지) - - if (handle!.dy === -1) { - // 상단 핸들: 하단 엣지가 앵커 — 위치 스냅 후 height를 앵커에서 역산 - const bottomAnchor = startBounds!.y + startBounds!.height; - newY = snap(bottomAnchor - newHeight); - newHeight = bottomAnchor - newY; - } else if (handle!.dy === 0) { - // 비례 유지 시 중앙 정렬 (스냅 없이 앵커 보존) - newY = startBounds!.y + (startBounds!.height - newHeight) / 2; + if (handle!.dy === -1) { + // 상단 핸들: 하단 엣지가 앵커 - 위치 스냅 후 height를 앵커에서 역산 + const bottomAnchor = startBounds!.y + startBounds!.height; + newY = snap(bottomAnchor - newHeight); + newHeight = bottomAnchor - newY; + } + // dy === 1: newY = startBounds.y (상단 앵커 유지) } - // dy === 1: newY = startBounds.y (상단 앵커 유지) - const result = { - x: newX, - y: newY, - width: newWidth, - height: newHeight, + // 회전 요소는 반대 변이 화면에서 고정되도록 중심 이동만 회전시킨다. + // 스마트 가이드는 논리 상자 변이 화면 변과 다르므로 회전 중엔 쓰지 않는다 + const rotated = anchorRotatedResize( + startBounds!, + { x: newX, y: newY, width: newWidth, height: newHeight }, + rotation, + ); + const anchored = + rotation !== 0 && !keepAspect + ? constrainRotatedResize(startBounds!, rotated) + : rotated; + const result: ResizeResult = { + x: anchored.x, + y: anchored.y, + width: anchored.width, + height: anchored.height, handle: handle!, - suppressSmartSnap: isMac() ? moveEvent.metaKey : moveEvent.ctrlKey, + suppressSmartSnap: + rotation !== 0 || (isMac() ? moveEvent.metaKey : moveEvent.ctrlKey), + aspect, }; const changed = result.x !== startBounds!.x || @@ -472,9 +534,8 @@ const ResizeHandles = ({ // 프리뷰 bounds가 있으면 프리뷰용으로 사용, 없으면 실제 bounds 사용 const displayBounds = previewBounds || bounds; - // 선택 테두리의 중심선 기준 좌표 (테두리 두께 2px의 중심 = 1px) - const borderThickness = 2; - const borderCenter = borderThickness / 2; // 테두리의 중심선까지의 거리 + // 선택 테두리의 중심선 기준 좌표 - 핸들은 테두리 선 위에 앉는다 + const borderCenter = SELECTION_BORDER_CENTER; const selectionLeft = displayBounds.x * zoom + panX - borderCenter; const selectionTop = displayBounds.y * zoom + panY - borderCenter; const selectionWidth = displayBounds.width * zoom + borderCenter * 2; @@ -482,17 +543,40 @@ const ResizeHandles = ({ return ( <> - {HANDLES.map((handle) => { - // 핸들 중심 위치 계산 (선택 테두리의 가장자리 중앙에 배치) - const centerX = selectionLeft + selectionWidth * handle.x; - const centerY = selectionTop + selectionHeight * handle.y; + {HANDLES.filter( + (handle) => + !occupiedHandle || + handle.x !== occupiedHandle.x || + handle.y !== occupiedHandle.y, + ).map((handle) => { + // 핸들 중심 위치 계산 (선택 테두리의 가장자리 중앙에 배치). 회전 요소는 + // 틀 중심 기준으로 같이 돌고 커서도 화면 방향에 맞춘다 + const center = rotatePointAround( + { + x: selectionLeft + selectionWidth * handle.x, + y: selectionTop + selectionHeight * handle.y, + }, + { + x: selectionLeft + selectionWidth / 2, + y: selectionTop + selectionHeight / 2, + }, + rotation, + ); + const rotatedHandle = + rotation === 0 + ? handle + : { + ...handle, + cursor: resizeCursorForHandle(handle.dx, handle.dy, rotation), + }; return ( ); diff --git a/src/renderer/components/main/Grid/handles/RotateCornerHandles.tsx b/src/renderer/components/main/Grid/handles/RotateCornerHandles.tsx new file mode 100644 index 000000000..1694a479b --- /dev/null +++ b/src/renderer/components/main/Grid/handles/RotateCornerHandles.tsx @@ -0,0 +1,88 @@ +import type { PointerEvent } from 'react'; +import { useCustomCursorHover } from '@hooks/Grid/viewport/useCustomCursorHover'; +import type { Point } from '@utils/element/rotation'; +import { + getCursor, + rotationCursorForAngle, + type RotationCursorType, +} from '@utils/grid/cursorUtils'; +import { + ROTATE_CORNER_INNER_REACH, + ROTATE_CORNER_OUTER_REACH, + rotateCornerGeometry, +} from './rotateCornerGeometry'; + +interface RotateCornerProps { + corner: Point & { rotation: number }; + index: number; + label: string; + innerReach: number; + onPointerDown: ( + event: PointerEvent, + cursor: RotationCursorType, + ) => void; +} + +const RotateCorner = ({ + corner, + index, + label, + innerReach, + onPointerDown, +}: RotateCornerProps) => { + const cursor = rotationCursorForAngle(corner.rotation); + const hover = useCustomCursorHover(cursor); + const size = ROTATE_CORNER_OUTER_REACH + innerReach; + + return ( +
onPointerDown(event, cursor)} + onPointerEnter={hover.onPointerEnter} + onPointerLeave={hover.onPointerLeave} + style={{ + position: 'absolute', + left: corner.x - ROTATE_CORNER_OUTER_REACH, + top: corner.y - ROTATE_CORNER_OUTER_REACH, + width: size, + height: size, + transformOrigin: `${ROTATE_CORNER_OUTER_REACH}px ${ROTATE_CORNER_OUTER_REACH}px`, + transform: `rotate(${corner.rotation}deg)`, + clipPath: `polygon(0 0, 100% 0, 100% ${ROTATE_CORNER_OUTER_REACH}px, ${ROTATE_CORNER_OUTER_REACH}px ${ROTATE_CORNER_OUTER_REACH}px, ${ROTATE_CORNER_OUTER_REACH}px 100%, 0 100%)`, + cursor: getCursor(cursor), + }} + /> + ); +}; + +interface RotateCornerHandlesProps { + corners: readonly Point[]; + label: string; + innerReach?: number; + onPointerDown: RotateCornerProps['onPointerDown']; +} + +const RotateCornerHandles = ({ + corners, + label, + innerReach = ROTATE_CORNER_INNER_REACH, + onPointerDown, +}: RotateCornerHandlesProps) => ( + <> + {rotateCornerGeometry(corners).map((corner, index) => ( + + ))} + +); + +export default RotateCornerHandles; diff --git a/src/renderer/components/main/Grid/handles/SelectionRotateHandle.tsx b/src/renderer/components/main/Grid/handles/SelectionRotateHandle.tsx new file mode 100644 index 000000000..83e9a1ab7 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SelectionRotateHandle.tsx @@ -0,0 +1,32 @@ +import CanvasRotateHandle from './CanvasRotateHandle'; +import { + createSelectionRotationGesture, + type SelectionRotationFrame, +} from './selectionRotationGesture'; + +interface SelectionRotateHandleProps { + frame: SelectionRotationFrame; + zoom: number; + panX: number; + panY: number; +} + +const SelectionRotateHandle = ({ + frame, + zoom, + panX, + panY, +}: SelectionRotateHandleProps) => ( + createSelectionRotationGesture(frame)} + /> +); + +export default SelectionRotateHandle; diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx new file mode 100644 index 000000000..57cd27cf5 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx @@ -0,0 +1,466 @@ +// @vitest-environment jsdom +import React, { act, useSyncExternalStore } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + CanonicalReactiveSpritePosition, + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + EditorDocumentV1, +} from '@src/types/editor'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; + +const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const KEY_ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +// IPC 경계만 대역. 게스처 컨트롤러·프리뷰 오버레이·스토어·coordinator는 실물이라 +// 단위 테스트가 mock으로 가려 두던 "드래그 중 canonical 교체" 경로까지 지나간다 +const runtime = vi.hoisted(() => { + let resolveCommit!: (result: EditorCommitResult) => void; + const commit = vi.fn( + (_request: EditorCommitRequest) => + new Promise((resolve) => { + resolveCommit = resolve; + }), + ); + const get = vi.fn(); + let committedListener: ((event: EditorCommittedV1) => void) | null = null; + const onCommitted = vi.fn((listener: (event: EditorCommittedV1) => void) => { + committedListener = listener; + return Object.assign(() => {}, { ready: Promise.resolve() }); + }); + return { + commit, + get, + onCommitted, + emitCommitted: (event: EditorCommittedV1) => committedListener?.(event), + resolveCommit: (result: EditorCommitResult) => resolveCommit(result), + previewPublish: vi.fn(async () => {}), + previewCancel: vi.fn(async () => {}), + previewSubscribe: vi.fn(async () => 1), + }; +}); + +vi.mock('@api/modules/editor/editorApi', () => ({ + editorApi: { + get: runtime.get, + commit: runtime.commit, + onCommitted: runtime.onCommitted, + }, +})); +vi.mock('@api/modules/editor/previewApi', () => ({ + previewApi: { + publish: runtime.previewPublish, + cancel: runtime.previewCancel, + subscribe: runtime.previewSubscribe, + }, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const spriteFixture = ( + overrides: Partial = {}, +): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, + rotation: 0, + id: SPRITE_ID, + dx: 0, + dy: 0, + width: 200, + height: 150, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [ + { + poseId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + triggers: [KEY_ID_A], + transform: { x: 10, y: 0, rotation: 90, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, + }, + ], + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + referenceNaturalSize: null, + ...overrides, +}); + +const makeDocument = ( + sprite: CanonicalReactiveSpritePosition, +): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: {}, + keyPositions: {}, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: { '4key': [sprite] }, + layerGroups: {}, +}); + +const loadHarness = async () => { + const [handles, overlay, spriteStore, coordinator, dragSession] = + await Promise.all([ + import('./SpriteCanvasHandles'), + import('@src/renderer/editor/runtime/gesture/previewOverlay'), + import('@stores/data/useSpriteStore'), + import('@src/renderer/editor/runtime/coordinator/editorStateCoordinator'), + import('@hooks/Grid/drag/dragSession'), + ]); + return { + SpriteCanvasHandles: handles.default, + composePreviewPositions: overlay.composePreviewPositions, + previewOverlay: overlay.previewOverlay, + subscribePreviewOverlay: overlay.subscribePreviewOverlay, + getPreviewOverlayVersion: overlay.getPreviewOverlayVersion, + useSpriteStore: spriteStore.useSpriteStore, + editorCoordinator: coordinator.editorCoordinator, + releaseDragSession: dragSession.releaseDragSession, + }; +}; +type Harness = Awaited>; + +describe('SpriteCanvasHandles 기준점 드래그 통합', () => { + let harness: Harness; + let container: HTMLDivElement; + let root: Root; + const selected: SelectedElement[] = [{ type: 'sprite', id: SPRITE_ID }]; + + // Grid와 같은 합성: canonical 위에 프리뷰 오버레이를 얹어 핸들에 넘긴다 + const Host = () => { + const canonical = harness.useSpriteStore((state) => state.positions); + useSyncExternalStore( + harness.subscribePreviewOverlay, + harness.getPreviewOverlayVersion, + ); + const spritePositions = harness.composePreviewPositions( + 'spritePosition', + canonical, + ); + return ( + + ); + }; + + const render = () => act(() => root.render()); + const pivotHandle = () => + container.querySelector('[data-sprite-pivot-handle="true"]')!; + const pointer = ( + type: string, + target: EventTarget, + clientX: number, + clientY: number, + init: { ctrlKey?: boolean } = {}, + ) => + act(() => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + clientX, + clientY, + pointerId: 1, + ctrlKey: init.ctrlKey ?? false, + }), + ); + }); + const handleCenterX = () => parseFloat(pivotHandle().style.left) + 13; + // rAF 스케줄러가 setTimeout 0으로 스텁돼 있어 한 틱 기다리면 move가 반영된다 + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + + beforeEach(async () => { + vi.resetModules(); + runtime.commit.mockClear(); + runtime.previewPublish.mockClear(); + runtime.previewCancel.mockClear(); + runtime.get.mockReset(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + harness = await loadHarness(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + harness.releaseDragSession(); + harness.editorCoordinator.stop(); + vi.unstubAllGlobals(); + }); + + it('중앙에 스냅된 기준점을 드래그하면 표식이 따라가고 커밋 뒤에도 유지된다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 중앙 (100, 75) → 히트 26 좌상단 (87, 62) + expect(pivotHandle().style.left).toBe('87px'); + expect(pivotHandle().style.top).toBe('62px'); + + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 160, 75); + await settle(); + // pivot x = 0.8 → 축 160 + 프레임 보정 0.6 → 히트 좌상단 147.6 + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(147.6, 3); + + pointer('pointermove', window, 180, 90); + await settle(); + // 두 번째 move도 살아 있다 - 드래그가 도중에 취소되지 않았다 + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.8, 3); + expect(parseFloat(pivotHandle().style.top)).toBeCloseTo(77.2, 3); + + pointer('pointerup', window, 180, 90); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + const canonical = harness.useSpriteStore.getState().positions['4key'][0]; + expect(canonical.pivot).toEqual({ x: 0.9, y: 0.6 }); + // 표식은 드래그를 놓은 자리에 남는다 + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.8, 3); + + runtime.resolveCommit({ revision: 1, changedFields: ['spritePositions'] }); + await settle(); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.8, 3); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot, + ).toEqual({ x: 0.9, y: 0.6 }); + }); + + it('직전 커밋이 착지한 직후 중앙에서 시작한 두 번째 드래그도 이어진다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture({ pivot: { x: 1, y: 0 } })), + }); + await harness.editorCoordinator.start(); + render(); + + // 1차: 모서리 → 중앙으로 드래그해 스냅 + pointer('pointerdown', pivotHandle(), 201, -1); + pointer('pointermove', window, 102, 77); + await settle(); + pointer('pointerup', window, 102, 77); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot, + ).toEqual({ x: 0.5, y: 0.5 }); + runtime.resolveCommit({ revision: 1, changedFields: ['spritePositions'] }); + await settle(); + expect(pivotHandle().style.left).toBe('87px'); + + // 2차: 중앙에서 바로 다른 곳으로 + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75); + await settle(); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(127.4, 3); + + pointer('pointermove', window, 160, 75); + await settle(); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(147.6, 3); + + pointer('pointerup', window, 160, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot, + ).toEqual({ x: 0.8, y: 0.5 }); + }); + + it('배율 0.1 idle 스프라이트도 표식이 포인터를 그대로 따라간다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument( + spriteFixture({ + idleTransform: { x: 0, y: 0, rotation: 0, scale: 0.1 }, + }), + ), + }); + await harness.editorCoordinator.start(); + render(); + expect(handleCenterX()).toBeCloseTo(100, 6); + + pointer('pointerdown', pivotHandle(), 100, 75); + // 스냅이 켜진 채 1px - 프리셋 자리가 7.5px 간격으로 몰려도 옆 프리셋으로 튀지 않는다 + pointer('pointermove', window, 100, 74); + await settle(); + expect(handleCenterX()).toBeCloseTo(100, 6); + expect(parseFloat(pivotHandle().style.top) + 13).toBeCloseTo(75, 6); + // 스냅 해제 상태로 5px - 배율 0.1이라 기준점은 0.25 움직이고 표식은 포인터 아래 + pointer('pointermove', window, 105, 75, { ctrlKey: true }); + await settle(); + // 105 + 프레임 보정 (2·0.75−1)·1 = 105.5 + expect(handleCenterX()).toBeCloseTo(105.5, 6); + + pointer('pointerup', window, 105, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + const canonical = harness.useSpriteStore.getState().positions['4key'][0]; + expect(canonical.pivot.x).toBeCloseTo(0.75, 9); + expect(canonical.pivot.y).toBeCloseTo(0.5, 9); + // 그림은 움직이지 않는다 - t' = t + (P − P') + sR·Δ = 0 + (100 − 150) + 0.1·50 + expect(canonical.idleTransform.x).toBeCloseTo(-45, 9); + expect(handleCenterX()).toBeCloseTo(105.5, 6); + }); + + it('히트 영역 가장자리를 잡아도 첫 move에서 표식이 튀지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 중심에서 8px 오른쪽을 잡고 4px 이동 → 표식은 4px만 따라온다 + pointer('pointerdown', pivotHandle(), 108, 75); + pointer('pointermove', window, 112, 75, { ctrlKey: true }); + await settle(); + // 축 104 + 프레임 보정 (2·0.52−1)·1 = 104.04 + expect(handleCenterX()).toBeCloseTo(104.04, 6); + pointer('pointerup', window, 112, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot.x, + ).toBeCloseTo(0.52, 9); + }); + + it('표시 기준점이 canonical과 다르면(다른 프리뷰 진행 중) 드래그를 시작하지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + const { editGestureController } = await import( + '@src/renderer/editor/runtime/gesture/editGestureController' + ); + // 다른 컨트롤의 프리뷰가 기준점을 0.75로 보여 주는 상태 + act(() => + editGestureController.preview( + '4key', + [ + { + id: SPRITE_ID, + patch: { + pivot: { x: 0.75, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + }, + }, + ], + { domain: 'spritePosition' }, + ), + ); + await settle(); + expect(handleCenterX()).toBeCloseTo(150.5, 6); + + pointer('pointerdown', pivotHandle(), 150, 75); + pointer('pointermove', window, 120, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 120, 75); + await settle(); + expect(runtime.commit).not.toHaveBeenCalled(); + expect(handleCenterX()).toBeCloseTo(150.5, 6); + act(() => editGestureController.cancel()); + }); + + it('컨트롤러가 잃어버린 로컬 프리뷰가 기준점을 가려도 한 번의 드래그로 회수하고 저장한다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + const orphanSessionId = '11111111-1111-4111-8111-111111111111'; + act(() => { + harness.previewOverlay.applyLocalPatchByIds( + orphanSessionId, + '4key', + [SPRITE_ID], + { + pivot: { x: 0.75, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + }, + 'spritePosition', + ); + }); + await settle(); + expect(handleCenterX()).toBeCloseTo(150.5, 6); + + pointer('pointerdown', pivotHandle(), 150, 75); + pointer('pointermove', window, 120, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 120, 75); + await settle(); + + expect(runtime.previewCancel).toHaveBeenCalledWith(orphanSessionId); + expect(runtime.commit).toHaveBeenCalledOnce(); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot.x, + ).toBeCloseTo(0.6, 9); + expect(handleCenterX()).toBeCloseTo(120.2, 6); + }); + + it('다른 프리뷰가 상자 크기만 바꿔 보여 줘도 드래그를 시작하지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + const { editGestureController } = await import( + '@src/renderer/editor/runtime/gesture/editGestureController' + ); + // 기준점·transform은 canonical 그대로, 상자만 두 배로 보이는 프리뷰 + act(() => + editGestureController.preview( + '4key', + [{ id: SPRITE_ID, patch: { width: 400, height: 300 } }], + { domain: 'spritePosition' }, + ), + ); + await settle(); + // 표시 표식은 두 배 상자의 중앙 (200, 150) + expect(handleCenterX()).toBeCloseTo(200, 6); + + pointer('pointerdown', pivotHandle(), 200, 150); + pointer('pointermove', window, 200, 150, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 200, 150); + await settle(); + expect(runtime.commit).not.toHaveBeenCalled(); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot, + ).toEqual({ x: 0.5, y: 0.5 }); + act(() => editGestureController.cancel()); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseReopen.integration.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseReopen.integration.test.tsx new file mode 100644 index 000000000..b7d15c8a5 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseReopen.integration.test.tsx @@ -0,0 +1,498 @@ +// @vitest-environment jsdom +import React, { act, createRef, useSyncExternalStore } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + CanonicalReactiveSpritePosition, + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + EditorDocumentV1, +} from '@src/types/editor'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; + +const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const POSE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const KEY_ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +// 실제 팝업 바깥 클릭과 캔버스 이미지까지 포함해 자세 프리뷰 종료와 저장값 유지를 구분 +const runtime = vi.hoisted(() => { + const resolvers: Array<(result: EditorCommitResult) => void> = []; + let committedListener: ((event: EditorCommittedV1) => void) | null = null; + const commit = vi.fn( + (_request: EditorCommitRequest) => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const get = vi.fn(); + const onCommitted = vi.fn((listener: (event: EditorCommittedV1) => void) => { + committedListener = listener; + return Object.assign(() => {}, { ready: Promise.resolve() }); + }); + return { + commit, + get, + onCommitted, + emitCommittedForCall: (index: number, revision: number) => { + const request = commit.mock.calls[index]?.[0]; + if (!request?.changes) throw new Error('Missing commit request'); + committedListener?.({ + schemaVersion: 1, + revision, + mutationId: request.mutationId, + changedFields: ['spritePositions'], + patch: { ...request.changes, schemaVersion: 1 }, + gestureId: request.gestureId, + gestureIds: request.gestureId ? [request.gestureId] : [], + }); + }, + // 케이스 사이에 남은 resolver를 비운다 - 이전 케이스의 보류 저장이 다음 케이스의 + // resolveNext를 가로채면 직렬 큐가 멈춘 것처럼 보인다 + reset: () => { + resolvers.length = 0; + }, + resolveNext: (revision: number) => { + const resolve = resolvers.shift(); + if (!resolve) throw new Error('Missing commit resolver'); + resolve({ revision, changedFields: ['spritePositions'] }); + }, + previewPublish: vi.fn(async () => {}), + previewCancel: vi.fn(async () => {}), + previewSubscribe: vi.fn(async () => 1), + }; +}); + +vi.mock('@api/modules/editor/editorApi', () => ({ + editorApi: { + get: runtime.get, + commit: runtime.commit, + onCommitted: runtime.onCommitted, + }, +})); +vi.mock('@api/modules/editor/previewApi', () => ({ + previewApi: { + publish: runtime.previewPublish, + cancel: runtime.previewCancel, + subscribe: runtime.previewSubscribe, + }, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@api/modules/resources/resourceApi', () => ({ + imageApi: { load: vi.fn(() => Promise.resolve({ success: false })) }, +})); +vi.mock('@utils/media/assetProbe', () => ({ + canDecodeImage: vi.fn(() => Promise.resolve(true)), + probeImageSize: vi.fn(() => Promise.resolve({ width: 64, height: 32 })), + canLoadFont: vi.fn(() => Promise.resolve(true)), +})); +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ scrollContainerRef: vi.fn() }), +})); +class ResizeObserverStub { + observe() {} + disconnect() {} +} + +const spriteFixture = (): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, + rotation: 0, + id: SPRITE_ID, + dx: 0, + dy: 0, + width: 200, + height: 150, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: true, + baseImage: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yhQAAAABJRU5ErkJggg==', + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [ + { + poseId: POSE_ID, + triggers: [KEY_ID_A], + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, + }, + ], + transitionMs: 0, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + referenceNaturalSize: null, +}); + +const makeDocument = ( + sprite: CanonicalReactiveSpritePosition, +): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: { '4key': ['A'] }, + keyPositions: { + '4key': [{ ...createDefaultKeyPosition(), id: KEY_ID_A }], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: { '4key': [sprite] }, + layerGroups: {}, +}); + +const loadHarness = async () => { + const [ + handles, + panel, + overlay, + spriteStore, + coordinator, + dragSession, + spriteItem, + editPreview, + ] = await Promise.all([ + import('./SpriteCanvasHandles'), + import('@components/main/Grid/PropertiesPanel/single/SingleSpritePanel'), + import('@src/renderer/editor/runtime/gesture/previewOverlay'), + import('@stores/data/useSpriteStore'), + import('@src/renderer/editor/runtime/coordinator/editorStateCoordinator'), + import('@hooks/Grid/drag/dragSession'), + import('../layers/SpriteItem'), + import('@stores/grid/useSpriteEditPreviewStore'), + ]); + return { + SpriteCanvasHandles: handles.default, + SpriteItem: spriteItem.default, + useSpriteEditPreviewStore: editPreview.useSpriteEditPreviewStore, + SingleSpritePanel: panel.SingleSpritePanel, + composePreviewPositions: overlay.composePreviewPositions, + previewOverlay: overlay.previewOverlay, + subscribePreviewOverlay: overlay.subscribePreviewOverlay, + getPreviewOverlayVersion: overlay.getPreviewOverlayVersion, + useSpriteStore: spriteStore.useSpriteStore, + editorCoordinator: coordinator.editorCoordinator, + releaseDragSession: dragSession.releaseDragSession, + }; +}; +type Harness = Awaited>; + +describe('자세 핸들 저장 후 실제 팝업 닫기와 재개방', () => { + let harness: Harness; + let container: HTMLDivElement; + let root: Root; + let errors: string[]; + const selected: SelectedElement[] = [{ type: 'sprite', id: SPRITE_ID }]; + + const Host = ({ isSelected = true }: { isSelected?: boolean }) => { + const canonical = harness.useSpriteStore((state) => state.positions); + useSyncExternalStore( + harness.subscribePreviewOverlay, + harness.getPreviewOverlayVersion, + ); + const spritePositions = harness.composePreviewPositions( + 'spritePosition', + canonical, + ); + const position = spritePositions['4key']?.find( + (candidate) => candidate.id === SPRITE_ID, + ); + if (!position) return null; + return ( + <> +
+ {}} + isSelected={isSelected} + activeTool="move" + /> + {isSelected ? ( + () as never} + renameValue="" + setRenameValue={vi.fn()} + renameCancelledRef={{ current: false }} + handleRenameCommit={vi.fn()} + handleRenameCancel={vi.fn()} + handleRenameStart={vi.fn()} + singleScrollRefFor={() => vi.fn()} + t={((key: string) => key) as never} + /> + ) : null} + + + ); + }; + + const render = (isSelected = true) => + act(() => root.render()); + const pivotHandle = () => + container.querySelector('[data-sprite-pivot-handle="true"]')!; + const poseFrame = () => + container.querySelector( + '[data-sprite-pose-frame="true"]', + ); + const poseRows = () => + [...container.querySelectorAll('[role="button"]')].filter( + (row) => + row.textContent?.startsWith('propertiesPanel.spritePose ') ?? false, + ); + const canonicalSprite = () => + harness.useSpriteStore.getState().positions['4key'][0]; + const pointer = ( + type: string, + target: EventTarget, + clientX: number, + clientY: number, + init: { buttons?: number; ctrlKey?: boolean } = {}, + ) => + act(() => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: init.buttons ?? (type === 'pointerup' ? 0 : 1), + clientX, + clientY, + pointerId: 1, + ctrlKey: init.ctrlKey ?? false, + }), + ); + }); + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + const framePoints = () => + poseFrame()! + .getAttribute('points')! + .split(' ') + .map((pair) => pair.split(',').map(Number)); + const pivotCenter = () => ({ + x: Number.parseFloat(pivotHandle().style.left) + 13, + y: Number.parseFloat(pivotHandle().style.top) + 13, + }); + + beforeEach(async () => { + vi.resetModules(); + runtime.commit.mockClear(); + runtime.get.mockReset(); + runtime.reset(); + errors = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }); + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + harness = await loadHarness(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + harness.releaseDragSession(); + harness.editorCoordinator.stop(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const imageTransform = () => + container.querySelector( + '[data-sprite-element] img:not([data-sprite-idle-ghost])', + )!.style.transform; + + const elementCenter = (element: HTMLElement) => ({ + x: + Number.parseFloat(element.style.left) + + Number.parseFloat(element.style.width) / 2, + y: + Number.parseFloat(element.style.top) + + Number.parseFloat(element.style.height) / 2, + }); + + it.each( + (['scale', 'move', 'rotate', 'pivot'] as const).flatMap((kind) => + (['popup', 'selection'] as const).flatMap((closeBy) => + [false, true].map((invalidSibling) => ({ + kind, + closeBy, + invalidSibling, + })), + ), + ), + )( + '$kind 종료 후 $closeBy 닫힘과 재개방은 저장 응답 전후 자세를 유지한다 (무효 형제: $invalidSibling)', + async ({ kind, closeBy, invalidSibling }) => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + const idleImageTransform = imageTransform(); + expect(idleImageTransform).toContain('scale(1)'); + if (invalidSibling) { + const addButton = [ + ...container.querySelectorAll('button'), + ].find((button) => + button.textContent?.includes('propertiesPanel.spriteAddPose'), + )!; + act(() => addButton.click()); + await settle(); + expect(poseRows()).toHaveLength(2); + expect(canonicalSprite().poses).toHaveLength(1); + expect( + harness.useSpriteEditPreviewStore.getState().preview?.fallbackPose + .triggers, + ).toEqual([]); + expect(runtime.commit).not.toHaveBeenCalled(); + } + act(() => poseRows()[0].click()); + await settle(); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + expect(poseFrame()).not.toBeNull(); + + const beforeFrame = framePoints(); + const axis = pivotCenter(); + let target: Element; + let from: { x: number; y: number }; + let to: { x: number; y: number }; + if (kind === 'move') { + target = poseFrame()!; + from = { x: 60, y: 60 }; + to = { x: 90, y: 80 }; + } else if (kind === 'pivot') { + target = pivotHandle(); + from = axis; + to = { x: axis.x + 20, y: axis.y + 10 }; + } else { + const knob = container.querySelector( + kind === 'scale' + ? '[data-sprite-scale-knob]' + : '[data-rotate-corner="0"]', + )!; + target = knob; + from = elementCenter(knob); + to = + kind === 'scale' + ? { + x: axis.x + (from.x - axis.x) / 2, + y: axis.y + (from.y - axis.y) / 2, + } + : { x: axis.x - (from.y - axis.y), y: axis.y + (from.x - axis.x) }; + } + pointer('pointerdown', target, from.x, from.y); + pointer('pointermove', window, to.x, to.y, { ctrlKey: true }); + await settle(); + expect(poseFrame()).not.toBeNull(); + expect(runtime.commit).not.toHaveBeenCalled(); + const duringImageTransform = imageTransform(); + pointer('pointerup', window, to.x, to.y); + await settle(); + + expect(runtime.commit).toHaveBeenCalledTimes(1); + expect(canonicalSprite().poses).toHaveLength(1); + expect(poseRows()).toHaveLength(invalidSibling ? 2 : 1); + const savedPose = structuredClone(canonicalSprite().poses[0]); + const savedFrame = framePoints(); + const savedImageTransform = imageTransform(); + if (kind !== 'pivot') { + expect(savedImageTransform).toBe(duringImageTransform); + } + expect( + runtime.commit.mock.calls[0][0].changes?.spritePositions?.['4key']?.[0] + .poses[0], + ).toEqual(savedPose); + if (kind === 'scale') { + expect(savedPose.transform.scale).toBeCloseTo(0.5, 9); + expect(savedImageTransform).toContain('scale(0.5)'); + } else if (kind === 'move') { + expect(savedPose.transform).toMatchObject({ x: 30, y: 20 }); + } else if (kind === 'rotate') { + expect(savedPose.transform.rotation).toBeCloseTo(90, 9); + } else { + expect(savedPose.pivot).not.toBeNull(); + expect(savedPose.pivot).not.toEqual({ x: 0.5, y: 0.5 }); + savedFrame.forEach((point, index) => { + expect(point[0]).toBeCloseTo(beforeFrame[index][0], 6); + expect(point[1]).toBeCloseTo(beforeFrame[index][1], 6); + }); + } + + // 실제 FloatingPopup의 문서 capture 리스너가 바깥 클릭으로 팝업을 닫는다 + const background = container.querySelector( + '[data-testid="grid-background"]', + )!; + pointer('pointerdown', background, 600, 400); + pointer('pointerup', background, 600, 400); + await settle(); + expect(poseFrame()).toBeNull(); + expect(harness.useSpriteEditPreviewStore.getState().preview).toBeNull(); + expect(imageTransform()).toBe(idleImageTransform); + expect(canonicalSprite().poses[0]).toEqual(savedPose); + expect(poseRows()).toHaveLength(invalidSibling ? 2 : 1); + + if (closeBy === 'selection') { + render(false); + await settle(); + expect(poseRows()).toHaveLength(0); + expect(imageTransform()).toBe(idleImageTransform); + render(); + await settle(); + } + act(() => poseRows()[0].click()); + await settle(); + expect(framePoints()).toEqual(savedFrame); + expect(imageTransform()).toBe(savedImageTransform); + if (kind === 'scale') { + const scaleInput = document.querySelector( + 'input[aria-label="propertiesPanel.spriteScale"]', + ); + expect(Number.parseFloat(scaleInput!.value)).toBe(50); + } + + // 재개방 뒤 늦게 도착한 저장 이벤트와 응답도 현재 자세를 되돌리지 않는다 + act(() => { + runtime.emitCommittedForCall(0, 1); + runtime.resolveNext(1); + }); + await settle(); + expect(canonicalSprite().poses[0]).toEqual(savedPose); + expect(framePoints()).toEqual(savedFrame); + expect(imageTransform()).toBe(savedImageTransform); + expect(runtime.commit).toHaveBeenCalledTimes(1); + expect(canonicalSprite().poses).toHaveLength(1); + expect(poseRows()).toHaveLength( + invalidSibling && closeBy === 'popup' ? 2 : 1, + ); + expect(errors).toEqual([]); + }, + ); +}); diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx new file mode 100644 index 000000000..17ec15900 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx @@ -0,0 +1,1130 @@ +// @vitest-environment jsdom +import React, { act, createRef, useSyncExternalStore } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + CanonicalReactiveSpritePosition, + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + CanonicalEditorDocumentV1, +} from '@src/types/editor'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; + +const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const POSE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const KEY_ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +// 패널 + 캔버스 핸들 + 실물 게스처 컨트롤러·오버레이·스토어·coordinator. +// IPC와 리소스 조회만 대역. "기준점을 옮긴 뒤 자세를 열어 캔버스에서 끌면 +// 제자리로 돌아온다"는 실기 보고를 그대로 재현한다 +const runtime = vi.hoisted(() => { + const resolvers: Array<(result: EditorCommitResult) => void> = []; + let committedListener: ((event: EditorCommittedV1) => void) | null = null; + const commit = vi.fn( + (_request: EditorCommitRequest) => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const get = vi.fn(); + const onCommitted = vi.fn((listener: (event: EditorCommittedV1) => void) => { + committedListener = listener; + return Object.assign(() => {}, { ready: Promise.resolve() }); + }); + return { + commit, + get, + onCommitted, + emitCommittedForCall: (index: number, revision: number) => { + const request = commit.mock.calls[index]?.[0]; + if (!request?.changes) throw new Error('Missing commit request'); + committedListener?.({ + schemaVersion: 1, + revision, + mutationId: request.mutationId, + changedFields: ['spritePositions'], + patch: { ...request.changes, schemaVersion: 1 }, + gestureId: request.gestureId, + gestureIds: request.gestureId ? [request.gestureId] : [], + }); + }, + // 케이스 사이에 남은 resolver를 비운다 - 이전 케이스의 보류 저장이 다음 케이스의 + // resolveNext를 가로채면 직렬 큐가 멈춘 것처럼 보인다 + reset: () => { + resolvers.length = 0; + }, + resolveNext: (revision: number) => { + const resolve = resolvers.shift(); + if (!resolve) throw new Error('Missing commit resolver'); + resolve({ revision, changedFields: ['spritePositions'] }); + }, + resolveAll: () => { + let revision = 1; + while (resolvers.length > 0) { + resolvers.shift()!({ + revision: revision++, + changedFields: ['spritePositions'], + }); + } + }, + previewPublish: vi.fn(async () => {}), + previewCancel: vi.fn(async () => {}), + previewSubscribe: vi.fn(async () => 1), + }; +}); + +vi.mock('@api/modules/editor/editorApi', () => ({ + editorApi: { + get: runtime.get, + commit: runtime.commit, + onCommitted: runtime.onCommitted, + }, +})); +vi.mock('@api/modules/editor/previewApi', () => ({ + previewApi: { + publish: runtime.previewPublish, + cancel: runtime.previewCancel, + subscribe: runtime.previewSubscribe, + }, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@api/modules/resources/resourceApi', () => ({ + imageApi: { load: vi.fn(() => Promise.resolve({ success: false })) }, +})); +vi.mock('@utils/media/assetProbe', () => ({ + canDecodeImage: vi.fn(() => Promise.resolve(true)), + probeImageSize: vi.fn(() => Promise.resolve({ width: 64, height: 32 })), + canLoadFont: vi.fn(() => Promise.resolve(true)), +})); +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ scrollContainerRef: vi.fn() }), +})); +vi.mock('@components/main/Grid/PropertiesPanel/controls/PickerSurface', () => ({ + default: ({ + open, + children, + ariaLabel, + }: { + open: boolean; + children: React.ReactNode; + ariaLabel: string; + }) => + open ? ( +
+ {children} +
+ ) : null, +})); + +class ResizeObserverStub { + observe() {} + disconnect() {} +} + +// NumberInput은 네이티브 value setter + input 이벤트로 타이핑을 흉내 낸다 +const setInputValue = (input: HTMLInputElement, value: string) => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +}; + +const spriteFixture = (): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, + rotation: 0, + id: SPRITE_ID, + dx: 0, + dy: 0, + width: 200, + height: 150, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [ + { + poseId: POSE_ID, + triggers: [KEY_ID_A], + transform: { x: 0, y: 0, rotation: 8.8, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, + }, + ], + transitionMs: 0, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + referenceNaturalSize: null, +}); + +const makeDocument = ( + sprite: CanonicalReactiveSpritePosition, +): CanonicalEditorDocumentV1 => ({ + schemaVersion: 1, + keys: {}, + keyPositions: {}, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: { '4key': [sprite] }, + layerGroups: {}, +}); + +const loadHarness = async () => { + const [ + handles, + panel, + overlay, + spriteStore, + coordinator, + dragSession, + historyLock, + lifecycle, + committedApply, + ] = await Promise.all([ + import('./SpriteCanvasHandles'), + import('@components/main/Grid/PropertiesPanel/single/SingleSpritePanel'), + import('@src/renderer/editor/runtime/gesture/previewOverlay'), + import('@stores/data/useSpriteStore'), + import('@src/renderer/editor/runtime/coordinator/editorStateCoordinator'), + import('@hooks/Grid/drag/dragSession'), + import('@src/renderer/editor/runtime/lifecycle/historyEditorFlushLock'), + import('@src/renderer/editor/runtime/lifecycle/lifecycleEditorFlush'), + import('@stores/data/useCommittedApplyStore'), + ]); + return { + SpriteCanvasHandles: handles.default, + SingleSpritePanel: panel.SingleSpritePanel, + composePreviewPositions: overlay.composePreviewPositions, + previewOverlay: overlay.previewOverlay, + subscribePreviewOverlay: overlay.subscribePreviewOverlay, + getPreviewOverlayVersion: overlay.getPreviewOverlayVersion, + useSpriteStore: spriteStore.useSpriteStore, + editorCoordinator: coordinator.editorCoordinator, + applyEditorDocument: coordinator.applyEditorDocument, + releaseDragSession: dragSession.releaseDragSession, + historyLock, + flushFocusedEditor: lifecycle.flushFocusedEditor, + useCommittedApplyStore: committedApply.useCommittedApplyStore, + }; +}; +type Harness = Awaited>; + +describe('자세 편집 세션 통합 (패널 + 캔버스 핸들)', () => { + let harness: Harness; + let container: HTMLDivElement; + let root: Root; + let errors: string[]; + const selected: SelectedElement[] = [{ type: 'sprite', id: SPRITE_ID }]; + + const Host = () => { + const canonical = harness.useSpriteStore((state) => state.positions); + useSyncExternalStore( + harness.subscribePreviewOverlay, + harness.getPreviewOverlayVersion, + ); + const spritePositions = harness.composePreviewPositions( + 'spritePosition', + canonical, + ); + const position = spritePositions['4key']?.find( + (candidate) => candidate.id === SPRITE_ID, + ); + if (!position) return null; + return ( + <> + () as never} + renameValue="" + setRenameValue={vi.fn()} + renameCancelledRef={{ current: false }} + handleRenameCommit={vi.fn()} + handleRenameCancel={vi.fn()} + handleRenameStart={vi.fn()} + singleScrollRefFor={() => vi.fn()} + t={((key: string) => key) as never} + /> + + + ); + }; + + const render = () => act(() => root.render()); + const pivotHandle = () => + container.querySelector('[data-sprite-pivot-handle="true"]')!; + const poseFrame = () => + container.querySelector( + '[data-sprite-pose-frame="true"]', + ); + const poseRows = () => + [...container.querySelectorAll('[role="button"]')].filter( + (row) => + row.textContent?.startsWith('propertiesPanel.spritePose ') ?? false, + ); + const canonicalSprite = () => + harness.useSpriteStore.getState().positions['4key'][0]; + const pointer = ( + type: string, + target: EventTarget, + clientX: number, + clientY: number, + init: { buttons?: number; ctrlKey?: boolean } = {}, + ) => + act(() => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: init.buttons ?? (type === 'pointerup' ? 0 : 1), + clientX, + clientY, + pointerId: 1, + ctrlKey: init.ctrlKey ?? false, + }), + ); + }); + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + const framePoints = () => + poseFrame()! + .getAttribute('points')! + .split(' ') + .map((pair) => pair.split(',').map(Number)); + const pivotCenter = () => ({ + x: Number.parseFloat(pivotHandle().style.left) + 13, + y: Number.parseFloat(pivotHandle().style.top) + 13, + }); + + beforeEach(async () => { + vi.resetModules(); + runtime.commit.mockClear(); + runtime.get.mockReset(); + runtime.reset(); + errors = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }); + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + harness = await loadHarness(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + harness.releaseDragSession(); + harness.historyLock.resetHistoryEditorFlushLock(); + harness.editorCoordinator.stop(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it.each([ + ['base', 'pointerup'], + ['pose', 'pointerup'], + ['base', 'settlement'], + ['pose', 'settlement'], + ['base', 'historyTick'], + ['pose', 'historyTick'], + ['pose', 'canonicalHistory'], + ] as const)( + '%s 핸들은 history 경계 %s에서 드래그를 저장하지 않는다', + async (kind, boundary) => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + if (kind === 'pose') { + act(() => poseRows()[0].click()); + await settle(); + } + const original = canonicalSprite(); + const target = kind === 'pose' ? poseFrame()! : pivotHandle(); + const start = kind === 'pose' ? { x: 60, y: 60 } : pivotCenter(); + pointer('pointerdown', target, start.x, start.y); + pointer('pointermove', window, start.x + 30, start.y + 20, { + ctrlKey: true, + }); + await settle(); + const historyTick = harness.useCommittedApplyStore.getState().historyTick; + if (boundary !== 'historyTick' && boundary !== 'canonicalHistory') + act(() => { + harness.historyLock.acquireHistoryEditorFlushLock( + 'sprite-handle-history', + ); + }); + let flushing: Promise | null = null; + if (boundary === 'pointerup') + pointer('pointerup', window, start.x + 30, start.y + 20); + else if (boundary === 'historyTick' || boundary === 'canonicalHistory') + act(() => { + if (boundary === 'historyTick') + harness.useCommittedApplyStore.getState().bump('historyUndo'); + else { + harness.applyEditorDocument( + makeDocument({ + ...original, + poses: original.poses.map((pose) => ({ + ...pose, + transform: { ...pose.transform, x: 100 }, + })), + }), + ); + harness.useCommittedApplyStore.getState().bump('historyUndo'); + } + window.dispatchEvent( + new PointerEvent('pointerup', { + pointerId: 1, + bubbles: true, + button: 0, + buttons: 0, + clientX: start.x + 30, + clientY: start.y + 20, + }), + ); + }); + else flushing = harness.flushFocusedEditor(); + await settle(); + const commits = runtime.commit.mock.calls.length; + runtime.resolveAll(); + if (flushing) + await act(async () => { + expect(await flushing).toBe(true); + }); + await settle(); + expect(harness.useCommittedApplyStore.getState().historyTick).toBe( + historyTick + + (boundary === 'historyTick' || boundary === 'canonicalHistory' + ? 1 + : 0), + ); + expect(commits).toBe(0); + const expected = + boundary === 'canonicalHistory' + ? { + ...original, + poses: original.poses.map((pose) => ({ + ...pose, + transform: { ...pose.transform, x: 100 }, + })), + } + : original; + expect(canonicalSprite()).toEqual(expected); + expect( + harness.composePreviewPositions( + 'spritePosition', + harness.useSpriteStore.getState().positions, + )['4key'][0], + ).toEqual(expected); + if (boundary === 'canonicalHistory') + expect(framePoints()[0][0]).toBeGreaterThan(90); + }, + ); + + it('기준점을 옮긴 뒤 자세를 열어 캔버스에서 끌면 자세가 새 자리에 남는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 1. 기준점을 중앙 (100, 75)에서 (140, 75)로 - 스냅 해제 + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 140, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + expect(canonicalSprite().pivot.x).toBeCloseTo(0.7, 9); + const baseAxisAfter = pivotCenter(); + // 기본 선택 표식은 1px 선택 테두리 프레임에 놓여 x가 0.4px 바깥이다 + expect(baseAxisAfter.x).toBeCloseTo(140.4, 6); + expect(baseAxisAfter.y).toBeCloseTo(75, 6); + const linkedTransform = canonicalSprite().poses[0].transform; + // 연결 상태는 이동값을 유지해 기준점 화면 좌표가 기본 축을 따라간다 + expect(linkedTransform).toEqual({ + x: 0, + y: 0, + rotation: 8.8, + scale: 1, + }); + runtime.resolveAll(); + await settle(); + + // 2. 자세 행 클릭 → 팝업·세션 + expect(poseRows().length).toBe(1); + act(() => poseRows()[0].click()); + await settle(); + expect(poseFrame()).not.toBeNull(); + const linkedAxis = pivotCenter(); + expect(linkedAxis.x).toBeCloseTo(140, 6); + expect(linkedAxis.y).toBeCloseTo(baseAxisAfter.y, 6); + const before = framePoints(); + + // 3. 자세 프레임 본체를 (60, 60)에서 (90, 80)으로 끌기 + pointer('pointerdown', poseFrame()!, 60, 60); + // 일부 WebView 합성 입력은 활성 드래그에서도 buttons를 0으로 전달한다 + pointer('pointermove', window, 90, 80, { buttons: 0 }); + await settle(); + const during = framePoints(); + expect(during[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(during[0][1] - before[0][1]).toBeCloseTo(20, 6); + + pointer('pointerup', window, 90, 80); + await settle(); + // 커밋이 나갔고 canonical 자세 이동값이 30, 20만큼 늘었다 + expect(runtime.commit).toHaveBeenCalledTimes(2); + const moved = canonicalSprite().poses[0].transform; + expect(moved.x - linkedTransform.x).toBeCloseTo(30, 6); + expect(moved.y - linkedTransform.y).toBeCloseTo(20, 6); + // 프레임도 새 자리에 남는다 (제자리 복귀 없음) + const after = framePoints(); + expect(after[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(after[0][1] - before[0][1]).toBeCloseTo(20, 6); + + runtime.resolveAll(); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect( + canonicalSprite().poses[0].transform.x - linkedTransform.x, + ).toBeCloseTo(30, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('pointerup 없이 mouseup만 와도 자세 이동을 저장하고 드래그를 끝낸다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + act(() => poseRows()[0].click()); + await settle(); + const before = framePoints(); + + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80); + await settle(); + act(() => { + window.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + }), + ); + }); + await settle(); + + expect(runtime.commit).toHaveBeenCalledOnce(); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 30, y: 20 }), + ); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + + // 해제 후 마우스만 옮겨도 닫힌 드래그가 다시 움직이지 않음 + pointer('pointermove', window, 140, 120, { buttons: 0 }); + await settle(); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 30, y: 20 }), + ); + expect(runtime.commit).toHaveBeenCalledOnce(); + + runtime.resolveAll(); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('눌린 move 뒤 buttons 0 move로 끝나도 자세 이동을 저장한다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + act(() => poseRows()[0].click()); + await settle(); + const before = framePoints(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 80, 70, { buttons: 1 }); + await settle(); + pointer('pointermove', window, 90, 80, { buttons: 0 }); + await settle(); + + expect(runtime.commit).toHaveBeenCalledOnce(); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 30, y: 20 }), + ); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + + pointer('pointermove', window, 140, 120, { buttons: 0 }); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + runtime.resolveAll(); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + }); + + it('이전 로컬 프리뷰가 남아 있어도 새 자세 이동 커밋을 다시 가리지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + const stalePose = { + ...canonicalSprite().poses[0], + transform: { + ...canonicalSprite().poses[0].transform, + x: 20, + }, + }; + act(() => { + harness.previewOverlay.applyLocalPatchByIds( + '11111111-1111-4111-8111-111111111111', + '4key', + [SPRITE_ID], + { poses: [stalePose] }, + 'spritePosition', + ); + }); + await settle(); + + act(() => poseRows()[0].click()); + await settle(); + const before = framePoints(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80); + await settle(); + pointer('pointerup', window, 90, 80); + await settle(); + + expect(runtime.commit).toHaveBeenCalledOnce(); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 50, y: 20 }), + ); + runtime.resolveAll(); + await settle(); + + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 50, y: 20 }), + ); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('기준점 저장 응답을 기다리지 않고 자세를 움직여도 새 자리에 남는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + act(() => poseRows()[0].click()); + await settle(); + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 140, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + const compensated = canonicalSprite().poses[0].transform; + const before = framePoints(); + + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80, { buttons: 0 }); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + pointer('pointerup', window, 90, 80); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + runtime.resolveAll(); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + expect(canonicalSprite().poses[0].transform.x - compensated.x).toBeCloseTo( + 30, + 6, + ); + runtime.resolveAll(); + await settle(); + expect(canonicalSprite().poses[0].transform.x - compensated.x).toBeCloseTo( + 30, + 6, + ); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + }); + + it('기본 기준점과 자세 기준점 저장이 차례로 대기해도 후속 자세 이동을 유지한다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 140, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + act(() => poseRows()[0].click()); + await settle(); + const beforePosePivot = framePoints(); + const posePivot = pivotCenter(); + pointer('pointerdown', pivotHandle(), posePivot.x, posePivot.y); + pointer('pointermove', window, posePivot.x + 20, posePivot.y + 10, { + ctrlKey: true, + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + pointer('pointerup', window, posePivot.x + 20, posePivot.y + 10); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + const afterPosePivot = framePoints(); + afterPosePivot.forEach((point, index) => { + expect(point[0]).toBeCloseTo(beforePosePivot[index][0], 6); + expect(point[1]).toBeCloseTo(beforePosePivot[index][1], 6); + }); + + const before = framePoints(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80, { buttons: 0 }); + await settle(); + pointer('pointerup', window, 90, 80); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + + act(() => { + runtime.emitCommittedForCall(0, 1); + runtime.resolveNext(1); + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + expect(canonicalSprite().poses[0].pivot).toEqual( + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + }), + ); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + + act(() => { + runtime.emitCommittedForCall(1, 2); + runtime.resolveNext(2); + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + + act(() => { + runtime.emitCommittedForCall(2, 3); + runtime.resolveNext(3); + }); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('자세 이동과 독립 기준점 저장이 대기 중이어도 후속 기본 기준점 변경이 둘을 덮지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 1. 자세 이동 저장을 응답 대기 상태로 둔다 + act(() => poseRows()[0].click()); + await settle(); + const initialFrame = framePoints(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80); + await settle(); + pointer('pointerup', window, 90, 80); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 30, y: 20 }), + ); + + // 2. 첫 저장을 기다리지 않고 자세 기준점을 독립 위치로 옮긴다 + const statePivot = pivotCenter(); + pointer('pointerdown', pivotHandle(), statePivot.x, statePivot.y); + pointer('pointermove', window, statePivot.x + 20, statePivot.y + 10, { + ctrlKey: true, + }); + await settle(); + pointer('pointerup', window, statePivot.x + 20, statePivot.y + 10); + await settle(); + const beforeBasePivotFrame = framePoints(); + // 3. 팝업을 닫고 기본 기준점을 옮긴다. 앞의 두 저장은 아직 대기 중이다 + act(() => poseRows()[0].click()); + await settle(); + const basePivot = pivotCenter(); + pointer('pointerdown', pivotHandle(), basePivot.x, basePivot.y); + pointer('pointermove', window, basePivot.x - 30, basePivot.y - 15, { + ctrlKey: true, + }); + await settle(); + const intendedBasePivot = { x: 0.35, y: 0.4 }; + + // 첫 저장 응답이 기본 기준점 드래그 도중 도착하는 실제 순서 + act(() => { + runtime.emitCommittedForCall(0, 1); + runtime.resolveNext(1); + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + const releasedBasePivot = pivotCenter(); + pointer('pointerup', window, basePivot.x - 30, basePivot.y - 15); + await settle(); + // 앞선 자세 저장 때문에 기본 기준점 커밋이 아직 슬롯에 못 들어가도 + // pointerup 착지는 그대로 유지되어야 한다 + expect(runtime.commit).toHaveBeenCalledTimes(2); + expect(pivotCenter().x).toBeCloseTo(releasedBasePivot.x, 6); + expect(pivotCenter().y).toBeCloseTo(releasedBasePivot.y, 6); + act(() => { + runtime.emitCommittedForCall(1, 2); + runtime.resolveNext(2); + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + act(() => { + runtime.emitCommittedForCall(2, 3); + runtime.resolveNext(3); + }); + await settle(); + + expect(canonicalSprite().pivot).toEqual(intendedBasePivot); + expect(canonicalSprite().poses[0].pivot).toEqual( + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + }), + ); + expect(canonicalSprite().poses[0].transform.x).not.toBe(0); + expect(canonicalSprite().poses[0].transform.y).not.toBe(0); + act(() => poseRows()[0].click()); + await settle(); + const finalFrame = framePoints(); + finalFrame.forEach((point, index) => { + expect(point[0]).toBeCloseTo(beforeBasePivotFrame[index][0], 6); + expect(point[1]).toBeCloseTo(beforeBasePivotFrame[index][1], 6); + }); + expect(finalFrame[0][0]).not.toBeCloseTo(initialFrame[0][0], 6); + expect(finalFrame[0][1]).not.toBeCloseTo(initialFrame[0][1], 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('상태 기준점 연결을 끄고 다시 켜도 이미지가 제자리에 남는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + act(() => poseRows()[0].click()); + await settle(); + const toggle = container.querySelector('[role="switch"]')!; + expect(toggle.getAttribute('aria-checked')).toBe('true'); + const before = framePoints(); + + act(() => toggle.click()); + await act(async () => { + await vi.waitFor(() => expect(runtime.commit).toHaveBeenCalledTimes(1)); + }); + expect( + runtime.commit.mock.calls[0][0].changes?.spritePositions?.['4key']?.[0] + .poses[0].pivot, + ).toEqual({ x: 0.5, y: 0.5 }); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + framePoints().forEach((point, index) => { + expect(point[0]).toBeCloseTo(before[index][0], 6); + expect(point[1]).toBeCloseTo(before[index][1], 6); + }); + + act(() => { + runtime.emitCommittedForCall(0, 1); + runtime.resolveNext(1); + }); + await settle(); + const linkedToggle = + container.querySelector('[role="switch"]')!; + act(() => linkedToggle.click()); + await act(async () => { + await vi.waitFor(() => expect(runtime.commit).toHaveBeenCalledTimes(2)); + }); + expect( + runtime.commit.mock.calls[1][0].changes?.spritePositions?.['4key']?.[0] + .poses[0].pivot, + ).toBeNull(); + expect(linkedToggle.getAttribute('aria-checked')).toBe('true'); + framePoints().forEach((point, index) => { + expect(point[0]).toBeCloseTo(before[index][0], 6); + expect(point[1]).toBeCloseTo(before[index][1], 6); + }); + }); + + it('앞선 저장이 큐를 점유해 자세 이동이 draft에만 있을 때 팝업 회전이 이동값을 덮지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 1. 기본 기준점 저장 - 응답을 보류해 직렬 큐를 점유한다 + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 140, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + // 2. 자세를 열고 캔버스에서 이동 - 큐가 막혀 있어 canonical엔 못 들어가고 draft에만 남는다 + act(() => poseRows()[0].click()); + await settle(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80); + await settle(); + pointer('pointerup', window, 90, 80); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + expect(canonicalSprite().poses[0].transform.x).toBe(0); + + // 3. 큐가 비기 전에 팝업에서 회전만 편집 + const rotation = container.querySelector( + 'input[aria-label="propertiesPanel.spriteRotation"]', + )!; + act(() => { + rotation.focus(); + setInputValue(rotation, '20'); + }); + act(() => rotation.blur()); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + // 4. 저장을 차례로 풀면 이동 → 회전 순으로 나가고, 회전 wire에도 이동값이 남는다 + runtime.resolveNext(1); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + runtime.resolveNext(2); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + const third = runtime.commit.mock.calls[2][0]; + const wirePose = third.changes?.spritePositions?.['4key']?.[0]?.poses?.[0]; + expect(wirePose?.transform.x).toBeCloseTo(30, 6); + expect(wirePose?.transform.y).toBeCloseTo(20, 6); + expect(wirePose?.transform.rotation).toBe(20); + + runtime.resolveNext(3); + await settle(); + const final = canonicalSprite().poses[0].transform; + expect(final.x).toBeCloseTo(30, 6); + expect(final.y).toBeCloseTo(20, 6); + expect(final.rotation).toBe(20); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('앞선 저장이 큐를 점유해도 기본 기준점 X 뒤 Y 입력이 서로를 덮지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 큐 점유 - 캔버스 기준점 드래그 저장을 보류 + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 120, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 120, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + const typePercent = (axis: 'X' | 'Y', value: string) => { + const input = container.querySelector( + `input[aria-label="propertiesPanel.spritePivot ${axis}"]`, + )!; + act(() => { + input.focus(); + setInputValue(input, value); + }); + act(() => input.blur()); + }; + typePercent('X', '25'); + await settle(); + typePercent('Y', '80'); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + runtime.resolveNext(1); + await settle(); + runtime.resolveNext(2); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + const third = runtime.commit.mock.calls[2][0]; + const wireSprite = third.changes?.spritePositions?.['4key']?.[0]; + expect(wireSprite?.pivot.x).toBeCloseTo(0.25, 9); + expect(wireSprite?.pivot.y).toBeCloseTo(0.8, 9); + runtime.resolveNext(3); + await settle(); + expect(canonicalSprite().pivot.x).toBeCloseTo(0.25, 9); + expect(canonicalSprite().pivot.y).toBeCloseTo(0.8, 9); + }); + + it('팝업에서 X를 입력한 직후 캔버스를 끌면 입력값 위에 이동량이 더해진다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + act(() => poseRows()[0].click()); + await settle(); + + // X=30을 입력하고 blur 없이 바로 캔버스 프레임을 잡는다 - 잡는 순간의 포커스 정산이 + // 30을 커밋하고, 드래그는 그 값 위에서 시작해야 한다 + // 패널의 요소 위치 X와 라벨이 같아 팝업 안에서 고른다 + const xInput = container.querySelector( + '[data-testid="pose-popup"] input[aria-label="propertiesPanel.position X"]', + )!; + act(() => { + xInput.focus(); + setInputValue(xInput, '30'); + }); + pointer('pointerdown', poseFrame()!, 60, 60); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + pointer('pointermove', window, 70, 60); + await settle(); + pointer('pointerup', window, 70, 60); + await settle(); + + // 직렬 큐 - 입력 저장이 끝나야 이동 저장이 나간다 + runtime.resolveNext(1); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + const second = runtime.commit.mock.calls[1][0]; + const wirePose = second.changes?.spritePositions?.['4key']?.[0]?.poses?.[0]; + expect(wirePose?.transform.x).toBeCloseTo(40, 6); + expect(wirePose?.transform.y).toBeCloseTo(0, 6); + runtime.resolveNext(2); + await settle(); + expect(canonicalSprite().poses[0].transform.x).toBeCloseTo(40, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('상태 기준점을 입력한 직후 기준점을 끌어도 입력값 위에서 이어진다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + act(() => poseRows()[0].click()); + await settle(); + + // 기본 기준점 연결을 끊어 상태 기준점 입력을 연다 (after-paint 커밋) + const link = container.querySelector( + '[role="switch"][aria-label="propertiesPanel.spriteFollowBasePivot"]', + )!; + act(() => link.click()); + await act(async () => { + await vi.waitFor(() => expect(runtime.commit).toHaveBeenCalledTimes(1)); + }); + + // X=25%를 입력하고 blur 없이 바로 기준점 표식을 잡는다. 표식은 입력이 확정되기 + // 전이라 아직 옛 자리(x 50%)에 있고, 잡는 순간의 정산이 25%를 커밋한다 + const pivotX = container.querySelector( + 'input[aria-label="propertiesPanel.spriteStatePivot X"]', + )!; + act(() => { + pivotX.focus(); + setInputValue(pivotX, '25'); + }); + const start = pivotCenter(); + pointer('pointerdown', pivotHandle(), start.x, start.y); + await settle(); + // 표식은 입력값 자리로 옮겨져 있다 - 자세가 8.8° 돌아 있어 이미지 축의 -50px가 + // 화면에서는 회전해 보인다. 드래그는 이 자리에서 포인터 이동량만큼 이어진다 + const rad = (8.8 * Math.PI) / 180; + const settled = pivotCenter(); + expect(settled.x).toBeCloseTo(start.x - 50 * Math.cos(rad), 6); + expect(settled.y).toBeCloseTo(start.y - 50 * Math.sin(rad), 6); + pointer('pointermove', window, start.x + 20, start.y, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, start.x + 20, start.y); + await settle(); + + runtime.resolveNext(1); + await settle(); + runtime.resolveNext(2); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + runtime.resolveNext(3); + await settle(); + + // 표식이 놓은 자리에 남고, 저장된 기준점은 25%에서 20px만큼 옮긴 값이다 + // (자세 회전 8.8°라 화면 +x는 이미지 축으로 되돌려 잰다) + const landed = pivotCenter(); + expect(landed.x).toBeCloseTo(settled.x + 20, 6); + expect(landed.y).toBeCloseTo(settled.y, 6); + const pivot = canonicalSprite().poses[0].pivot!; + expect(pivot.x).toBeCloseTo(0.25 + (20 * Math.cos(rad)) / 200, 6); + expect(pivot.y).toBeCloseTo(0.5 - (20 * Math.sin(rad)) / 150, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx new file mode 100644 index 000000000..bd51eebc1 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx @@ -0,0 +1,803 @@ +/** + * 스프라이트 캔버스 핸들 + * - 선택 중엔 기준점 십자를 그리고, 드래그가 9점 스냅·보정 patch를 commit한다 + * - 시작 시점 canonical이 바뀌면(리사이즈 착지·다른 창 편집) 낡은 patch를 버린다 + * - 자세 세션은 본체 이동·모서리 바깥 회전·모서리 배율을 요소 로컬 px로 계산한다 + * - undo/redo 반영·다른 pointerId·세션 종료는 진행 중 드래그를 커밋 없이 닫는다 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + useSpritePoseHandleStore, + type SpritePoseHandleSession, +} from '@stores/grid/useSpritePoseHandleStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; +import { releaseDragSession } from '@hooks/Grid/drag/dragSession'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { + makeCanonicalSpritePosition, + makeSpritePose, +} from '@utils/sprite/spriteFixtures'; + +import SpriteCanvasHandles from './SpriteCanvasHandles'; + +const mocks = vi.hoisted(() => ({ + patchPosition: vi.fn( + (_mode: string, _id: string, _patch: unknown, _gestureId?: string) => + Promise.resolve(undefined), + ), + discardOrphanedLocalPreviews: vi.fn(() => false), + settleCommit: vi.fn(), +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@api/modules/editor/itemsApi', () => ({ + spriteItemsApi: { patchPosition: mocks.patchPosition }, +})); +vi.mock('@src/renderer/editor/runtime/gesture/editGestureController', () => ({ + editGestureController: { + discardOrphanedLocalPreviews: mocks.discardOrphanedLocalPreviews, + activeGestureId: () => undefined, + settleCommit: mocks.settleCommit, + }, +})); +vi.mock('@src/renderer/editor/model/elementIdMap', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveElementById: () => ({ mode: '4key' }), +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + +// 200x100 상자, 원점 (10, 20), 기준점 중앙 → 십자 로컬 (100, 50) +const sprite = () => + makeCanonicalSpritePosition({ + id: SPRITE_ID, + dx: 10, + dy: 20, + width: 200, + height: 100, + pivot: { x: 0.5, y: 0.5 }, + poses: [ + makeSpritePose({ + poseId: 'pose-1', + triggers: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], + transform: { x: 10, y: 0, rotation: 90, scale: 1 }, + }), + ], + }); + +const makeSession = ( + overrides: Partial = {}, +): SpritePoseHandleSession => ({ + positionId: SPRITE_ID, + poseId: 'pose-1', + origin: { dx: 10, dy: 20 }, + width: 200, + height: 100, + pivot: { x: 0.5, y: 0.5 }, + imagePivot: { x: 0.5, y: 0.5 }, + followsBasePivot: true, + placement: { + rect: { x: 0, y: 0, width: 200, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + }, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + preview: vi.fn(), + commit: vi.fn(), + previewPivot: vi.fn(), + commitPivot: vi.fn(), + cancel: vi.fn(), + ...overrides, +}); + +describe('SpriteCanvasHandles', () => { + let container: HTMLDivElement; + let root: Root; + + const pivotHandle = () => + container.querySelector('[data-sprite-pivot-handle="true"]'); + const poseFrame = () => + container.querySelector( + '[data-sprite-pose-frame="true"]', + ); + const rotateCorner = () => + container.querySelector('[data-rotate-corner="0"]'); + const scaleKnobs = () => + container.querySelectorAll('[data-sprite-scale-knob="true"]'); + + const pointer = ( + type: string, + target: EventTarget, + init: { + clientX?: number; + clientY?: number; + pointerId?: number; + buttons?: number; + shiftKey?: boolean; + ctrlKey?: boolean; + } = {}, + ) => + act(() => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: init.buttons ?? (type === 'pointerup' ? 0 : 1), + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + pointerId: init.pointerId ?? 1, + shiftKey: init.shiftKey ?? false, + ctrlKey: init.ctrlKey ?? false, + }), + ); + }); + + // move는 rAF 스케줄러에 실리므로 한 틱 기다려 preview를 흘린다 + const flushFrame = async () => { + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + }; + + const render = ( + selected: SelectedElement[] = [{ type: 'sprite', id: SPRITE_ID }], + view: { zoom?: number; panX?: number; panY?: number } = {}, + ) => { + act(() => { + root.render( + , + ); + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + useSpriteStore.setState({ positions: { '4key': [sprite()] } }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + act(() => useSpritePoseHandleStore.getState().setSession(null)); + useSpriteStore.setState({ positions: {} }); + releaseDragSession(); + vi.unstubAllGlobals(); + }); + + it('선택이 없으면 그리지 않고, 스프라이트가 선택되면 기준점 십자를 그린다', () => { + render([]); + expect(pivotHandle()).toBeNull(); + + render(undefined, { zoom: 2, panX: 5, panY: 7 }); + const handle = pivotHandle()!; + // 로컬 (100, 50) → 화면 ((10+100)*2+5, (20+50)*2+7) = (225, 147), 히트 26 중심 + expect(handle.style.left).toBe('212px'); + expect(handle.style.top).toBe('134px'); + expect(poseFrame()).toBeNull(); + }); + + it('스냅 해제 보조키 드래그는 캔버스 메뉴를 열지 않는다', () => { + render(); + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + ctrlKey: true, + }); + + pivotHandle()!.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + + it('배치가 90도 돌아도 기준점 포인터를 상자 로컬 축으로 해석한다', async () => { + useSpriteStore.setState({ + positions: { '4key': [{ ...sprite(), rotation: 90 }] }, + }); + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { + clientX: 60, + clientY: 170, + ctrlKey: true, + }); + await flushFrame(); + pointer('pointerup', window, { clientX: 60, clientY: 170, ctrlKey: true }); + expect(mocks.patchPosition.mock.calls.at(-1)?.slice(0, 3)).toEqual([ + '4key', + SPRITE_ID, + expect.objectContaining({ pivot: { x: 1, y: 1 } }), + ]); + await flushFrame(); + }); + + it('기준점 조작 전에 포커스된 입력을 정산한다', () => { + render(); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + + expect(document.activeElement).not.toBe(input); + input.remove(); + }); + + it('상자 핸들이 보이는 동안 표식은 선택 테두리 프레임에 앉고 자세 편집 중엔 상자 그대로다', () => { + useSpriteStore.setState({ + positions: { '4key': [{ ...sprite(), pivot: { x: 1, y: 0 } }] }, + }); + render(); + // 로컬 (200, 0) → 화면 (210, 20), 테두리 선 중심은 모서리 밖 1px → (211, 19), 히트 26 중심 + expect(pivotHandle()!.style.left).toBe('198px'); + expect(pivotHandle()!.style.top).toBe('6px'); + + act(() => + useSpritePoseHandleStore + .getState() + .setSession(makeSession({ pivot: { x: 1, y: 0 } })), + ); + render(); + // 자세 프레임 선은 상자 그대로라 보정 없이 (210, 20) + expect(pivotHandle()!.style.left).toBe('197px'); + expect(pivotHandle()!.style.top).toBe('7px'); + }); + + it('기준점 드래그는 그림 프리뷰 없이 표식만 9점에 스냅하고 놓으면 커밋한다', async () => { + render(); + const handle = pivotHandle()!; + pointer('pointerdown', handle, { clientX: 110, clientY: 70 }); + // 왼쪽 아래 모서리 (10, 120)에서 5px 안쪽 - 스냅 반경 8px 안 + pointer('pointermove', window, { clientX: 14, clientY: 117 }); + await flushFrame(); + + expect(parseFloat(handle.style.left) + 13).toBeCloseTo(9, 6); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + + pointer('pointerup', window, { clientX: 14, clientY: 117 }); + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock.calls[0][2] as { + pivot: { x: number; y: number }; + poses: Array<{ transform: { x: number; y: number } }>; + }; + expect(patch.pivot).toEqual({ x: 0, y: 1 }); + // 연결 상태는 이동값을 유지해 기본 축 변화량을 그대로 따라간다 + expect(patch.poses[0].transform.x).toBe(10); + expect(patch.poses[0].transform.y).toBe(0); + + expect(mocks.patchPosition.mock.calls[0][2]).toEqual(patch); + expect(mocks.settleCommit).toHaveBeenCalled(); + }); + + it('Ctrl을 누르면 스냅 없이 포인터 자리를 그대로 기준점으로 쓴다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { + clientX: 14, + clientY: 117, + ctrlKey: true, + }); + await flushFrame(); + pointer('pointerup', window, { clientX: 14, clientY: 117 }); + const patch = mocks.patchPosition.mock.calls[0][2] as { + pivot: { x: number; y: number }; + }; + expect(patch.pivot.x).toBeCloseTo(4 / 200, 9); + expect(patch.pivot.y).toBeCloseTo(97 / 100, 9); + }); + + it('스냅된 기준점은 더 넓은 이탈 반경까지 유지해 경계에서 떨리지 않는다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + + pointer('pointermove', window, { clientX: 117, clientY: 70 }); + await flushFrame(); + expect(parseFloat(pivotHandle()!.style.left) + 13).toBeCloseTo(110, 6); + + pointer('pointermove', window, { clientX: 119, clientY: 70 }); + await flushFrame(); + expect(parseFloat(pivotHandle()!.style.left) + 13).toBeCloseTo(110, 6); + + pointer('pointermove', window, { clientX: 123, clientY: 70 }); + await flushFrame(); + expect(parseFloat(pivotHandle()!.style.left) + 13).toBeGreaterThan(122); + pointer('pointerup', window, { clientX: 123, clientY: 70 }); + }); + + it('기본 기준점은 pointerup이 빠져도 mouseup으로 마지막 위치를 커밋한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 150, clientY: 70 }); + await flushFrame(); + + act(() => { + window.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + }), + ); + }); + + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + }); + + it('기본 기준점은 캡처 해제로 끝나도 마지막 위치를 커밋한다', async () => { + render(); + const handle = pivotHandle()!; + pointer('pointerdown', handle, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 150, clientY: 70 }); + await flushFrame(); + + pointer('lostpointercapture', handle, { pointerId: 1, buttons: 0 }); + + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + }); + + it('기본 기준점은 눌린 move 뒤 buttons 0 move로 끝나도 커밋한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { + clientX: 130, + clientY: 70, + buttons: 1, + }); + await flushFrame(); + + pointer('pointermove', window, { + clientX: 150, + clientY: 70, + buttons: 0, + }); + + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + }); + + it('창 포커스를 잃은 기본 기준점 드래그는 저장하지 않고 표시만 복원한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 150, clientY: 70 }); + await flushFrame(); + + act(() => window.dispatchEvent(new Event('blur'))); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + expect(parseFloat(pivotHandle()!.style.left) + 13).toBeCloseTo(110, 6); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('드래그 중 canonical이 바뀌면 낡은 patch를 커밋하지 않고 포기한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 14, clientY: 117 }); + await flushFrame(); + expect(parseFloat(pivotHandle()!.style.left) + 13).not.toBeCloseTo(110, 6); + + // 리사이즈 착지·다른 창 편집 - 스토어 객체가 교체된다 + useSpriteStore.setState({ + positions: { '4key': [{ ...sprite(), width: 400 }] }, + }); + pointer('pointerup', window, { clientX: 14, clientY: 117 }); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('드래그 중 요소 원점이 바뀌면 이전 좌표계의 기준점 patch를 버린다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 150, clientY: 70 }); + await flushFrame(); + + useSpriteStore.setState({ + positions: { '4key': [{ ...sprite(), dx: 40, dy: 60 }] }, + }); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('undo/redo 반영은 진행 중 기준점 드래그를 커밋 없이 취소한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 14, clientY: 117 }); + await flushFrame(); + + act(() => + useCommittedApplyStore.setState((state) => ({ + historyTick: state.historyTick + 1, + })), + ); + pointer('pointerup', window, { clientX: 14, clientY: 117 }); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + describe('자세 세션', () => { + const openSession = ( + overrides: Partial = {}, + view: { zoom?: number; panX?: number; panY?: number } = {}, + ) => { + const session = makeSession(overrides); + act(() => useSpritePoseHandleStore.getState().setSession(session)); + render(undefined, view); + return session; + }; + + it('자세 프레임과 기준점은 배치 회전과 로컬 자세 회전을 합성한다', () => { + openSession({ + rotation: 90, + transform: { x: 30, y: 0, rotation: 90, scale: 1 }, + }); + const corners = poseFrame()! + .getAttribute('points')! + .split(' ') + .map((point) => point.split(',').map(Number)); + [ + [210, 150], + [10, 150], + [10, 50], + [210, 50], + ].forEach((expected, index) => { + expect(corners[index][0]).toBeCloseTo(expected[0], 8); + expect(corners[index][1]).toBeCloseTo(expected[1], 8); + }); + expect(Number.parseFloat(pivotHandle()!.style.left) + 13).toBeCloseTo( + 110, + 8, + ); + expect(Number.parseFloat(pivotHandle()!.style.top) + 13).toBeCloseTo( + 100, + 8, + ); + }); + + it('배치가 돌아도 자세 이동과 포즈 기준점은 로컬 축으로 편집한다', async () => { + const session = openSession({ rotation: 90 }, { zoom: 2 }); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 140, clientY: 80 }); + await flushFrame(); + expect(session.preview).toHaveBeenLastCalledWith( + expect.objectContaining({ x: -10, y: -20 }), + ); + pointer('pointerup', window, { clientX: 140, clientY: 80 }); + expect(session.commit).toHaveBeenLastCalledWith( + expect.objectContaining({ x: -10, y: -20 }), + ); + const pivotSession = openSession({ rotation: 90 }); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { + clientX: 110, + clientY: 90, + ctrlKey: true, + }); + await flushFrame(); + expect(Number.parseFloat(pivotHandle()!.style.left) + 13).toBeCloseTo( + 110, + 8, + ); + expect(Number.parseFloat(pivotHandle()!.style.top) + 13).toBeCloseTo( + 90, + 8, + ); + pointer('pointerup', window, { + clientX: 110, + clientY: 90, + ctrlKey: true, + }); + expect(pivotSession.commitPivot).toHaveBeenCalledWith( + expect.objectContaining({ x: 0.6, y: 0.5 }), + expect.anything(), + ); + }); + + it('배치 각도를 더하지 않고 자세 회전과 배율만 편집한다', async () => { + const rotateSession = openSession({ + rotation: 90, + transform: { x: 0, y: 0, rotation: 45, scale: 1 }, + }); + pointer('pointerdown', rotateCorner()!, { clientX: 160, clientY: 70 }); + pointer('pointermove', window, { clientX: 110, clientY: 120 }); + await flushFrame(); + expect(rotateSession.preview).toHaveBeenLastCalledWith( + expect.objectContaining({ rotation: 135 }), + ); + pointer('pointerup', window, { clientX: 110, clientY: 120 }); + const scaleSession = openSession({ + rotation: 90, + transform: { x: 30, y: 0, rotation: 45, scale: 1 }, + }); + pointer('pointerdown', scaleKnobs()[0], { clientX: 160, clientY: 100 }); + pointer('pointermove', window, { clientX: 210, clientY: 100 }); + await flushFrame(); + expect(scaleSession.preview).toHaveBeenLastCalledWith( + expect.objectContaining({ rotation: 45, scale: 2 }), + ); + pointer('pointerup', window, { clientX: 210, clientY: 100 }); + }); + + it('도중에 배치 회전이 바뀌면 이전 자세 포인터를 커밋하지 않는다', async () => { + const session = openSession(); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 140, clientY: 80 }); + await flushFrame(); + act(() => + useSpritePoseHandleStore + .getState() + .setSession({ ...session, rotation: 90 }), + ); + pointer('pointerup', window, { clientX: 140, clientY: 80 }); + expect(session.commit).not.toHaveBeenCalled(); + }); + + it('프레임과 회전·배율 영역 각 4개를 그리고 십자가 자세 축을 따라간다', () => { + openSession({ transform: { x: 30, y: 0, rotation: 0, scale: 1 } }); + expect(poseFrame()).not.toBeNull(); + expect(rotateCorner()).not.toBeNull(); + expect(container.querySelectorAll('[data-rotate-corner]')).toHaveLength( + 4, + ); + expect(container.querySelector('[data-sprite-rotate-knob]')).toBeNull(); + expect(poseFrame()!.parentElement!.querySelector('line')).toBeNull(); + expect(scaleKnobs().length).toBe(4); + // 십자 = 원점 + P + 이동값 = (10+100+30, 20+50) → 히트 26 좌상단 (127, 57) + expect(pivotHandle()!.style.left).toBe('127px'); + expect(pivotHandle()!.style.top).toBe('57px'); + }); + + it('본체 드래그는 줌을 나눈 로컬 이동량만큼 위치를 preview하고 놓으면 커밋한다', async () => { + const session = openSession({}, { zoom: 2 }); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 140, clientY: 80 }); + await flushFrame(); + expect(session.preview).toHaveBeenLastCalledWith({ + x: 20, + y: -10, + rotation: 0, + scale: 1, + }); + expect(session.commit).not.toHaveBeenCalled(); + pointer('pointerup', window, { clientX: 140, clientY: 80 }); + expect(session.commit).toHaveBeenCalledWith({ + x: 20, + y: -10, + rotation: 0, + scale: 1, + }); + }); + + it('모서리 바깥 회전은 실제 영역 중심에서 시작하고 Shift는 15° 단위로 스냅한다', async () => { + const session = openSession(); + const handle = rotateCorner()!; + const fromX = + parseFloat(handle.style.left) + parseFloat(handle.style.width) / 2; + const fromY = + parseFloat(handle.style.top) + parseFloat(handle.style.height) / 2; + const toX = 110 - (fromY - 70); + const toY = 70 + (fromX - 110); + pointer('pointerdown', handle, { clientX: fromX, clientY: fromY }); + pointer('pointermove', window, { clientX: toX, clientY: toY }); + await flushFrame(); + const rotated = ( + session.preview as ReturnType + ).mock.calls.at(-1)![0]; + expect(rotated.rotation).toBeCloseTo(90, 6); + + pointer('pointermove', window, { + clientX: toX + 3, + clientY: toY, + shiftKey: true, + }); + await flushFrame(); + const snapped = ( + session.preview as ReturnType + ).mock.calls.at(-1)![0]; + expect(snapped.rotation).toBe(90); + expect(session.commit).not.toHaveBeenCalled(); + pointer('pointerup', window, { clientX: toX + 3, clientY: toY }); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ rotation: 90 }), + ); + }); + + it('모서리 노브는 축까지 거리 비로 배율을 바꾼다', async () => { + const session = openSession(); + // 축 (110, 70)에서 50px 떨어진 곳에서 100px 떨어진 곳으로 - 배율 2 + pointer('pointerdown', scaleKnobs()[0], { clientX: 160, clientY: 70 }); + pointer('pointermove', window, { clientX: 210, clientY: 70 }); + await flushFrame(); + const scaled = ( + session.preview as ReturnType + ).mock.calls.at(-1)![0]; + expect(scaled.scale).toBeCloseTo(2, 9); + expect(session.commit).not.toHaveBeenCalled(); + pointer('pointerup', window, { clientX: 210, clientY: 70 }); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ scale: 2 }), + ); + }); + + it('다른 pointerId의 move·up은 무시되고 세션 종료가 드래그를 취소한다', async () => { + const session = openSession(); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { + clientX: 150, + clientY: 100, + pointerId: 2, + }); + await flushFrame(); + expect(session.preview).not.toHaveBeenCalled(); + pointer('pointerup', window, { + clientX: 150, + clientY: 100, + pointerId: 2, + }); + expect(session.commit).not.toHaveBeenCalled(); + + act(() => useSpritePoseHandleStore.getState().setSession(null)); + expect(session.cancel).toHaveBeenCalled(); + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + expect(session.commit).not.toHaveBeenCalled(); + }); + + it('캡처 해제의 buttons가 남아 있어도 마지막 자세를 커밋한다', async () => { + const session = openSession(); + const frame = poseFrame()!; + pointer('pointerdown', frame, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 150, clientY: 100 }); + await flushFrame(); + expect(session.preview).toHaveBeenCalled(); + + pointer('lostpointercapture', frame, { pointerId: 1 }); + expect(session.commit).toHaveBeenCalledOnce(); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ x: 50, y: 0 }), + ); + expect(session.cancel).not.toHaveBeenCalled(); + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + expect(session.commit).toHaveBeenCalledOnce(); + }); + + it('버튼을 놓은 캡처 해제는 누락된 up 대신 마지막 자세를 커밋한다', async () => { + const session = openSession(); + const frame = poseFrame()!; + pointer('pointerdown', frame, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 150, clientY: 100 }); + await flushFrame(); + + pointer('lostpointercapture', frame, { pointerId: 1, buttons: 0 }); + expect(session.commit).toHaveBeenCalledOnce(); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ x: 50, y: 0 }), + ); + expect(session.cancel).not.toHaveBeenCalled(); + + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + expect(session.commit).toHaveBeenCalledOnce(); + }); + + it('pointerup이 누락돼도 mouseup으로 마지막 자세를 커밋한다', async () => { + const session = openSession(); + const frame = poseFrame()!; + pointer('pointerdown', frame, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 150, clientY: 100 }); + await flushFrame(); + + act(() => { + window.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + }), + ); + }); + expect(session.commit).toHaveBeenCalledOnce(); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ x: 50, y: 0 }), + ); + + pointer('pointermove', window, { + clientX: 180, + clientY: 100, + buttons: 0, + }); + await flushFrame(); + expect(session.preview).toHaveBeenCalledTimes(1); + }); + + it('눌린 move 뒤 buttons 0 move만 오면 누락된 up으로 정산한다', async () => { + const session = openSession(); + const frame = poseFrame()!; + pointer('pointerdown', frame, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { + clientX: 130, + clientY: 100, + buttons: 1, + }); + await flushFrame(); + + pointer('pointermove', window, { + clientX: 150, + clientY: 100, + buttons: 0, + }); + expect(session.commit).toHaveBeenCalledOnce(); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ x: 50, y: 0 }), + ); + + pointer('pointermove', window, { + clientX: 180, + clientY: 100, + buttons: 0, + }); + pointer('pointerup', window, { clientX: 180, clientY: 100 }); + await flushFrame(); + expect(session.preview).toHaveBeenCalledTimes(2); + expect(session.commit).toHaveBeenCalledOnce(); + }); + + it('리사이즈 착지의 소유권 무효화 뒤 up은 커밋 대신 취소한다', async () => { + const session = openSession(); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 150, clientY: 100 }); + await flushFrame(); + act(() => + useSpritePoseHandleStore.getState().invalidateOwnership(SPRITE_ID), + ); + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + expect(session.commit).not.toHaveBeenCalled(); + expect(session.cancel).toHaveBeenCalled(); + }); + + it('같은 자세라도 요소 원점이 바뀌면 진행 중 드래그의 좌표계 소유권을 무효화한다', () => { + const session = openSession(); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + + act(() => + useSpritePoseHandleStore.getState().setSession({ + ...session, + origin: { dx: 40, dy: 60 }, + }), + ); + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + + expect(session.commit).not.toHaveBeenCalled(); + expect(session.cancel).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx new file mode 100644 index 000000000..ee827a085 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx @@ -0,0 +1,1128 @@ +'use no memo'; +// 렌더 중 ref 대입(세션·지오메트리 최신화)이 React Compiler bailout이라 +// 컴파일 대상에서 빠진다. 조용한 제외와 구분되게 명시한다 (GradientAxisHandle과 같은 이유) +import React, { useEffect, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { useTranslation } from '@contexts/useTranslation'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { + useSpritePoseHandleStore, + type SpritePoseHandleSession, +} from '@stores/grid/useSpritePoseHandleStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; +import { spriteItemsApi } from '@api/modules/editor/itemsApi'; +import { subscribeHistoryEditorFlushStart } from '@src/renderer/editor/runtime/lifecycle/historyEditorFlushLock'; +import { editGestureController } from '@src/renderer/editor/runtime/gesture/editGestureController'; +import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import { + SPRITE_CONSTRAINTS, + type ReactiveSpritePosition, + type SpriteAnchor, + type SpriteTransform, +} from '@src/types/key/sprites'; +import { + DEG_TO_RAD, + isSameSpriteAnchor, + isSameSpriteTransform, + spritePivotPx, +} from '@utils/sprite/spriteGeometry'; +import { + compensateTransformForPivotDelta, + compensateTransformForPosePivotDelta, + pivotHandleLocalPoint, + pivotForHandleTarget, + placeSpriteVisual, + posePivotHandleLocalPoint, + posePivotForHandleTarget, + snapPivotToPreset, + snapPosePivotToPreset, + spriteIdleVisual, + spritePivotChangePatch, + type SpritePivotHandleFrame, + type SpritePosePivotHandleFrame, +} from '@utils/sprite/spritePlacement'; +import { clamp } from '@utils/number/clamp'; +import { resolveRotationDrag } from '@utils/element/rotation'; +import { rotatePointAround } from '@utils/element/rotation'; +import { suppressNextClick } from '@utils/dom/suppressNextClick'; +import { createRafLatestScheduler } from '@utils/animation/rafLatestScheduler'; +import { beginDragCursor, endDragCursor } from '@utils/dom/dragCursor'; +import { + getCursor, + lockCustomCursor, + unlockCustomCursor, + type RotationCursorType, +} from '@utils/grid/cursorUtils'; +import { getActiveElement } from '@utils/dom/activeElement'; +import { isHTMLElementNode } from '@utils/dom/isElementNode'; +import { + releaseDragSession, + tryAcquireDragSession, +} from '@hooks/Grid/drag/dragSession'; +import { SELECTION_BORDER_CENTER } from './selectionOutline'; +import RotateCornerHandles from './RotateCornerHandles'; + +/** + * 온캔버스 스프라이트 핸들. + * 스프라이트가 선택돼 있으면 기준점 십자를 그리고 드래그로 옮긴다 (9점 자석 스냅, + * Ctrl/Cmd로 해제). 기본 그림과 독립 상태는 제자리에 두고 연결 상태는 새 축을 따른다. + * 자세 팝업이 열려 있으면 자세 이미지 프레임을 그린다: 본체 드래그 = 위치, + * 모서리 바깥 = 회전(Shift 15° 스냅), 모서리 = 배율(기준점 중심) + * 배치는 GradientAxisOverlay와 동일한 비스케일 오버레이 층 - zoom/pan을 직접 계산. + * 드래그 계산은 전부 요소 로컬 px에서 하므로 도중에 팬·줌이 바뀌어도 포인터가 + * 가리키는 자리를 그대로 따라간다 + */ + +interface SpriteCanvasHandlesProps { + spritePositions: Record; + selectedElements: SelectedElement[]; + selectedKeyType: string; + zoom: number; + panX: number; + panY: number; +} + +type DragKind = 'pivot' | 'move' | 'rotate' | 'scale'; + +interface Point { + x: number; + y: number; +} + +type PivotDragBase = + | { + kind: 'base'; + mode: string; + id: string; + canonical: ReactiveSpritePosition; + frame: SpritePivotHandleFrame; + grabOffset: Point; + } + | { + kind: 'pose'; + poseId: string; + /** 세션(표시 기하)에서 뜬 프레임 - 저장 대기 중인 필드 편집도 그대로 담긴다 */ + frame: SpritePosePivotHandleFrame; + grabOffset: Point; + }; + +interface DragState { + kind: DragKind; + pointerId: number; + /** buttons가 유효했던 이동을 본 뒤 0으로 바뀌면 누락된 up으로 판정 */ + sawPressedMove: boolean; + /** 드래그 시작 포인터 (요소 로컬 px) */ + startLocal: Point; + /** 드래그 시작 시점 자세 transform (자세 핸들) */ + baseTransform: SpriteTransform; + /** 회전·배율 축의 로컬 위치 - 시작 시점 이동값이 더해진 P */ + axisLocal: Point; + startAngle: number; + startDistance: number; + /** 시작 시점 세션 - 스토어 세션이 먼저 닫혀도 cancel 배선을 유지한다 */ + session: SpritePoseHandleSession | null; + /** 기준점 드래그의 canonical 스프라이트 - 도중에 바뀌면 드래그를 포기한다 */ + pivotBase: PivotDragBase | null; + /** 자석에 붙은 프리셋 - 더 넓은 이탈 반경까지 유지해 경계 떨림 방지 */ + snappedPivot: SpriteAnchor | null; +} + +interface PendingBasePivotLanding { + generation: number; + mode: string; + id: string; + pivot: SpriteAnchor; +} + +/** 최신 렌더의 표시 기하 - 드래그 시작이 포커스 정산 뒤에 읽는다 */ +interface HandleGeometry { + session: SpritePoseHandleSession | null; + sprite: ReactiveSpritePosition | null; + origin: { dx: number; dy: number }; + rotation: number; + box: { width: number; height: number }; + imagePivot: SpriteAnchor; + displayTransform: SpriteTransform; + pivotLocal: Point; +} + +const KNOB_HIT_SIZE = 22; +const PIVOT_SNAP_PX = 8; +const PIVOT_SNAP_RELEASE_PX = 12; +// 기준점 표식 - 포커스 브래킷: 가운데 점을 네 귀퉁이 꺾쇠가 감싼다. 꺾쇠는 대각선 +// 방향으로만 뻗어 선택 테두리와 겹치지 않고, 호버·드래그에서 안쪽으로 조여든다 +const PIVOT_HIT_SIZE = 26; +const PIVOT_CENTER = PIVOT_HIT_SIZE / 2; +const PIVOT_BRACKET_REACH = 8; +const PIVOT_BRACKET_ARM = 4.5; +const PIVOT_BRACKET_ACTIVE_SCALE = 0.8125; +const PIVOT_DOT_RADIUS = 1.75; +const PIVOT_DOT_ACTIVE_RADIUS = 2; +const PIVOT_BRACKETS = (() => { + const near = PIVOT_CENTER - PIVOT_BRACKET_REACH; + const far = PIVOT_CENTER + PIVOT_BRACKET_REACH; + const arm = PIVOT_BRACKET_ARM; + return [ + `M${near} ${near + arm}V${near}H${near + arm}`, + `M${far - arm} ${near}H${far}V${near + arm}`, + `M${far} ${far - arm}V${far}H${far - arm}`, + `M${near + arm} ${far}H${near}V${far - arm}`, + ].join(''); +})(); +// 축과 겹친 모서리는 배율 방향이 정의되지 않아 잡지 않는다 +const MIN_SCALE_ARM_PX = 2; + +const findCanonicalSprite = ( + mode: string, + id: string, +): ReactiveSpritePosition | null => + useSpriteStore + .getState() + .positions[mode]?.find((candidate) => candidate.id === id) ?? null; + +const SpriteCanvasHandles = ({ + spritePositions, + selectedElements, + selectedKeyType, + zoom, + panX, + panY, +}: SpriteCanvasHandlesProps) => { + const { t } = useTranslation(); + const session = useSpritePoseHandleStore((state) => state.session); + + const rootRef = useRef(null); + const dragRef = useRef(null); + const grabbedRef = useRef<{ el: Element; pointerId: number } | null>(null); + const detachRef = useRef<(() => void) | null>(null); + const moveSchedulerRef = useRef + > | null>(null); + const ownerGenerationRef = useRef(0); + const lastTransformRef = useRef(null); + const lastPivotPatchRef = useRef | null>(null); + const lastPosePivotRef = useRef<{ + pivot: SpriteAnchor; + transform: SpriteTransform; + } | null>(null); + const pendingBasePivotGenerationRef = useRef(0); + const [pendingBasePivotLanding, setPendingBasePivotLanding] = + useState(null); + + // 드래그 중 로컬 표시값 - preview 채널과 별개로 핸들을 즉시 따라가게 한다 + const [dragTransform, setDragTransform] = useState( + null, + ); + const [dragPivot, setDragPivot] = useState(null); + // 기준점 드래그 중 표식이 놓일 보정 transform - 오버레이 재합성을 기다리지 않고 + // 표식이 포인터 아래에 바로 놓이게 한다 (자세 편집 중엔 세션 값이 보정되지 않아 필수) + const [dragPivotTransform, setDragPivotTransform] = + useState(null); + const [pivotHover, setPivotHover] = useState(false); + + const selected = selectedElements.length === 1 ? selectedElements[0] : null; + const sprite = + selected && selected.type === 'sprite' + ? spritePositions[selectedKeyType]?.find( + (candidate) => candidate.id === selected.id, + ) ?? null + : null; + + // 드래그 도중 휠 팬·줌이 좌표를 움직여도 매 이벤트에서 최신 뷰로 + // 재계산한다 (GradientAxisHandle과 같은 이유의 렌더 중 ref 대입) + const viewRef = useRef({ zoom, panX, panY }); + // eslint-disable-next-line react-hooks/refs + viewRef.current = { zoom, panX, panY }; + const sessionRef = useRef(session); + // eslint-disable-next-line react-hooks/refs + sessionRef.current = session; + // 드래그 시작은 포커스 필드 정산(flushSync) 뒤에 온다. 정산이 값을 커밋하면 세션과 + // 표시 기하가 같은 프레임에 바뀌므로 시작 값은 렌더 클로저가 아니라 여기서 읽는다 + const latestRef = useRef(null); + + // 활성 드래그 취소 배선 - 세션 종료·대상 교체·언마운트에서 + // window 리스너·포인터 캡처·전역 드래그 락·열린 preview 제스처를 함께 정리한다 + const cancelDragRef = useRef<(() => void) | null>(null); + useEffect( + () => subscribeHistoryEditorFlushStart(() => cancelDragRef.current?.()), + [], + ); + const ownerKey = session + ? `${session.positionId}\n${session.poseId}` + : sprite + ? sprite.id + : null; + useEffect(() => { + if (dragRef.current) cancelDragRef.current?.(); + if (ownerKey === null) { + pendingBasePivotGenerationRef.current += 1; + setPendingBasePivotLanding(null); + } + }, [ownerKey]); + useEffect( + () => () => { + pendingBasePivotGenerationRef.current += 1; + cancelDragRef.current?.(); + }, + [], + ); + + // undo/redo 반영은 대상도 세대도 바꾸지 않아 위 배선에 걸리지 않는다. 그대로 두면 + // 저장값이 되돌아간 뒤 pointerup이 시작 시점 값으로 푼 결과를 다시 커밋한다 + useEffect( + () => + useCommittedApplyStore.subscribe((state, previous) => { + if (state.historyTick === previous.historyTick) return; + cancelDragRef.current?.(); + pendingBasePivotGenerationRef.current += 1; + setPendingBasePivotLanding(null); + }), + [], + ); + + if (!sprite && !session) { + // eslint-disable-next-line react-hooks/refs + latestRef.current = null; + if ( + dragTransform !== null || + dragPivot !== null || + dragPivotTransform !== null || + pendingBasePivotLanding !== null + ) { + // 대상이 사라지면 로컬 드래그 상태도 함께 비운다 (렌더 중 보정 패턴) + setDragTransform(null); + setDragPivot(null); + setDragPivotTransform(null); + } + return null; + } + + // 기하의 원천 - 세션이 있으면 세션(자세 편집 대상), 없으면 선택 스프라이트 + const origin = session ? session.origin : { dx: sprite!.dx, dy: sprite!.dy }; + const box = session + ? { width: session.width, height: session.height } + : { width: sprite!.width, height: sprite!.height }; + const rotation = session ? session.rotation ?? 0 : sprite!.rotation ?? 0; + const visiblePendingBasePivot = + !session && + pendingBasePivotLanding?.mode === selectedKeyType && + pendingBasePivotLanding.id === sprite?.id + ? spritePivotChangePatch(sprite!, pendingBasePivotLanding.pivot) + : null; + const imagePivot = + dragPivot ?? + visiblePendingBasePivot?.pivot ?? + (session ? session.imagePivot : sprite!.pivot); + const axisPivot = session ? session.pivot : imagePivot; + const displayTransform = + dragPivotTransform ?? + dragTransform ?? + visiblePendingBasePivot?.idleTransform ?? + (session ? session.transform : sprite!.idleTransform); + const axis = spritePivotPx({ ...box, pivot: axisPivot }); + + const toScreen = (local: Point): Point => { + const point = rotatePointAround( + local, + { x: box.width / 2, y: box.height / 2 }, + rotation, + ); + return { + x: (origin.dx + point.x) * zoom + panX, + y: (origin.dy + point.y) * zoom + panY, + }; + }; + // 십자는 화면에서 실제로 회전이 일어나는 점 - 축에 이동값이 더해진다 + const pivotLocal = { + x: axis.x + displayTransform.x, + y: axis.y + displayTransform.y, + }; + // eslint-disable-next-line react-hooks/refs + latestRef.current = { + session, + sprite, + origin, + rotation, + box, + imagePivot, + displayTransform, + pivotLocal, + }; + // 선택 테두리와 리사이즈 핸들은 상자 밖 1px 프레임에 그려진다. 상자 핸들이 보이는 동안은 + // 표식도 그 프레임에 앉혀 모서리·변에서 선·핸들 중심과 정확히 겹치게 한다. + // 자세 편집 중에는 자세 프레임 선이 상자 그대로라 보정하지 않는다 + const frameInset = session ? 0 : SELECTION_BORDER_CENTER; + const pivotAxisScreen = toScreen(pivotLocal); + const pivotInset = rotatePointAround( + { + x: (2 * axisPivot.x - 1) * frameInset, + y: (2 * axisPivot.y - 1) * frameInset, + }, + { x: 0, y: 0 }, + rotation, + ); + const pivotScreen = { + x: pivotAxisScreen.x + pivotInset.x, + y: pivotAxisScreen.y + pivotInset.y, + }; + + const posePlacement = session + ? { + rect: { + ...session.placement.rect, + x: + session.placement.rect.x - + (imagePivot.x - session.imagePivot.x) * + session.placement.rect.width, + y: + session.placement.rect.y - + (imagePivot.y - session.imagePivot.y) * + session.placement.rect.height, + }, + pivot: imagePivot, + } + : null; + + // 뷰(zoom·pan)와 원점은 ref에서 읽는다 - 드래그 중 최신 값이어야 팬·줌과 + // 정산된 위치 편집을 따라간다 + const clientToLocal = (clientX: number, clientY: number): Point | null => { + const hostRect = rootRef.current?.getBoundingClientRect(); + if (!hostRect) return null; + const view = viewRef.current; + const live = latestRef.current; + const liveOrigin = live?.origin ?? origin; + const liveBox = live?.box ?? box; + return rotatePointAround( + { + x: (clientX - hostRect.left - view.panX) / view.zoom - liveOrigin.dx, + y: (clientY - hostRect.top - view.panY) / view.zoom - liveOrigin.dy, + }, + { x: liveBox.width / 2, y: liveBox.height / 2 }, + -(live?.rotation ?? rotation), + ); + }; + + // 자세 프레임 - 배치 rect의 네 모서리에 자세 변환을 적용한 화면 폴리곤. + // 한 점 u는 t + P + sR(u − P)에 놓인다 + const poseCorners = (() => { + if (!session) return null; + const { rect } = posePlacement!; + const rad = displayTransform.rotation * DEG_TO_RAD; + const cos = Math.cos(rad) * displayTransform.scale; + const sin = Math.sin(rad) * displayTransform.scale; + const corners: Point[] = [ + { x: rect.x, y: rect.y }, + { x: rect.x + rect.width, y: rect.y }, + { x: rect.x + rect.width, y: rect.y + rect.height }, + { x: rect.x, y: rect.y + rect.height }, + ]; + return corners.map((corner) => { + const dx = corner.x - axis.x; + const dy = corner.y - axis.y; + return toScreen({ + x: displayTransform.x + axis.x + dx * cos - dy * sin, + y: displayTransform.y + axis.y + dx * sin + dy * cos, + }); + }); + })(); + + const generationNow = () => useSpritePoseHandleStore.getState().generation; + // 드래그 시작 때 잡은 세대가 아직 유효한지 - 자세 preview·커밋의 공통 전제 + const ownsSession = () => ownerGenerationRef.current === generationNow(); + // 기준점 드래그의 전제. 기본 기준점은 시작 시점 canonical이 아직 스토어의 그 객체인지 - + // 리사이즈 착지·다른 창 편집·undo가 끼어들면 시작 시점 patch는 낡은 값이다. + // 자세 기준점은 세션 프레임이 기준이라 세션 소유권으로 판정한다 (기하가 바뀌면 세대가 오른다) + const pivotBaseCurrent = (drag: DragState): boolean => { + const base = drag.pivotBase; + if (!base) return false; + if (base.kind === 'pose') return ownsSession(); + const current = findCanonicalSprite(base.mode, base.id); + if (!current) return false; + const placement = placeSpriteVisual(current, spriteIdleVisual(current)); + return ( + current.dx === base.canonical.dx && + current.dy === base.canonical.dy && + (current.rotation ?? 0) === (base.canonical.rotation ?? 0) && + current.width === base.frame.box.width && + current.height === base.frame.box.height && + isSameSpriteAnchor(current.pivot, base.frame.pivot) && + isSameSpriteTransform(current.idleTransform, base.frame.transform) && + placement.rect.width === base.frame.rect.width && + placement.rect.height === base.frame.rect.height + ); + }; + + const releaseGrabbed = () => { + const grabbed = grabbedRef.current; + if (!grabbed) return; + endDragCursor(grabbed.el.ownerDocument); + if (dragRef.current?.kind === 'rotate') unlockCustomCursor(); + try { + grabbed.el.releasePointerCapture(grabbed.pointerId); + } catch { + // 이미 해제됐으면 무시 + } + grabbedRef.current = null; + }; + + const finishDrag = () => { + if (!dragRef.current) return; + cancelDragRef.current = null; + releaseDragSession(); + moveSchedulerRef.current?.cancel(); + moveSchedulerRef.current = null; + detachRef.current?.(); + detachRef.current = null; + releaseGrabbed(); + dragRef.current = null; + setDragTransform(null); + setDragPivot(null); + setDragPivotTransform(null); + // 캡처 중 leave가 유실되면 호버 채움이 남는다 + setPivotHover(false); + lastTransformRef.current = null; + lastPivotPatchRef.current = null; + lastPosePivotRef.current = null; + suppressNextClick(); + }; + + const cancelActiveDrag = () => { + const drag = dragRef.current; + if (!drag) return; + // 기준점은 드래그 중 저장 프리뷰를 만들지 않아 정리할 세션이 없다 + if (drag.kind !== 'pivot') { + drag.session?.cancel(); + } + finishDrag(); + }; + + // 기준점 드래그 - 표식이 포인터(잡은 자리 보정) 아래에 오도록 역변환으로 기준점을 + // 구한다. 표식은 P + t + sR·Δ에 놓이므로 회전·배율이 있어도 포인터를 그대로 따라간다. + // 9점 프리셋은 각 프리셋일 때 표식이 놓일 자리와 화면 px로 비교해 자석 스냅한다 - + // 줌과 무관하게 같은 손맛이다 + const pivotFromPointer = ( + drag: DragState, + local: Point, + snapDisabled: boolean, + ): SpriteAnchor | null => { + const base = drag.pivotBase; + if (!base) return null; + const target = { + x: local.x - base.grabOffset.x, + y: local.y - base.grabOffset.y, + }; + const next = + base.kind === 'pose' + ? posePivotForHandleTarget(base.frame, target) + : pivotForHandleTarget(base.frame, target); + if (snapDisabled) { + drag.snappedPivot = null; + return next; + } + const held = drag.snappedPivot; + if (held) { + const at = + base.kind === 'pose' + ? posePivotHandleLocalPoint(base.frame, held) + : pivotHandleLocalPoint(base.frame, held); + const releaseRadius = PIVOT_SNAP_RELEASE_PX / viewRef.current.zoom; + if (Math.hypot(at.x - target.x, at.y - target.y) <= releaseRadius) { + return held; + } + drag.snappedPivot = null; + } + const snapped = + base.kind === 'pose' + ? snapPosePivotToPreset( + base.frame, + target, + PIVOT_SNAP_PX / viewRef.current.zoom, + ) + : snapPivotToPreset( + base.frame, + target, + PIVOT_SNAP_PX / viewRef.current.zoom, + ); + drag.snappedPivot = snapped; + return snapped ?? next; + }; + + const updatePivotDrag = (drag: DragState, next: SpriteAnchor) => { + const base = drag.pivotBase; + if (!base) return; + if (base.kind === 'pose') { + const display = compensateTransformForPosePivotDelta(base.frame, next); + if (!display) return; + lastPosePivotRef.current = { pivot: next, transform: display }; + setDragPivot(next); + setDragPivotTransform(display); + return; + } + // 보정이 이동값 범위를 넘는 자리는 그림이 움직이므로 받지 않는다 - + // 마지막으로 가능했던 자리에 머문다 + // 커밋 patch(canonical 기준)와 표식 보정(표시 프레임 기준)이 둘 다 성립할 때만 발행 + const patch = spritePivotChangePatch(base.canonical, next); + const display = compensateTransformForPivotDelta(base.frame, next); + if (!patch || !display) return; + lastPivotPatchRef.current = patch; + setDragPivot(next); + setDragPivotTransform(display); + }; + + const applyWindowMove = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + const local = clientToLocal(event.clientX, event.clientY); + if (!local) return; + if (drag.kind === 'pivot') { + if (!pivotBaseCurrent(drag)) { + cancelActiveDrag(); + return; + } + const next = pivotFromPointer( + drag, + local, + event.ctrlKey || event.metaKey, + ); + if (next) updatePivotDrag(drag, next); + return; + } + const live = sessionRef.current; + if (!live) return; + // 소유권이 갈린 뒤의 move는 이미 취소된 preview를 되열 뿐이라 버린다 + if (!ownsSession()) return; + const { offset, scale } = SPRITE_CONSTRAINTS; + let next: SpriteTransform; + if (drag.kind === 'move') { + next = { + ...drag.baseTransform, + x: clamp( + drag.baseTransform.x + (local.x - drag.startLocal.x), + offset.min, + offset.max, + ), + y: clamp( + drag.baseTransform.y + (local.y - drag.startLocal.y), + offset.min, + offset.max, + ), + }; + } else if (drag.kind === 'rotate') { + next = { + ...drag.baseTransform, + rotation: resolveRotationDrag({ + base: drag.baseTransform.rotation, + startAngle: drag.startAngle, + angle: Math.atan2( + local.y - drag.axisLocal.y, + local.x - drag.axisLocal.x, + ), + snap: event.shiftKey, + }), + }; + } else { + const distance = Math.hypot( + local.x - drag.axisLocal.x, + local.y - drag.axisLocal.y, + ); + next = { + ...drag.baseTransform, + scale: clamp( + (drag.baseTransform.scale * distance) / drag.startDistance, + scale.min, + scale.max, + ), + }; + } + lastTransformRef.current = next; + setDragTransform(next); + live.preview(next); + }; + + const settleActiveDrag = (pointerId: number) => { + const drag = dragRef.current; + if (!drag) return; + // 다른 포인터의 up이 첫 드래그를 커밋하지 못하게 한다 (멀티터치) + if (pointerId !== drag.pointerId) return; + if (drag.kind === 'pivot') { + // 시작 시점 canonical이 바뀌었으면 낡은 patch라 커밋하지 않는다 + if (!pivotBaseCurrent(drag)) { + cancelActiveDrag(); + return; + } + moveSchedulerRef.current?.flush(); + const base = drag.pivotBase; + if (base?.kind === 'pose') { + const next = lastPosePivotRef.current; + if (next && ownsSession()) { + drag.session?.commitPivot(next.pivot, next.transform); + } + finishDrag(); + return; + } + const patch = lastPivotPatchRef.current; + if (patch && base && pivotBaseCurrent(drag)) { + const landingGeneration = pendingBasePivotGenerationRef.current + 1; + pendingBasePivotGenerationRef.current = landingGeneration; + setPendingBasePivotLanding({ + generation: landingGeneration, + mode: base.mode, + id: base.id, + pivot: patch.pivot, + }); + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = spriteItemsApi.patchPosition( + base.mode, + base.id, + patch, + gestureId, + (current) => spritePivotChangePatch(current, patch.pivot), + ); + editGestureController.settleCommit(persisted); + void persisted.then( + (result) => { + if (pendingBasePivotGenerationRef.current === landingGeneration) { + setPendingBasePivotLanding(null); + } + if (result === 'skipped') { + console.warn( + 'Sprite pivot change skipped against the latest base', + base.id, + patch.pivot, + ); + } + }, + (error) => { + if (pendingBasePivotGenerationRef.current === landingGeneration) { + setPendingBasePivotLanding(null); + } + console.error('Failed to move sprite pivot', error); + }, + ); + } + finishDrag(); + return; + } + // 소유권을 잃었으면 커밋도 flush도 하지 않고 포기 경로로 간다 - + // 대기 move를 flush하면 리사이즈 착지가 닫아둔 preview가 다시 열린다 + if (!ownsSession()) { + cancelActiveDrag(); + return; + } + // 같은 프레임의 마지막 move가 rAF에 남아 있으면 커밋 전에 반영한다 + moveSchedulerRef.current?.flush(); + if (lastTransformRef.current) { + drag.session?.commit(lastTransformRef.current); + } + finishDrag(); + }; + + const handleWindowUp = (event: PointerEvent) => { + settleActiveDrag(event.pointerId); + }; + + const handleWindowMove = (event: PointerEvent) => { + const drag = dragRef.current; + if (event.pointerId !== drag?.pointerId) return; + if (event.buttons !== 0) drag.sawPressedMove = true; + moveSchedulerRef.current?.push(event); + // 일부 WebView 합성 입력은 up 없이 마지막 move의 buttons만 0으로 바뀐다 + // 처음부터 buttons가 0인 합성 드래그는 기존처럼 명시적 up까지 유지한다 + if (event.buttons === 0 && drag.sawPressedMove) { + settleActiveDrag(event.pointerId); + } + }; + const handleWindowCancel = (event: PointerEvent) => { + if (event.pointerId !== dragRef.current?.pointerId) return; + cancelActiveDrag(); + }; + const handleMouseUp = (event: MouseEvent) => { + if (event.button !== 0) return; + const drag = dragRef.current; + if (!drag) return; + // 일부 WebView는 마우스 해제를 pointerup으로 올리지 않는다 + settleActiveDrag(drag.pointerId); + }; + const handleLostPointerCapture = (event: Event) => { + const pointerEvent = event as PointerEvent; + if (pointerEvent.pointerId !== dragRef.current?.pointerId) return; + // 캡처 해제 시점의 buttons는 엔진마다 달라 마지막 유효 위치를 확정한다 + // 명시적인 중단은 pointercancel과 blur가 맡는다 + settleActiveDrag(pointerEvent.pointerId); + }; + const handleWindowBlur = () => cancelActiveDrag(); + + const attachWindowListeners = () => { + const captureTarget = grabbedRef.current?.el ?? null; + const ownerDocument = captureTarget?.ownerDocument ?? document; + const ownerWindow = ownerDocument.defaultView ?? window; + ownerWindow.addEventListener('pointermove', handleWindowMove, true); + ownerWindow.addEventListener('pointerup', handleWindowUp, true); + ownerWindow.addEventListener('pointercancel', handleWindowCancel, true); + ownerWindow.addEventListener('mouseup', handleMouseUp, true); + ownerWindow.addEventListener('blur', handleWindowBlur); + captureTarget?.addEventListener( + 'lostpointercapture', + handleLostPointerCapture, + ); + detachRef.current = () => { + ownerWindow.removeEventListener('pointermove', handleWindowMove, true); + ownerWindow.removeEventListener('pointerup', handleWindowUp, true); + ownerWindow.removeEventListener( + 'pointercancel', + handleWindowCancel, + true, + ); + ownerWindow.removeEventListener('mouseup', handleMouseUp, true); + ownerWindow.removeEventListener('blur', handleWindowBlur); + captureTarget?.removeEventListener( + 'lostpointercapture', + handleLostPointerCapture, + ); + }; + }; + + const grab = (event: React.PointerEvent, cursor: string) => { + const el = event.currentTarget; + try { + el.setPointerCapture(event.pointerId); + } catch { + // 미지원 환경은 커서 고정 없이 동작만 유지 + } + beginDragCursor(cursor, el.ownerDocument); + grabbedRef.current = { el, pointerId: event.pointerId }; + }; + + const settleFocusedField = () => { + const active = getActiveElement(); + if ( + isHTMLElementNode(active) && + active.matches('input, textarea, [contenteditable="true"]') + ) { + flushSync(() => active.blur()); + } + }; + + const beginPoseDrag = ( + event: React.PointerEvent, + kind: Exclude, + rotationCursor: RotationCursorType = 'rotate', + ) => { + if (event.button !== 0 || dragRef.current || !session) return; + settleFocusedField(); + // 정산이 팝업 필드를 커밋했으면 세션은 이미 새 값이다 - 시작 자세도 그 값이어야 + // 입력한 값 위에 이동량이 더해진다 + const live = latestRef.current; + const liveSession = live?.session; + if (!live || !liveSession) return; + const local = clientToLocal(event.clientX, event.clientY); + if (!local) return; + const startDistance = Math.hypot( + local.x - live.pivotLocal.x, + local.y - live.pivotLocal.y, + ); + if ( + kind === 'scale' && + startDistance * viewRef.current.zoom < MIN_SCALE_ARM_PX + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (!tryAcquireDragSession()) return; + grab( + event, + kind === 'rotate' + ? getCursor(rotationCursor) + : kind === 'move' + ? 'move' + : 'grabbing', + ); + if (kind === 'rotate') lockCustomCursor(rotationCursor, event.nativeEvent); + ownerGenerationRef.current = generationNow(); + dragRef.current = { + kind, + pointerId: event.pointerId, + sawPressedMove: false, + startLocal: local, + baseTransform: liveSession.transform, + axisLocal: live.pivotLocal, + startAngle: Math.atan2( + local.y - live.pivotLocal.y, + local.x - live.pivotLocal.x, + ), + startDistance, + session: liveSession, + pivotBase: null, + snappedPivot: null, + }; + cancelDragRef.current = cancelActiveDrag; + moveSchedulerRef.current = createRafLatestScheduler(applyWindowMove); + attachWindowListeners(); + }; + + // 표시 상태가 canonical과 어긋나 드래그를 거부할 때는 이유를 남긴다 - 조용히 죽은 + // 핸들처럼 보이지 않게 + const refusePivotDrag = (reason: string) => { + console.warn('Sprite pivot drag refused:', reason); + }; + + const beginPivotDrag = (event: React.PointerEvent) => { + if (event.button !== 0 || dragRef.current) return; + settleFocusedField(); + const live = latestRef.current; + if (!live) return; + const liveSession = live.session; + let pivotBase: PivotDragBase; + let reclaimedOrphanPreview = false; + if (liveSession) { + // 자세 편집 중에는 세션이 표시 기하의 원천이다. 방금 정산된 필드 커밋이 canonical에 + // 아직 닿지 않았어도 세션 프레임으로 역변환하고, 커밋은 저장 시점 최신 자세 위에 병합된다 + pivotBase = { + kind: 'pose', + poseId: liveSession.poseId, + frame: { + axis: spritePivotPx({ + width: liveSession.width, + height: liveSession.height, + pivot: liveSession.pivot, + }), + rect: liveSession.placement.rect, + pivot: liveSession.imagePivot, + transform: liveSession.transform, + }, + grabOffset: { x: 0, y: 0 }, + }; + } else { + const id = live.sprite?.id; + if (!id || !isNativeElementId(id)) return; + const locator = resolveElementById('sprite', id); + if (!locator) return; + const canonical = findCanonicalSprite(locator.mode, id); + if (!canonical) return; + // 컨트롤러 소유권이 사라진 로컬 프리뷰는 새 조작보다 먼저 회수한다 + reclaimedOrphanPreview = + editGestureController.discardOrphanedLocalPreviews(); + // 상자 크기·배치 각도가 canonical과 다르면 포인터 역변환이 달라진다 + if ( + !reclaimedOrphanPreview && + (live.box.width !== canonical.width || + live.box.height !== canonical.height || + live.rotation !== (canonical.rotation ?? 0)) + ) { + refusePivotDrag('displayed box differs from canonical'); + return; + } + if ( + !reclaimedOrphanPreview && + !isSameSpriteAnchor(live.imagePivot, canonical.pivot) + ) { + refusePivotDrag('displayed pivot differs from canonical'); + return; + } + if ( + !reclaimedOrphanPreview && + !isSameSpriteTransform(live.displayTransform, canonical.idleTransform) + ) { + refusePivotDrag('displayed transform differs from canonical'); + return; + } + const rect = placeSpriteVisual( + canonical, + spriteIdleVisual(canonical), + ).rect; + pivotBase = { + kind: 'base', + mode: locator.mode, + id, + canonical, + frame: { + box: { width: canonical.width, height: canonical.height }, + rect, + pivot: canonical.pivot, + transform: canonical.idleTransform, + }, + grabOffset: { x: 0, y: 0 }, + }; + } + const local = clientToLocal(event.clientX, event.clientY); + if (!local) return; + event.preventDefault(); + event.stopPropagation(); + if (!tryAcquireDragSession()) return; + grab(event, 'grabbing'); + pivotBase.grabOffset = { + x: local.x - live.pivotLocal.x, + y: local.y - live.pivotLocal.y, + }; + if (liveSession) ownerGenerationRef.current = generationNow(); + dragRef.current = { + kind: 'pivot', + pointerId: event.pointerId, + sawPressedMove: false, + startLocal: local, + baseTransform: pivotBase.frame.transform, + axisLocal: live.pivotLocal, + startAngle: 0, + startDistance: 0, + session: liveSession, + pivotBase, + snappedPivot: null, + }; + if (reclaimedOrphanPreview && pivotBase.kind === 'base') { + const recoveredPivot = pivotForHandleTarget( + pivotBase.frame, + live.pivotLocal, + ); + const recoveredTransform = compensateTransformForPivotDelta( + pivotBase.frame, + recoveredPivot, + ); + if (recoveredTransform) { + setDragPivot(recoveredPivot); + setDragPivotTransform(recoveredTransform); + } + } + cancelDragRef.current = cancelActiveDrag; + moveSchedulerRef.current = createRafLatestScheduler(applyWindowMove); + attachWindowListeners(); + }; + + const knobStyle = ( + center: Point, + cursor: string, + size = KNOB_HIT_SIZE, + ): React.CSSProperties => ({ + position: 'absolute', + left: center.x - size / 2, + top: center.y - size / 2, + width: size, + height: size, + cursor, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }); + const pivotActive = pivotHover || dragPivot !== null; + + return ( +
+ {poseCorners ? ( + + beginPoseDrag(event, 'rotate', cursor) + } + /> + ) : null} + + {poseCorners + ? poseCorners.map((corner, index) => ( + // 모서리 배율 노브 - 기준점 중심 균일 배율 +
beginPoseDrag(event, 'scale')} + className="pointer-events-auto" + style={knobStyle(corner, 'nwse-resize')} + data-sprite-scale-knob="true" + > +
+
+ )) + : null} + {/* 기준점 표식 - 드래그로 옮긴다. 그림은 움직이지 않는다 */} +
event.preventDefault()} + onPointerEnter={() => setPivotHover(true)} + onPointerLeave={() => setPivotHover(false)} + className="pointer-events-auto" + style={{ + ...knobStyle(pivotScreen, 'grab', PIVOT_HIT_SIZE), + color: pivotActive + ? 'var(--ui-selection)' + : 'var(--ui-selection-border-strong)', + }} + data-sprite-pivot-handle="true" + > + +
+
+ ); +}; + +export default SpriteCanvasHandles; diff --git a/src/renderer/components/main/Grid/handles/SpriteRotateHandle.tsx b/src/renderer/components/main/Grid/handles/SpriteRotateHandle.tsx new file mode 100644 index 000000000..10acf5a5b --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpriteRotateHandle.tsx @@ -0,0 +1,81 @@ +import { spriteItemsApi } from '@api/modules/editor/itemsApi'; +import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; +import { editGestureController } from '@src/renderer/editor/runtime/gesture/editGestureController'; +import CanvasRotateHandle from './CanvasRotateHandle'; +import type { Bounds } from './groupResizeUtils'; + +interface SpriteRotateHandleProps { + elementId: string; + mode: string; + bounds: Bounds; + rotation: number; + zoom: number; + panX: number; + panY: number; +} + +const SpriteRotateHandle = ({ + elementId, + mode, + bounds, + rotation, + zoom, + panX, + panY, +}: SpriteRotateHandleProps) => ( + { + if (resolveElementById('sprite', elementId)?.mode !== mode) return null; + let gestureId: string | undefined; + return { + preview: (value) => { + if ( + (gestureId && + editGestureController.activeGestureId() !== gestureId) || + resolveElementById('sprite', elementId)?.mode !== mode + ) + return false; + editGestureController.preview( + mode, + [{ id: elementId, patch: { rotation: value } }], + { domain: 'spritePosition' }, + ); + gestureId = editGestureController.activeGestureId(); + return Boolean(gestureId); + }, + commit: (value) => { + if ( + !gestureId || + editGestureController.activeGestureId() !== gestureId + ) + return; + const persisted = spriteItemsApi.patchPosition( + mode, + elementId, + { rotation: value }, + gestureId, + ); + editGestureController.settleCommit(persisted); + void persisted.catch((error) => + console.error('Failed to rotate sprite', error), + ); + }, + cancel: () => { + if ( + gestureId && + editGestureController.activeGestureId() === gestureId + ) + editGestureController.cancel(); + }, + }; + }} + /> +); + +export default SpriteRotateHandle; diff --git a/src/renderer/components/main/Grid/handles/aspectResize.test.ts b/src/renderer/components/main/Grid/handles/aspectResize.test.ts new file mode 100644 index 000000000..91db575ff --- /dev/null +++ b/src/renderer/components/main/Grid/handles/aspectResize.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from 'vitest'; + +import { + aspectScaleFromPrimary, + aspectScaleRange, + isSameAspect, + scaleBoundsAnchored, + settleAspectScale, +} from './aspectResize'; +import { isBoundsWithinEditorLimits } from './resizeLimits'; + +const MIN = 10; +const east = { dx: 1, dy: 0 } as const; +const south = { dx: 0, dy: 1 } as const; +const west = { dx: -1, dy: 0 } as const; + +describe('aspectScaleRange 하한', () => { + it('정상 4:3은 짧은 변이 10에서 멈춘다 (13.3x10 유지)', () => { + const range = aspectScaleRange( + { x: 0, y: 0, width: 200, height: 150 }, + east, + MIN, + ); + expect(range.min).toBeCloseTo(10 / 150, 12); + }); + + it('한 축이 이미 얇으면 그 축은 기준에서 빠진다 (400x0.1은 폭 10까지)', () => { + const range = aspectScaleRange( + { x: 0, y: 0, width: 400, height: 0.1 }, + east, + MIN, + ); + expect(range.min).toBeCloseTo(10 / 400, 12); + }); + + it('하한 배율은 반올림으로 10 아래를 만들지 않는다 (연속 축소 우회 방지)', () => { + // 10/77 은 아래로 반올림돼 77 * (10/77) = 9.999999999999998 + const start = { x: 0, y: 0, width: 100, height: 77 }; + const range = aspectScaleRange(start, east, MIN); + const shrunk = scaleBoundsAnchored(start, range.min, east); + expect(shrunk.height).toBeGreaterThanOrEqual(10); + // 그 결과를 다시 시작값으로 써도 두 축이 보호 축이라 10 아래로 못 내려간다 + const again = scaleBoundsAnchored( + shrunk, + aspectScaleRange(shrunk, east, MIN).min, + east, + ); + expect(again.height).toBeGreaterThanOrEqual(10); + expect(again.width).toBeGreaterThan(12.9); + }); + + it('두 축 모두 얇으면 현재보다 축소를 막는다', () => { + const range = aspectScaleRange( + { x: 0, y: 0, width: 8, height: 4 }, + east, + MIN, + ); + expect(range.min).toBe(1); + expect(range.max).toBeGreaterThan(1); + }); +}); + +describe('aspectScaleRange 상한', () => { + it('두 치수가 32768을 넘지 않는 배율까지', () => { + const range = aspectScaleRange( + { x: 0, y: 0, width: 400, height: 0.1 }, + east, + MIN, + ); + expect(range.max).toBeCloseTo(32768 / 400, 12); + }); + + it('시작 가장자리 고정(dir 1)은 좌표를 제한하지 않는다', () => { + const range = aspectScaleRange( + { x: -32768, y: -32768, width: 100, height: 100 }, + { dx: 1, dy: 1 }, + MIN, + ); + expect(range.max).toBeCloseTo(32768 / 100, 12); + }); + + it('중앙 앵커는 왼쪽 가장자리가 -32768에 닿으면 멈춘다', () => { + // x' = cx - w·s/2 ≥ -32768. cx = -32368 이면 s ≤ 2, 이미 가장자리가 한계면 s ≤ 1 + expect( + aspectScaleRange({ x: -32568, y: 0, width: 400, height: 0.1 }, south, MIN) + .max, + ).toBeCloseTo(2, 12); + expect( + aspectScaleRange({ x: -32768, y: 0, width: 400, height: 0.1 }, south, MIN) + .max, + ).toBe(1); + }); + + it('반대 가장자리 고정(dir -1)은 x = right - w·s 로 제한한다', () => { + // right = -32668, s ≤ (right + 32768) / w = 100 / 100 = 1 → 확대 불가 + const range = aspectScaleRange( + { x: -32768, y: 0, width: 100, height: 100 }, + west, + MIN, + ); + expect(range.max).toBe(1); + }); +}); + +describe('aspectScaleRange 좌표 하한 (양쪽 부등식)', () => { + it('상단이 좌표 상한 근처면 중앙 앵커 축소가 하한을 올린다', () => { + // y=32760, h=100 을 가로 핸들로 줄이면 y' = 32810 - 50·s ≤ 32768 → s ≥ 0.84 + const start = { x: 0, y: 32760, width: 100, height: 100 }; + const range = aspectScaleRange(start, east, MIN); + expect(range.min).toBeCloseTo(0.84, 12); + expect( + isBoundsWithinEditorLimits(scaleBoundsAnchored(start, range.min, east)), + ).toBe(true); + expect( + isBoundsWithinEditorLimits(scaleBoundsAnchored(start, 0.5, east)), + ).toBe(false); + }); + + it('유효한 시작이면 s=1은 항상 구간 안이다', () => { + const starts = [ + { x: 32768, y: 32768, width: 32768, height: 32768 }, + { x: -32768, y: -32768, width: 0.001, height: 0.001 }, + { x: 32760, y: -32760, width: 100, height: 1 }, + // origin + size 를 먼저 더하면 정밀도가 깨져 빈 구간이 되던 극단값 + { x: 32768, y: -32768, width: 0.01141460293365526, height: 1e-12 }, + ]; + const handles = [ + east, + south, + west, + { dx: -1, dy: -1 }, + { dx: 1, dy: -1 }, + ] as const; + for (const start of starts) { + for (const handle of handles) { + const range = aspectScaleRange(start, handle, MIN); + expect(range.min).toBeLessThanOrEqual(1); + expect(range.max).toBeGreaterThanOrEqual(1); + } + } + }); +}); + +describe('scaleBoundsAnchored', () => { + const start = { x: 100, y: 50, width: 200, height: 100 }; + + it('s=1은 극단 좌표에서도 시작 bounds를 비트 단위로 돌려준다', () => { + const extreme = { + x: 32768, + y: -32768, + width: 0.01141460293365526, + height: 1e-12, + }; + const handle = { dx: -1, dy: -1 } as const; + expect(scaleBoundsAnchored(extreme, 1, handle)).toEqual(extreme); + const range = aspectScaleRange(extreme, handle, MIN); + expect(settleAspectScale(extreme, 1, handle, range)).toBe(1); + expect( + isBoundsWithinEditorLimits( + scaleBoundsAnchored( + extreme, + settleAspectScale(extreme, 3, handle, range), + handle, + ), + ), + ).toBe(true); + }); + + it('잡지 않은 가장자리를 고정한다', () => { + expect(scaleBoundsAnchored(start, 2, east)).toEqual({ + x: 100, + y: 0, + width: 400, + height: 200, + }); + expect(scaleBoundsAnchored(start, 0.5, west)).toEqual({ + x: 200, + y: 75, + width: 100, + height: 50, + }); + expect(scaleBoundsAnchored(start, 2, { dx: -1, dy: -1 })).toEqual({ + x: -100, + y: -50, + width: 400, + height: 200, + }); + }); + + it('두 변이 같은 배율로 움직인다', () => { + const next = scaleBoundsAnchored( + { x: 0, y: 0, width: 400, height: 0.1 }, + 11, + south, + ); + expect(isSameAspect({ x: 0, y: 0, width: 400, height: 0.1 }, next)).toBe( + true, + ); + }); +}); + +describe('settleAspectScale', () => { + it('범위로 자른 뒤 경계 오차가 있으면 s를 1 쪽으로 물린다', () => { + const start = { x: 0, y: 32760, width: 100, height: 100 }; + const range = aspectScaleRange(start, east, MIN); + const settled = settleAspectScale(start, 0.1, east, range); + expect(settled).toBeGreaterThanOrEqual(range.min); + expect( + isBoundsWithinEditorLimits(scaleBoundsAnchored(start, settled, east)), + ).toBe(true); + }); + + it('극소 치수는 배율이 표현 범위를 넘어도 NaN 없이 1로 닫힌다', () => { + // 1e-305 는 백엔드가 받는 유효 치수지만 5000/1e-305 은 Infinity + const tiny = { x: 0, y: 0, width: 1e-305, height: 1e-305 }; + const range = aspectScaleRange(tiny, east, MIN); + expect(Number.isFinite(range.max)).toBe(true); + const primary = aspectScaleFromPrimary( + tiny, + 'width', + 5000, + (v) => v, + range, + ); + // 배율로는 비율을 지킬 수 없으니 이 프레임은 움직이지 않는다 + expect(primary).toEqual({ + scale: 1, + exact: { axis: 'width', size: 1e-305 }, + }); + const settled = settleAspectScale(tiny, Infinity, east, range); + expect(settled).toBe(1); + expect(scaleBoundsAnchored(tiny, settled, east)).toEqual(tiny); + }); + + it('legacy 시작은 범위 밖 항목을 건드리지 않는 축소를 허용한다', () => { + // x=40000 은 범위 밖이지만 동쪽 핸들 축소는 x를 그대로 둔다 + const legacy = { x: 40000, y: 0, width: 200, height: 100 }; + const range = aspectScaleRange(legacy, east, MIN); + const settled = settleAspectScale(legacy, 0.5, east, range); + expect(settled).toBe(0.5); + expect(scaleBoundsAnchored(legacy, settled, east)).toEqual({ + x: 40000, + y: 25, + width: 100, + height: 50, + }); + }); + + it('상한 배율의 결과는 백엔드 검증을 통과한다', () => { + const cases = [ + { start: { x: -32768, y: 0, width: 400, height: 0.1 }, handle: south }, + { start: { x: 0, y: 0, width: 400, height: 0.1 }, handle: south }, + { + start: { x: 32700, y: 32700, width: 3, height: 7 }, + handle: { dx: -1, dy: -1 } as const, + }, + ]; + for (const { start, handle } of cases) { + const range = aspectScaleRange(start, handle, MIN); + const settled = settleAspectScale(start, Number.MAX_VALUE, handle, range); + expect( + isBoundsWithinEditorLimits(scaleBoundsAnchored(start, settled, handle)), + ).toBe(true); + } + }); +}); + +describe('aspectScaleFromPrimary', () => { + const snap5 = (size: number) => Math.round(size / 5) * 5; + const thin = { x: 0, y: 0, width: 400, height: 0.1 }; + + it('스냅이 치수 0으로 무너지면 그 프레임은 스냅을 버린다', () => { + const range = aspectScaleRange(thin, south, MIN); + // 아래쪽 +1px: 1.1은 그리드 5에서 0 → 무효 → 원래 후보 → 배율 11 + const unsnapped = aspectScaleFromPrimary(thin, 'height', 1.1, snap5, range); + expect(unsnapped.scale).toBeCloseTo(11, 12); + expect(unsnapped.exact).toEqual({ axis: 'height', size: 1.1 }); + // +3px: 3.1 → 5 유효 → 배율 50, 기준 축 치수는 스냅값 그대로 + const snapped = aspectScaleFromPrimary(thin, 'height', 3.1, snap5, range); + expect(snapped.scale).toBe(50); + expect(snapped.exact).toEqual({ axis: 'height', size: 5 }); + }); + + it('스냅이 상한을 넘으면 원래 후보를 상한으로 자른다', () => { + const start = { x: 0, y: 0, width: 100, height: 100 }; + const range = { min: 0.1, max: 1.022 }; + // 102.51 → 스냅 105는 범위 밖 → raw 1.0251 → clamp 1.022, 치수는 다시 곱한다 + const clamped = aspectScaleFromPrimary( + start, + 'width', + 102.51, + snap5, + range, + ); + expect(clamped.scale).toBe(1.022); + expect(clamped.exact.size).toBeCloseTo(102.2, 12); + // 102.49 → 스냅 100 유효 + expect( + aspectScaleFromPrimary(start, 'width', 102.49, snap5, range), + ).toEqual({ + scale: 1, + exact: { axis: 'width', size: 100 }, + }); + }); + + it('후보가 앵커를 지나 0 이하가 되면 하한으로 올린다', () => { + const start = { x: 0, y: 0, width: 200, height: 150 }; + const range = aspectScaleRange(start, east, MIN); + expect( + aspectScaleFromPrimary(start, 'width', -30, snap5, range).scale, + ).toBe(range.min); + }); + + it('왼쪽 핸들은 주입된 가장자리 스냅으로 폭을 정한다', () => { + const start = { x: 3, y: 0, width: 200, height: 150 }; + const right = start.x + start.width; + const edgeSnap = (size: number) => right - snap5(right - size); + const range = aspectScaleRange(start, west, MIN); + // 후보 폭 190 → 왼쪽 가장자리 13 → 스냅 15 → 폭 188 + expect( + aspectScaleFromPrimary(start, 'width', 190, edgeSnap, range), + ).toEqual({ + scale: 188 / 200, + exact: { axis: 'width', size: 188 }, + }); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/aspectResize.ts b/src/renderer/components/main/Grid/handles/aspectResize.ts new file mode 100644 index 000000000..d1b794e60 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/aspectResize.ts @@ -0,0 +1,244 @@ +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; +import { clamp } from '@utils/number/clamp'; +import { DEG_TO_RAD } from '@utils/element/rotation'; +import { anchorRotatedResize } from './rotatedResize'; + +import { + isBoundsTransitionWithinEditorLimits, + type ResizeBounds, +} from './resizeLimits'; + +// 비율 고정 리사이즈는 배율 하나로만 움직인다. 배율을 허용 범위로 자르고 잡지 않은 +// 가장자리를 고정해 두 치수를 함께 바꾸면 비율이 정확히 유지된다. 단일 핸들과 +// 스마트 스냅 재유도가 같은 함수를 쓴다 + +export interface ResizeAxisHandle { + dx: -1 | 0 | 1; + dy: -1 | 0 | 1; +} + +export type AspectPrimaryAxis = 'width' | 'height'; + +export interface ScaleRange { + min: number; + max: number; +} + +// 앵커별 저장 좌표는 origin + slope·(s - 1). 범위를 풀 때 (s - 1) 기준으로 써야 +// s=1이 정확히 시작 좌표가 된다 - origin + size 같은 합을 먼저 만들면 극단 좌표에서 +// 정밀도가 깨져 빈 구간이 나온다. dir 1은 시작 가장자리 고정(좌표 불변), +// 0은 중앙 고정, -1은 반대 가장자리 고정 +const anchorSlope = (size: number, dir: -1 | 0 | 1): number => + dir === 1 ? 0 : dir === 0 ? -size / 2 : -size; + +// -limit ≤ origin + slope·(s - 1) ≤ limit 을 만족하는 s 구간 +const coordinateScaleRange = ( + origin: number, + slope: number, + limit: number, +): ScaleRange => { + const lo = 1 + (-limit - origin) / slope; + const hi = 1 + (limit - origin) / slope; + return slope > 0 ? { min: lo, max: hi } : { min: hi, max: lo }; +}; + +/** + * 비율 고정 배율의 허용 범위 + * - 하한은 시작 시 minSize 이상인 축만 기준으로 잡아 이미 얇은 축을 억지로 키우지 + * 않고, 두 축 모두 얇으면 현재보다 축소를 막는다. 상한은 두 치수와 저장 좌표 + * 양쪽 부등식에서 나온다 (좌표 조건은 하한에도 기여한다 - 상단 가장자리가 좌표 + * 상한 근처면 중앙 앵커 축소가 좌표를 밖으로 민다) + * - 유효한 시작이면 s=1이 항상 구간 안이고, 범위 밖 legacy 시작은 s=1 쪽으로 닫는다 + */ +export const aspectScaleRange = ( + start: ResizeBounds, + handle: ResizeAxisHandle, + minSize: number, + rotation = 0, +): ScaleRange => { + const { maxDimension, maxAbsCoordinate } = EDITOR_BOUNDS_LIMITS; + const guarded = [start.width, start.height].filter((size) => size >= minSize); + let min = + guarded.length === 0 + ? 1 + : Math.max(...guarded.map((size) => minSize / size)); + // minSize / size 가 아래로 반올림되면 size * min 이 9.999…로 저장되고 다음 드래그에서 + // 얇은 축으로 분류돼 하한이 뚫린다 - 보호 축이 전부 하한 이상이 될 때까지 한 ulp씩 올린다 + while (guarded.some((size) => size * min < minSize)) { + min += min * Number.EPSILON; + } + let max = Math.min(maxDimension / start.width, maxDimension / start.height); + const cos = Math.cos(rotation * DEG_TO_RAD); + const sin = Math.sin(rotation * DEG_TO_RAD); + const axes: Array<[number, number]> = + rotation === 0 + ? [ + [start.x, anchorSlope(start.width, handle.dx)], + [start.y, anchorSlope(start.height, handle.dy)], + ] + : [ + [ + start.x, + (cos * start.width * handle.dx - + sin * start.height * handle.dy - + start.width) / + 2, + ], + [ + start.y, + (sin * start.width * handle.dx + + cos * start.height * handle.dy - + start.height) / + 2, + ], + ]; + for (const [origin, slope] of axes) { + if (slope === 0) continue; + const range = coordinateScaleRange(origin, slope, maxAbsCoordinate); + min = Math.max(min, range.min); + max = Math.min(max, range.max); + } + // 유효한 시작이면 s=1이 구간 안이다. 극단값의 부동소수 오차나 legacy 시작이 + // 구간을 비우지 않게 1을 명시적으로 포함하고, 극소 치수에서 상한 나눗셈이 + // 넘치면 유한값으로 막는다 (배율 자체가 표현 불가면 뒤의 settle이 닫는다) + const finiteMax = Number.isFinite(max) ? max : Number.MAX_VALUE; + return { min: Math.min(min, 1), max: Math.max(finiteMax, 1) }; +}; + +export interface ExactAxisSize { + axis: AspectPrimaryAxis; + size: number; +} + +// 잡지 않은 가장자리 고정 - 위치는 치수 차이의 몫으로 잡아 스냅된 정수 치수가 +// 정수 위치를 만든다 (배율 곱은 410이 409.99999999999994가 된다) +const anchorFraction = (dir: -1 | 0 | 1): number => + dir === 1 ? 0 : dir === 0 ? 0.5 : 1; + +/** + * 두 치수에 같은 배율, 잡지 않은 가장자리 고정. s=1이면 시작 bounds 그대로 + * - exact는 배율의 출처인 축의 스냅·클램프된 치수 - 그 축은 곱셈 대신 그 값을 써 + * 그리드 정수를 보존한다 + */ +export const scaleBoundsAnchored = ( + start: ResizeBounds, + scale: number, + handle: ResizeAxisHandle, + exact?: ExactAxisSize, +): ResizeBounds => { + if (scale === 1) return { ...start }; + const width = exact?.axis === 'width' ? exact.size : start.width * scale; + const height = exact?.axis === 'height' ? exact.size : start.height * scale; + return { + x: start.x + (start.width - width) * anchorFraction(handle.dx), + y: start.y + (start.height - height) * anchorFraction(handle.dy), + width, + height, + }; +}; + +/** + * 배율을 범위로 자르고, 최종 bounds가 백엔드 전이 검증을 통과할 때까지 s를 1 쪽으로 + * 조금씩 물린다 (경계값 역산의 부동소수 오차 대비). 그래도 통과하지 못하면 1로 + * 닫는다 - 시작 bounds 그대로는 백엔드가 항상 받으므로 무효 bounds는 절대 내보내지 + * 않는다 (극소 치수처럼 배율이 표현 범위를 넘는 경우) + */ +export const settleAspectScale = ( + start: ResizeBounds, + scale: number, + handle: ResizeAxisHandle, + range: ScaleRange, + exact?: ExactAxisSize, + rotation = 0, +): number => { + let settled = clamp(Number.isFinite(scale) ? scale : 1, range.min, range.max); + for (let attempt = 0; attempt < 8; attempt += 1) { + const exactSize = settled === scale ? exact : undefined; + if ( + isBoundsTransitionWithinEditorLimits( + start, + anchorRotatedResize( + start, + scaleBoundsAnchored(start, settled, handle, exactSize), + rotation, + ), + ) + ) { + return settled; + } + settled += (1 - settled) * 2 ** -20; + } + return 1; +}; + +/** + * 기준 축 후보 크기 → 배율. snapSize는 방향별 그리드 스냅(오른쪽·아래는 크기, + * 왼쪽·위는 움직이는 가장자리 좌표)을 호출자가 넣는다. 스냅이 범위 밖 배율을 + * 만들면 그 프레임은 스냅을 버리고 원래 후보를 쓴다 - 얇은 요소는 그리드 한 칸이 + * 치수 0으로 무너지고, 상한 근처는 스냅이 범위를 넘는다 + */ +export interface AspectPrimaryScale { + scale: number; + /** 배율의 출처가 된 기준 축 치수 - 클램프에 걸리지 않았으면 스냅·후보 값 그대로 */ + exact: ExactAxisSize; +} + +export const aspectScaleFromPrimary = ( + start: ResizeBounds, + primary: AspectPrimaryAxis, + candidateSize: number, + snapSize: (size: number) => number, + range: ScaleRange, +): AspectPrimaryScale => { + const startSize = primary === 'width' ? start.width : start.height; + const snappedSize = snapSize(candidateSize); + const snapped = snappedSize / startSize; + const useSnapped = snapped >= range.min && snapped <= range.max; + const chosenSize = useSnapped ? snappedSize : candidateSize; + const chosen = chosenSize / startSize; + // 극소 시작 치수는 배율이 표현 범위를 넘는다. 그 배율로는 비율을 지킬 수 없으니 + // 이 프레임은 움직이지 않는다 (1은 시작 bounds 그대로) + if (!Number.isFinite(chosen)) { + return { scale: 1, exact: { axis: primary, size: startSize } }; + } + const scale = clamp(chosen, range.min, range.max); + return { + scale, + exact: { + axis: primary, + size: scale === chosen ? chosenSize : startSize * scale, + }, + }; +}; + +/** 배율 출처 축의 정확한 치수 - settle이 배율을 옮겼으면 다시 곱한다 */ +export const exactSizeFor = ( + start: ResizeBounds, + scale: number, + fromScale: number, + exact: ExactAxisSize, +): ExactAxisSize => + scale === fromScale + ? exact + : { + axis: exact.axis, + size: (exact.axis === 'width' ? start.width : start.height) * scale, + }; + +// double 한 칸(ulp) - 배율 비교 허용치의 단위 +const ulpOf = (value: number): number => + 2 ** (Math.floor(Math.log2(Math.abs(value))) - 52); + +/** + * 두 변이 같은 배율로 움직였는지 - 테스트·검증용 불변식. 반대 축은 곱셈 한 번으로 + * 만들어지므로 정상 범위 double에서는 몇 ulp 안에 든다 + */ +export const isSameAspect = ( + start: ResizeBounds, + next: ResizeBounds, + toleranceUlps = 4, +): boolean => { + const sx = next.width / start.width; + const sy = next.height / start.height; + return Math.abs(sx - sy) <= toleranceUlps * ulpOf(Math.max(sx, sy)); +}; diff --git a/src/renderer/components/main/Grid/handles/gradientAxisGeometry.ts b/src/renderer/components/main/Grid/handles/gradientAxisGeometry.ts index b377cf4ce..1535fbb3f 100644 --- a/src/renderer/components/main/Grid/handles/gradientAxisGeometry.ts +++ b/src/renderer/components/main/Grid/handles/gradientAxisGeometry.ts @@ -14,6 +14,7 @@ export interface GradientAxisBounds { y: number; width: number; height: number; + rotation?: number; } export interface GradientAxisGeometry { @@ -28,6 +29,8 @@ export interface GradientAxisGeometry { zoom: number; endX: number; endY: number; + rotation: number; + screenAngle: number; } interface RegisteredGradientAnchorBounds { @@ -172,6 +175,7 @@ export const resolveGradientAxisBounds = ({ y: pos.dy, width: pos.width || (anchor.kind === 'graph' ? 200 : 60), height: pos.height || (anchor.kind === 'graph' ? 100 : 60), + ...(pos.rotation ? { rotation: pos.rotation } : {}), }; }; @@ -189,9 +193,12 @@ export const buildGradientAxisGeometry = ( bounds.height, ); const rad = (angle * Math.PI) / 180; + const rotation = bounds.rotation ?? 0; + const screenAngle = angle + rotation; + const screenRad = (screenAngle * Math.PI) / 180; // CSS linear-gradient: 0deg = 위, 시계 방향 - 화면 좌표(y 아래)로 변환 - const dirX = Math.sin(rad); - const dirY = -Math.cos(rad); + const dirX = Math.sin(screenRad); + const dirY = -Math.cos(screenRad); // CSS 그라데이션 라인 절반 길이 - pos 0/1이 이 지점에 해당 const halfLine = ((Math.abs(bounds.width * Math.sin(rad)) + @@ -210,6 +217,8 @@ export const buildGradientAxisGeometry = ( zoom, endX: cx + dirX * halfLine, endY: cy + dirY * halfLine, + rotation, + screenAngle, }; }; @@ -234,11 +243,14 @@ export const gradientAxisPointerAngle = ( clientY: number, end: GradientAxisEnd, origin: { x: number; y: number }, + rotation = 0, ): number => { const raw = (Math.atan2(clientX - origin.x, origin.y - clientY) * 180) / Math.PI; // 시작점 쪽을 잡으면 축 반대 방향이 그라데이션 진행 방향 - return normalizeGradientAxisAngle(end === 'start' ? raw + 180 : raw); + return normalizeGradientAxisAngle( + (end === 'start' ? raw + 180 : raw) - rotation, + ); }; export const applyGradientAxisMagnet = ( diff --git a/src/renderer/components/main/Grid/handles/groupResizePlan.test.ts b/src/renderer/components/main/Grid/handles/groupResizePlan.test.ts index 02d502dfd..7755081b5 100644 --- a/src/renderer/components/main/Grid/handles/groupResizePlan.test.ts +++ b/src/renderer/components/main/Grid/handles/groupResizePlan.test.ts @@ -57,8 +57,8 @@ const calculate = ( snapSize: 1, startGroupBounds: { x: 100, y: 200, width: 100, height: 80 }, startElementBounds: elementBounds, - maxShrinkX: 90, - maxShrinkY: 70, + minGroupWidth: 10, + minGroupHeight: 10, smartSnap: { type: 'unchanged' }, ...overrides, }); diff --git a/src/renderer/components/main/Grid/handles/groupResizePlan.ts b/src/renderer/components/main/Grid/handles/groupResizePlan.ts index 76b40a281..7da87c50c 100644 --- a/src/renderer/components/main/Grid/handles/groupResizePlan.ts +++ b/src/renderer/components/main/Grid/handles/groupResizePlan.ts @@ -1,4 +1,5 @@ import { snapToGrid } from '@hooks/Grid/utils'; +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; import type { CursorType } from '@utils/grid/cursorUtils'; import { calculateBounds, @@ -10,7 +11,14 @@ import { type SizeSnapResult, type SpacingGuide, } from '@utils/grid/smartGuides'; -import type { Bounds, ElementBounds } from './groupResizeUtils'; +import { + isAspectLockedElement, + limitGroupGrowth, + projectGroupElementBounds, + shrinkLimitSize, + type Bounds, + type ElementBounds, +} from './groupResizeUtils'; export const GROUP_RESIZE_MIN_SIZE = 10; @@ -62,8 +70,9 @@ interface CalculateGroupResizePlanOptions { startGroupBounds: Bounds; startElementBounds: ElementBounds[]; nonResizableElementBounds?: ElementBounds[]; - maxShrinkX: number; - maxShrinkY: number; + /** 요소 하한에서 유도한 그룹 최소 크기 - 시작 크기면 그 축은 더 못 줄인다 */ + minGroupWidth: number; + minGroupHeight: number; smartSnap: SmartSnapPlan; } @@ -72,24 +81,40 @@ export interface GroupResizePlan { guides: GroupResizeGuidePlan; } -export const calculateMaxGroupShrink = ( +/** + * 요소 하한 기준 그룹 최소 크기. 비율 고정 요소는 어느 축을 끌어도 함께 줄어드는 + * 보호 축으로 재고, 이미 두 축이 얇으면 현재보다 축소를 막는다 + */ +export const calculateMinGroupSize = ( boundsList: ElementBounds[], groupSize: number, axis: 'x' | 'y', minSize: number, ): number => { - if (!Number.isFinite(groupSize) || groupSize <= 0) return 0; + if (!Number.isFinite(groupSize) || groupSize <= 0) return groupSize; let minScale = 0; - for (const { bounds } of boundsList) { - const size = axis === 'x' ? bounds.width : bounds.height; + const guardedSizes: number[] = []; + for (const { element, bounds } of boundsList) { + const size = shrinkLimitSize(element, bounds, axis, minSize); if (!Number.isFinite(size) || size <= 0) continue; if (size >= minSize) { + guardedSizes.push(size); minScale = Math.max(minScale, minSize / size); + } else if (isAspectLockedElement(element)) { + minScale = 1; } } - const groupMinScale = minSize / groupSize; - minScale = Math.min(1, Math.max(minScale, groupMinScale)); - return groupSize * (1 - minScale); + // 그룹 자체 하한은 얇은 스프라이트의 정상 축소까지 막으므로 + // 보호할 요소 축에서만 유도하고 보호 축이 없으면 현재 크기 유지 + minScale = minScale > 0 ? Math.min(1, minScale) : 1; + let minGroupSize = groupSize * minScale; + // 그룹 크기에서 배율을 다시 나눠 요소에 곱하는 실제 투영까지 하한 보장 + while ( + guardedSizes.some((size) => size * (minGroupSize / groupSize) < minSize) + ) { + minGroupSize += Math.max(Number.MIN_VALUE, minGroupSize * Number.EPSILON); + } + return minGroupSize; }; const clampShrinkDelta = ( @@ -98,7 +123,8 @@ const clampShrinkDelta = ( maxShrink: number, snapSize: number, ): number => { - if (!Number.isFinite(maxShrink) || maxShrink <= 0) return delta; + // maxShrink 0은 더 못 줄인다는 뜻이라 여기서도 잘라야 한다 + if (!Number.isFinite(maxShrink) || maxShrink < 0) return delta; const maxSnapped = snapSize > 0 ? Math.floor(maxShrink / snapSize) * snapSize : maxShrink; if (handleDir === -1) return Math.min(delta, maxSnapped); @@ -143,8 +169,8 @@ export const calculateGroupResizePlan = ({ startGroupBounds, startElementBounds, nonResizableElementBounds, - maxShrinkX, - maxShrinkY, + minGroupWidth: minimumWidth, + minGroupHeight: minimumHeight, smartSnap, }: CalculateGroupResizePlanOptions): GroupResizePlan => { const rawDeltaX = (pointerX - startMouseX) / zoom; @@ -157,7 +183,7 @@ export const calculateGroupResizePlan = ({ snappedDeltaX = clampShrinkDelta( snappedDeltaX, handle.dx, - maxShrinkX, + startGroupBounds.width - minimumWidth, snapSize, ); } @@ -165,41 +191,95 @@ export const calculateGroupResizePlan = ({ snappedDeltaY = clampShrinkDelta( snappedDeltaY, handle.dy, - maxShrinkY, + startGroupBounds.height - minimumHeight, snapSize, ); } - const minSize = GROUP_RESIZE_MIN_SIZE; let newGroupX = startGroupBounds.x; let newGroupY = startGroupBounds.y; let newGroupWidth = startGroupBounds.width; let newGroupHeight = startGroupBounds.height; if (handle.dx === -1) { - newGroupWidth = Math.max(minSize, startGroupBounds.width - snappedDeltaX); - if (newGroupWidth > minSize) { - newGroupX = startGroupBounds.x + snappedDeltaX; - } else { - newGroupX = startGroupBounds.x + startGroupBounds.width - minSize; - } + newGroupWidth = Math.max( + minimumWidth, + startGroupBounds.width - snappedDeltaX, + ); + newGroupX = startGroupBounds.x + (startGroupBounds.width - newGroupWidth); } else if (handle.dx === 1) { - newGroupWidth = Math.max(minSize, startGroupBounds.width + snappedDeltaX); + newGroupWidth = Math.max( + minimumWidth, + startGroupBounds.width + snappedDeltaX, + ); } if (handle.dy === -1) { - newGroupHeight = Math.max(minSize, startGroupBounds.height - snappedDeltaY); - if (newGroupHeight > minSize) { - newGroupY = startGroupBounds.y + snappedDeltaY; - } else { - newGroupY = startGroupBounds.y + startGroupBounds.height - minSize; - } + newGroupHeight = Math.max( + minimumHeight, + startGroupBounds.height - snappedDeltaY, + ); + newGroupY = startGroupBounds.y + (startGroupBounds.height - newGroupHeight); } else if (handle.dy === 1) { - newGroupHeight = Math.max(minSize, startGroupBounds.height + snappedDeltaY); + newGroupHeight = Math.max( + minimumHeight, + startGroupBounds.height + snappedDeltaY, + ); } - newGroupWidth = Math.max(minSize, newGroupWidth); - newGroupHeight = Math.max(minSize, newGroupHeight); + // 요소 하한 기준 그룹 최소 크기 - 잡은 핸들이 움직이는 축만. 잡지 않은 축은 시작값 + // 그대로 둔다 (얇은 그룹의 높이를 가로 핸들이 10으로 키우면 요소가 밀려난다) + // - 스마트 스냅이 하한을 다시 넘으면 잡지 않은 가장자리를 고정한 채 + // 되돌리고, 되돌린 축을 알려 그 축의 스냅을 무효화한다 + const minGroupWidth = handle.dx === 0 ? startGroupBounds.width : minimumWidth; + const minGroupHeight = + handle.dy === 0 ? startGroupBounds.height : minimumHeight; + const enforceMinGroupSize = (): { width: boolean; height: boolean } => { + const clamped = { width: false, height: false }; + if (newGroupWidth < minGroupWidth) { + if (handle.dx === -1) { + newGroupX = newGroupX + newGroupWidth - minGroupWidth; + } + newGroupWidth = minGroupWidth; + clamped.width = true; + } + if (newGroupHeight < minGroupHeight) { + if (handle.dy === -1) { + newGroupY = newGroupY + newGroupHeight - minGroupHeight; + } + newGroupHeight = minGroupHeight; + clamped.height = true; + } + return clamped; + }; + + // 요소 상한(치수·저장 좌표)까지 한 번에. 후보를 요소에 투영해 넘치면 시작 → + // 후보 진행 배율을 줄인다. 되돌린 축은 하한과 같은 규칙으로 가이드에서 뺀다 + const enforceGroupLimits = (): { width: boolean; height: boolean } => { + const clamped = enforceMinGroupSize(); + const growth = limitGroupGrowth( + startElementBounds, + startGroupBounds, + { + x: newGroupX, + y: newGroupY, + width: newGroupWidth, + height: newGroupHeight, + }, + handle, + EDITOR_BOUNDS_LIMITS, + ); + if (growth.limitedWidth || growth.limitedHeight) { + newGroupX = growth.bounds.x; + newGroupY = growth.bounds.y; + newGroupWidth = growth.bounds.width; + newGroupHeight = growth.bounds.height; + } + return { + width: clamped.width || growth.limitedWidth, + height: clamped.height || growth.limitedHeight, + }; + }; let guides: GroupResizeGuidePlan = { type: 'unchanged' }; if (smartSnap.type === 'suppressed') { @@ -234,8 +314,6 @@ export const calculateGroupResizePlan = ({ } else if (handle.dx === 1) { const snappedRight = snapResult.snappedX + groupBoundsForSnap.width; newGroupWidth = snappedRight - newGroupX; - } else if (handle.dx === 0) { - newGroupX = snapResult.snappedX; } } @@ -251,18 +329,18 @@ export const calculateGroupResizePlan = ({ } else if (handle.dy === 1) { const snappedBottom = snapResult.snappedY + groupBoundsForSnap.height; newGroupHeight = snappedBottom - newGroupY; - } else if (handle.dy === 0) { - newGroupY = snapResult.snappedY; } } let sizeSnapResult: SizeSnapResult | null = null; if (smartSnap.sizeMatchGuidesEnabled) { + // 잡은 핸들이 움직이는 축만 - 가로 핸들이 그룹 높이를 바꾸면 안 된다 sizeSnapResult = calculateSizeSnap( newGroupWidth, newGroupHeight, smartSnap.otherElements, 'group', + { matchWidth: handle.dx !== 0, matchHeight: handle.dy !== 0 }, ); if (sizeSnapResult.didSnapWidth) { @@ -281,16 +359,35 @@ export const calculateGroupResizePlan = ({ } } + // 스냅이 요소 하한 아래로 내려갔으면 되돌린다. 되돌린 축의 정렬·간격·크기 일치는 + // 화면에서 성립하지 않으므로 가이드에서도 뺀다 - 가이드는 최종 결과를 따라야 한다 + const clamped = enforceGroupLimits(); + const alignSnapX = snapResult.didSnapX && !clamped.width; + const alignSnapY = snapResult.didSnapY && !clamped.height; + const sizeSnapWidth = + sizeSnapResult?.didSnapWidth === true && !clamped.width; + const sizeSnapHeight = + sizeSnapResult?.didSnapHeight === true && !clamped.height; + const activeGuides = snapResult.guides.filter((guide) => + guide.type === 'vertical' ? alignSnapX : alignSnapY, + ); + const activeSpacingGuides = (snapResult.spacingGuides ?? []).filter( + (guide) => (guide.direction === 'horizontal' ? alignSnapX : alignSnapY), + ); + const activeSizeMatchGuides = ( + sizeSnapResult?.sizeMatchGuides ?? [] + ).filter((guide) => + guide.dimension === 'width' ? sizeSnapWidth : sizeSnapHeight, + ); + const hasAlignSnap = (handle.dx !== 0 && - snapResult.didSnapX && + alignSnapX && !(snapResult.didSpacingSnapX && !smartSnap.spacingGuidesEnabled)) || (handle.dy !== 0 && - snapResult.didSnapY && + alignSnapY && !(snapResult.didSpacingSnapY && !smartSnap.spacingGuidesEnabled)); - const hasSizeSnap = - sizeSnapResult && - (sizeSnapResult.didSnapWidth || sizeSnapResult.didSnapHeight); + const hasSizeSnap = sizeSnapWidth || sizeSnapHeight; if (hasAlignSnap || hasSizeSnap) { guides = { @@ -302,53 +399,61 @@ export const calculateGroupResizePlan = ({ newGroupHeight, 'group', ), - activeGuides: hasAlignSnap ? snapResult.guides : [], + activeGuides: hasAlignSnap ? activeGuides : [], spacingGuides: hasAlignSnap && smartSnap.spacingGuidesEnabled && - snapResult.spacingGuides && - snapResult.spacingGuides.length > 0 - ? filterSpacingGuides(snapResult.spacingGuides, handle) + activeSpacingGuides.length > 0 + ? filterSpacingGuides(activeSpacingGuides, handle) : [], - sizeMatchGuides: hasSizeSnap ? sizeSnapResult!.sizeMatchGuides : [], + sizeMatchGuides: hasSizeSnap ? activeSizeMatchGuides : [], }; } else { guides = { type: 'clear' }; } } - newGroupWidth = Math.max(minSize, newGroupWidth); - newGroupHeight = Math.max(minSize, newGroupHeight); + // 스마트 스냅을 건너뛴 경로도 같은 하한·상한을 지킨다 (스냅 경로에서는 이미 적용돼 무변화) + enforceGroupLimits(); - const scaleX = - startGroupBounds.width > 0 ? newGroupWidth / startGroupBounds.width : 1; - const scaleY = - startGroupBounds.height > 0 ? newGroupHeight / startGroupBounds.height : 1; + // 각 리사이즈 가능한 요소에 그룹 변환 투영 (스냅 적용된 그룹 bounds 기준). + // 스냅은 그룹 bounds에서만 처리하고, 비율 고정 요소는 단일 배율을 따른다 + const nextGroupBounds: Bounds = { + x: newGroupX, + y: newGroupY, + width: newGroupWidth, + height: newGroupHeight, + }; const newElementBounds: ElementBounds[] = startElementBounds.map( - ({ element, bounds }) => { - const relativeX = bounds.x - startGroupBounds.x; - const relativeY = bounds.y - startGroupBounds.y; - return { + ({ element, bounds }) => ({ + element, + bounds: projectGroupElementBounds( element, - bounds: { - x: newGroupX + relativeX * scaleX, - y: newGroupY + relativeY * scaleY, - width: bounds.width * scaleX, - height: bounds.height * scaleY, - }, - }; - }, + bounds, + startGroupBounds, + nextGroupBounds, + handle, + ), + }), ); let finalGroupMinX = newGroupX; let finalGroupMinY = newGroupY; let finalGroupMaxX = newGroupX + newGroupWidth; let finalGroupMaxY = newGroupY + newGroupHeight; - for (const { bounds } of nonResizableElementBounds || []) { + const includeInGroup = (bounds: Bounds) => { finalGroupMinX = Math.min(finalGroupMinX, bounds.x); finalGroupMinY = Math.min(finalGroupMinY, bounds.y); finalGroupMaxX = Math.max(finalGroupMaxX, bounds.x + bounds.width); finalGroupMaxY = Math.max(finalGroupMaxY, bounds.y + bounds.height); + }; + // 리사이즈 불가능한 요소는 원래 위치 유지 + for (const { bounds } of nonResizableElementBounds || []) { + includeInGroup(bounds); + } + // 비율 고정 요소는 한 축만 끌어도 반대 축이 함께 자라 그룹 변환 밖으로 나갈 수 있다 + for (const { element, bounds } of newElementBounds) { + if (isAspectLockedElement(element)) includeInGroup(bounds); } return { diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts index 93a206922..d7dd031b5 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts @@ -1,7 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { elementBoundsChanged, type ElementBounds } from './groupResizeUtils'; + +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; +import { + limitGroupGrowth, + elementBoundsChanged, + projectGroupElementBounds, + shrinkLimitSize, + uniformGroupScale, + type Bounds, + type ElementBounds, +} from './groupResizeUtils'; +import { isBoundsWithinEditorLimits } from './resizeLimits'; const ID_A = '00000000-0000-0000-0000-000000000001'; +const ID_B = '00000000-0000-0000-0000-000000000002'; const boundsAt = (index: number, width = 40): ElementBounds => ({ element: { type: 'key', id: ID_A, index }, @@ -17,3 +29,243 @@ describe('elementBoundsChanged', () => { expect(elementBoundsChanged([boundsAt(0)], [boundsAt(1, 41)])).toBe(true); }); }); + +describe('projectGroupElementBounds', () => { + // 키 60x60 (0,0)과 스프라이트 200x125 (100,0)이 한 그룹: 300x125 + const key = { type: 'key', id: ID_A, index: 0 } as const; + const sprite = { type: 'sprite', id: ID_B, index: 0 } as const; + const keyBounds: Bounds = { x: 0, y: 0, width: 60, height: 60 }; + const spriteBounds: Bounds = { x: 100, y: 0, width: 200, height: 125 }; + const startGroup: Bounds = { x: 0, y: 0, width: 300, height: 125 }; + const rightHandle = { dx: 1, dy: 0 } as const; + + it('오른쪽 핸들로 가로만 늘리면 키는 늘어나고 스프라이트는 비율을 지킨다', () => { + const nextGroup: Bounds = { x: 0, y: 0, width: 385.65, height: 125 }; + const scaleX = 385.65 / 300; + + const projectedKey = projectGroupElementBounds( + key, + keyBounds, + startGroup, + nextGroup, + rightHandle, + ); + expect(projectedKey.width).toBeCloseTo(60 * scaleX, 9); + expect(projectedKey.height).toBe(60); + + const projected = projectGroupElementBounds( + sprite, + spriteBounds, + startGroup, + nextGroup, + rightHandle, + ); + // 257.1x125로 왜곡되지 않고 257.1x160.69로 함께 커진다 + expect(projected.width).toBeCloseTo(257.1, 9); + expect(projected.height).toBeCloseTo(125 * scaleX, 9); + expect(projected.width / projected.height).toBeCloseTo(200 / 125, 9); + // 중심은 그룹 배율대로 옮겨 다른 요소와 같은 자리를 지킨다 + expect(projected.x + projected.width / 2).toBeCloseTo(200 * scaleX, 9); + expect(projected.y + projected.height / 2).toBeCloseTo(62.5, 9); + }); + + it('두 축 배율이 같으면 비율 고정 요소도 일반 투영과 같은 상자를 얻는다', () => { + const nextGroup: Bounds = { x: 10, y: 20, width: 600, height: 250 }; + const projected = projectGroupElementBounds( + sprite, + spriteBounds, + startGroup, + nextGroup, + { dx: 1, dy: 1 }, + ); + expect(projected).toEqual({ x: 210, y: 20, width: 400, height: 250 }); + }); + + it('모서리 핸들은 변화가 큰 축의 배율을 따른다', () => { + expect(uniformGroupScale(1.5, 1.1, { dx: 1, dy: 1 })).toBe(1.5); + expect(uniformGroupScale(1.1, 0.5, { dx: -1, dy: 1 })).toBe(0.5); + expect(uniformGroupScale(1.5, 1.1, { dx: 0, dy: 1 })).toBe(1.1); + expect(uniformGroupScale(1.5, 1.1, { dx: -1, dy: 0 })).toBe(1.5); + }); + + it('비율 고정 요소의 축소 한계는 어느 축이든 짧은 변으로 잰다', () => { + expect(shrinkLimitSize(sprite, spriteBounds, 'x')).toBe(125); + expect(shrinkLimitSize(sprite, spriteBounds, 'y')).toBe(125); + expect(shrinkLimitSize(key, { ...keyBounds, width: 80 }, 'x')).toBe(80); + expect(shrinkLimitSize(key, { ...keyBounds, width: 80 }, 'y')).toBe(60); + }); + + it('비율 고정 요소에 이미 얇은 축이 있으면 정상 축을 하한 기준으로 쓴다', () => { + const thin = { ...spriteBounds, width: 400, height: 0.1 }; + expect(shrinkLimitSize(sprite, thin, 'x', 10)).toBe(400); + expect(shrinkLimitSize(sprite, thin, 'y', 10)).toBe(400); + expect(shrinkLimitSize(sprite, { ...thin, width: 1 }, 'x', 10)).toBe(0.1); + }); +}); + +describe('limitGroupGrowth', () => { + const limits = EDITOR_BOUNDS_LIMITS; + const key = { type: 'key', id: ID_A, index: 0 } as const; + const sprite = { type: 'sprite', id: ID_B, index: 0 } as const; + const bottomHandle = { dx: 0, dy: 1 } as const; + const rightHandle = { dx: 1, dy: 0 } as const; + const leftHandle = { dx: -1, dy: 0 } as const; + + it('상한 안이면 후보를 그대로 돌려준다', () => { + const bounds: Bounds = { x: 0, y: 0, width: 200, height: 125 }; + const candidate: Bounds = { x: 0, y: 0, width: 200, height: 250 }; + expect( + limitGroupGrowth( + [{ element: sprite, bounds }], + { ...bounds }, + candidate, + bottomHandle, + limits, + ), + ).toEqual({ bounds: candidate, limitedWidth: false, limitedHeight: false }); + }); + + it('얇은 스프라이트의 세로 확대는 파생 폭이 32768에 닿는 진행 배율에서 멈춘다', () => { + const bounds: Bounds = { x: 0, y: 0, width: 400, height: 0.1 }; + const start: Bounds = { ...bounds }; + // 세로 배율 100 → 단일 배율로 폭 40000 + const candidate: Bounds = { x: 0, y: 0, width: 400, height: 10 }; + const result = limitGroupGrowth( + [{ element: sprite, bounds }], + start, + candidate, + bottomHandle, + limits, + ); + expect(result.limitedHeight).toBe(true); + expect(result.limitedWidth).toBe(false); + const projected = projectGroupElementBounds( + sprite, + bounds, + start, + result.bounds, + bottomHandle, + ); + expect(isBoundsWithinEditorLimits(projected)).toBe(true); + expect(projected.width).toBeGreaterThan(32767); + expect(projected.width / 400).toBeCloseTo(projected.height / 0.1, 6); + }); + + it('혼합 그룹은 가장 먼저 닿는 제약이 진행을 정한다 (여기서는 키의 저장 좌표)', () => { + const spriteBounds: Bounds = { x: 0, y: 0, width: 400, height: 0.1 }; + const keyBounds: Bounds = { x: 500, y: 0, width: 100, height: 100 }; + const elements = [ + { element: sprite, bounds: spriteBounds }, + { element: key, bounds: keyBounds }, + ]; + const start: Bounds = { x: 0, y: 0, width: 600, height: 100 }; + // 가로 배율 1000. 스프라이트 폭은 81.92배, 키 x=500은 65.536배에서 32768에 닿는다 + const candidate: Bounds = { x: 0, y: 0, width: 600000, height: 100 }; + const result = limitGroupGrowth( + elements, + start, + candidate, + rightHandle, + limits, + ); + const projected = elements.map(({ element, bounds }) => + projectGroupElementBounds( + element, + bounds, + start, + result.bounds, + rightHandle, + ), + ); + expect(projected.every(isBoundsWithinEditorLimits)).toBe(true); + expect(projected[1].x).toBeGreaterThan(32767); + expect(projected[0].width).toBeCloseTo(400 * 65.536, 3); + }); + + it('저장 좌표 상한도 진행을 막는다', () => { + // 왼쪽 핸들 확대는 요소 x를 -32768 아래로 민다 + const bounds: Bounds = { x: -32000, y: 0, width: 100, height: 100 }; + const start: Bounds = { ...bounds }; + const candidate: Bounds = { x: -34000, y: 0, width: 2100, height: 100 }; + const result = limitGroupGrowth( + [{ element: key, bounds }], + start, + candidate, + leftHandle, + limits, + ); + const projected = projectGroupElementBounds( + key, + bounds, + start, + result.bounds, + leftHandle, + ); + expect(projected.x).toBeGreaterThanOrEqual(-32768); + expect(projected.x).toBeLessThan(-32700); + expect(result.limitedWidth).toBe(true); + }); + + it('범위 밖 legacy 요소가 있어도 그 항목을 건드리지 않는 직교축 리사이즈는 통과한다', () => { + // 폭 40000 legacy 키 + 정상 키. 아래 핸들로 높이만 두 배 + const legacyBounds: Bounds = { x: 0, y: 0, width: 40000, height: 100 }; + const keyBounds: Bounds = { x: 0, y: 100, width: 100, height: 100 }; + const elements = [ + { + element: { type: 'key', id: ID_A, index: 0 } as const, + bounds: legacyBounds, + }, + { element: key, bounds: keyBounds }, + ]; + const start: Bounds = { x: 0, y: 0, width: 40000, height: 200 }; + const candidate: Bounds = { x: 0, y: 0, width: 40000, height: 400 }; + const result = limitGroupGrowth( + elements, + start, + candidate, + bottomHandle, + limits, + ); + expect(result).toEqual({ + bounds: candidate, + limitedWidth: false, + limitedHeight: false, + }); + }); + + it('안 움직인 축은 극단 좌표에서도 요소 값을 비트 단위로 보존한다', () => { + // 되돌린 뺄셈·덧셈이 1ulp 흔들리면 범위 밖 legacy x가 커진 것으로 거부되던 반례 + const bounds: Bounds = { + x: 50577.53549435205, + y: 0, + width: 100, + height: 100, + }; + const start: Bounds = { + x: -38245.44619570407, + y: 0, + width: 88922.98169005613, + height: 100, + }; + const candidate: Bounds = { ...start, height: 200 }; + const projected = projectGroupElementBounds( + key, + bounds, + start, + candidate, + bottomHandle, + ); + expect(projected.x).toBe(bounds.x); + expect(projected.width).toBe(bounds.width); + expect(projected.height).toBe(200); + expect( + limitGroupGrowth( + [{ element: key, bounds }], + start, + candidate, + bottomHandle, + limits, + ), + ).toEqual({ bounds: candidate, limitedWidth: false, limitedHeight: false }); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts index e6b6e0cc3..206dd1b13 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts @@ -8,6 +8,8 @@ import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { isBoundsTransitionWithinEditorLimits } from './resizeLimits'; + export type { SelectedElement }; // ===== 타입 정의 ===== @@ -43,6 +45,180 @@ export function elementBoundsChanged( }); } +/** 항상 비율을 지키는 요소 - 그림 레이어(스프라이트)는 늘리지 않는다 */ +export function isAspectLockedElement(element: SelectedElement): boolean { + return element.type === 'sprite'; +} + +export interface GroupResizeAxis { + dx: -1 | 0 | 1; + dy: -1 | 0 | 1; +} + +const groupScaleRatio = (start: number, next: number): number => + start > 0 && Number.isFinite(start) && Number.isFinite(next) + ? next / start + : 1; + +/** + * 비율 고정 요소가 따를 단일 배율. 드래그한 축의 배율을 쓰고, 두 축이 함께 움직이는 + * 모서리는 단일 리사이즈와 같은 규칙으로 변화가 큰 축을 따른다 + */ +export function uniformGroupScale( + scaleX: number, + scaleY: number, + handle: GroupResizeAxis, +): number { + if (handle.dx !== 0 && handle.dy === 0) return scaleX; + if (handle.dy !== 0 && handle.dx === 0) return scaleY; + return Math.abs(scaleX - 1) >= Math.abs(scaleY - 1) ? scaleX : scaleY; +} + +/** + * 그룹 변환을 요소 하나에 투영. 일반 요소는 그룹 배율을 축마다 그대로 받고, + * 비율 고정 요소는 단일 배율로 가로·세로를 함께 바꾸되 중심은 그룹 배율로 옮겨 + * 다른 요소와 같은 자리를 지킨다 + */ +export function projectGroupElementBounds( + element: SelectedElement, + bounds: Bounds, + startGroup: Bounds, + nextGroup: Bounds, + handle: GroupResizeAxis, +): Bounds { + const scaleX = groupScaleRatio(startGroup.width, nextGroup.width); + const scaleY = groupScaleRatio(startGroup.height, nextGroup.height); + // 그룹이 안 움직인 축은 요소 값을 그대로 둔다 - 되돌린 뺄셈·덧셈이 극단 좌표에서 + // 1ulp 흔들리면 범위 밖 legacy 좌표가 "커진" 것으로 거부된다 + const stillX = scaleX === 1 && nextGroup.x === startGroup.x; + const stillY = scaleY === 1 && nextGroup.y === startGroup.y; + const relativeX = bounds.x - startGroup.x; + const relativeY = bounds.y - startGroup.y; + if (!isAspectLockedElement(element)) { + return { + x: stillX ? bounds.x : nextGroup.x + relativeX * scaleX, + y: stillY ? bounds.y : nextGroup.y + relativeY * scaleY, + width: stillX ? bounds.width : bounds.width * scaleX, + height: stillY ? bounds.height : bounds.height * scaleY, + }; + } + const scale = uniformGroupScale(scaleX, scaleY, handle); + if (scale === 1 && stillX && stillY) return { ...bounds }; + const width = bounds.width * scale; + const height = bounds.height * scale; + const centerX = nextGroup.x + (relativeX + bounds.width / 2) * scaleX; + const centerY = nextGroup.y + (relativeY + bounds.height / 2) * scaleY; + return { + x: centerX - width / 2, + y: centerY - height / 2, + width, + height, + }; +} + +export interface GroupGrowthLimit { + bounds: Bounds; + /** 후보보다 줄어든 축 - 그 축의 스냅 가이드는 화면에서 성립하지 않는다 */ + limitedWidth: boolean; + limitedHeight: boolean; +} + +const lerpBounds = (start: Bounds, end: Bounds, t: number): Bounds => ({ + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + width: start.width + (end.width - start.width) * t, + height: start.height + (end.height - start.height) * t, +}); + +/** + * 그룹 후보를 요소들에 투영해 하나라도 백엔드 상한(치수·저장 좌표)을 넘으면 + * 시작 → 후보 선형 경로의 진행 배율 t를 줄인다. 경로가 선형이면 투영된 x·y·치수가 + * 전부 t에 선형이라(단일 배율 선택 축도 t에 불변) 제약마다 t*가 닫힌 꼴로 나온다 + * - 모서리 핸들은 크기(단일 배율)와 중심(축별 배율)이 결합돼 축별 델타 상한으로는 + * 증명이 안 되므로 최종 투영으로 판정한다. 부동소수 잔차는 같은 predicate로 + * 재검증하고 무효면 [0, t]를 이분 탐색한다 (t=0은 시작 상태라 항상 유효) + * - 전이 규칙을 미러하므로 범위 밖 legacy 요소는 그 항목을 키우지 않는 한 통과한다 + */ +export function limitGroupGrowth( + elements: ElementBounds[], + startGroup: Bounds, + candidate: Bounds, + handle: GroupResizeAxis, + limits: { maxDimension: number; maxAbsCoordinate: number }, +): GroupGrowthLimit { + const project = (group: Bounds): Bounds[] => + elements.map(({ element, bounds }) => + projectGroupElementBounds(element, bounds, startGroup, group, handle), + ); + // 요소마다 시작 bounds를 현재값으로 두는 전이 판정 - 범위 밖 legacy 요소가 있어도 + // 그 항목을 건드리지 않는 직교축 리사이즈는 막히지 않는다 (t=0은 항상 유효) + const allValid = (group: Bounds): boolean => + project(group).every((bounds, index) => + isBoundsTransitionWithinEditorLimits(elements[index].bounds, bounds), + ); + const unchanged = { + bounds: candidate, + limitedWidth: false, + limitedHeight: false, + }; + if (allValid(candidate)) return unchanged; + + // 제약마다 g(t) = g0 + t·(g1 - g0) 가 한계에 닿는 t* + let t = 1; + const tighten = (g0: number, g1: number, limit: number) => { + if (g1 > limit && g1 !== g0) t = Math.min(t, (limit - g0) / (g1 - g0)); + }; + const starts = project(startGroup); + const ends = project(candidate); + starts.forEach((g0, index) => { + const g1 = ends[index]; + tighten(g0.x, g1.x, limits.maxAbsCoordinate); + tighten(-g0.x, -g1.x, limits.maxAbsCoordinate); + tighten(g0.y, g1.y, limits.maxAbsCoordinate); + tighten(-g0.y, -g1.y, limits.maxAbsCoordinate); + tighten(g0.width, g1.width, limits.maxDimension); + tighten(g0.height, g1.height, limits.maxDimension); + }); + t = Math.max(0, Math.min(1, t)); + if (!allValid(lerpBounds(startGroup, candidate, t))) { + let lo = 0; + let hi = t; + for (let step = 0; step < 40; step += 1) { + const mid = (lo + hi) / 2; + if (allValid(lerpBounds(startGroup, candidate, mid))) lo = mid; + else hi = mid; + } + t = lo; + } + const bounds = lerpBounds(startGroup, candidate, t); + return { + bounds, + limitedWidth: bounds.width !== candidate.width, + limitedHeight: bounds.height !== candidate.height, + }; +} + +/** + * 축소 한계 계산에 쓸 요소 치수. 비율 고정 요소는 어느 축을 끌어도 함께 + * 줄어드는 보호 축 중 짧은 변으로 잰다 + */ +export function shrinkLimitSize( + element: SelectedElement, + bounds: Bounds, + axis: 'x' | 'y', + minSize = 0, +): number { + if (isAspectLockedElement(element)) { + const guarded = [bounds.width, bounds.height].filter( + (size) => size >= minSize, + ); + return guarded.length > 0 + ? Math.min(...guarded) + : Math.min(bounds.width, bounds.height); + } + return axis === 'x' ? bounds.width : bounds.height; +} + /** * 요소가 리사이즈 가능한지 확인 */ @@ -63,6 +239,9 @@ export function isElementResizable( return true; } else if (element.type === 'knob') { return true; + } else if (element.type === 'sprite') { + // 리사이즈는 resizeSprite op - 상자와 자세 이동값이 함께 비례 스케일된다 + return true; } else if (element.type === 'plugin') { const pluginEl = pluginElements.find( (p: PluginDisplayElementInternal) => p.fullId === element.id, @@ -90,6 +269,7 @@ export function getElementBounds( knobPositions: CanonicalEditorDocumentV1['knobPositions'], selectedKeyType: string, pluginElements: PluginDisplayElementInternal[], + spritePositions: CanonicalEditorDocumentV1['spritePositions'], ): Bounds | null { if (element.type === 'key') { const pos = positions[selectedKeyType]?.find( @@ -135,6 +315,18 @@ export function getElementBounds( width: pos.width || 60, height: pos.height || 60, }; + } else if (element.type === 'sprite') { + // 활동 영역 박스가 곧 리사이즈 대상 + const pos = spritePositions[selectedKeyType]?.find( + (candidate) => candidate.id === element.id, + ); + if (!pos) return null; + return { + x: pos.dx, + y: pos.dy, + width: pos.width || 200, + height: pos.height || 200, + }; } else if (element.type === 'plugin') { const pluginEl = pluginElements.find( (p: PluginDisplayElementInternal) => p.fullId === element.id, @@ -161,6 +353,7 @@ export function calculateGroupBounds( knobPositions: CanonicalEditorDocumentV1['knobPositions'], selectedKeyType: string, pluginElements: PluginDisplayElementInternal[], + spritePositions: CanonicalEditorDocumentV1['spritePositions'], ): (Bounds & { elementBounds: ElementBounds[] }) | null { let minX = Infinity; let minY = Infinity; @@ -192,6 +385,7 @@ export function calculateGroupBounds( knobPositions, selectedKeyType, pluginElements, + spritePositions, ); if (!bounds) continue; @@ -241,3 +435,34 @@ export function getNonResizableElementIds( ) .map((element) => element.id); } + +// 요소 배치 회전(도). 자세 회전은 별도 채널이며 플러그인은 0 +export function getElementRotation( + element: SelectedElement, + positions: CanonicalEditorDocumentV1['keyPositions'], + statPositions: CanonicalEditorDocumentV1['statPositions'], + graphPositions: CanonicalEditorDocumentV1['graphPositions'], + knobPositions: CanonicalEditorDocumentV1['knobPositions'], + selectedKeyType: string, + spritePositions?: CanonicalEditorDocumentV1['spritePositions'], +): number { + const collection = + element.type === 'key' + ? positions + : element.type === 'stat' + ? statPositions + : element.type === 'graph' + ? graphPositions + : element.type === 'knob' + ? knobPositions + : element.type === 'sprite' + ? spritePositions + : null; + const pos = collection?.[selectedKeyType]?.find( + (candidate) => candidate.id === element.id, + ); + const rotation = pos?.rotation; + return typeof rotation === 'number' && Number.isFinite(rotation) + ? rotation + : 0; +} diff --git a/src/renderer/components/main/Grid/handles/resizeLimits.test.ts b/src/renderer/components/main/Grid/handles/resizeLimits.test.ts new file mode 100644 index 000000000..6afd7fb3a --- /dev/null +++ b/src/renderer/components/main/Grid/handles/resizeLimits.test.ts @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; +import { + isBoundsTransitionWithinEditorLimits, + isBoundsWithinEditorLimits, +} from './resizeLimits'; + +describe('resizeLimits', () => { + it('상한 상수는 Rust editor.rs와 같다', () => { + const rustSource = readFileSync( + resolve(process.cwd(), 'src-tauri/src/state/editor.rs'), + 'utf8', + ); + const read = (name: string) => { + const match = rustSource.match( + new RegExp(`const ${name}: f64 = ([0-9_]+)\\.0;`), + ); + expect(match, `Rust ${name}`).not.toBeNull(); + return Number(match![1].replace(/_/g, '')); + }; + expect(read('MAX_DIMENSION')).toBe(EDITOR_BOUNDS_LIMITS.maxDimension); + expect(read('MAX_ABS_COORDINATE')).toBe( + EDITOR_BOUNDS_LIMITS.maxAbsCoordinate, + ); + }); + + it('저장 좌표 절댓값과 치수만 보고 오른쪽·아래 가장자리는 보지 않는다', () => { + expect( + isBoundsWithinEditorLimits({ + x: 32768, + y: -32768, + width: 32768, + height: 0.1, + }), + ).toBe(true); + // 오른쪽 가장자리 65536은 백엔드가 검사하지 않는다 + expect( + isBoundsWithinEditorLimits({ x: 32768, y: 0, width: 32768, height: 10 }), + ).toBe(true); + expect( + isBoundsWithinEditorLimits({ x: 32769, y: 0, width: 10, height: 10 }), + ).toBe(false); + expect( + isBoundsWithinEditorLimits({ x: 0, y: 0, width: 32769, height: 10 }), + ).toBe(false); + expect( + isBoundsWithinEditorLimits({ x: 0, y: 0, width: 0, height: 10 }), + ).toBe(false); + expect( + isBoundsWithinEditorLimits({ x: 0, y: NaN, width: 10, height: 10 }), + ).toBe(false); + }); + + it('전이 판정은 범위 밖 legacy 항목을 그대로 두거나 줄이는 후보만 받는다', () => { + const legacy = { x: 40000, y: 0, width: 40000, height: 100 }; + // 폭·x는 그대로, 높이만 바꿈 → 백엔드 non-increasing 규칙으로 통과 + expect( + isBoundsTransitionWithinEditorLimits(legacy, { ...legacy, height: 150 }), + ).toBe(true); + // 범위 밖 폭을 더 키우면 거부 + expect( + isBoundsTransitionWithinEditorLimits(legacy, { ...legacy, width: 40001 }), + ).toBe(false); + // 범위 밖 폭·좌표를 줄이면 통과 (절댓값 기준) + expect( + isBoundsTransitionWithinEditorLimits(legacy, { + ...legacy, + x: -39000, + width: 39000, + }), + ).toBe(true); + // 정상 시작에서 범위 밖으로 나가면 거부 + expect( + isBoundsTransitionWithinEditorLimits( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 0, y: 0, width: 32769, height: 100 }, + ), + ).toBe(false); + // 비유한값은 비트 동일일 때만 + expect( + isBoundsTransitionWithinEditorLimits( + { x: 0, y: 0, width: 100, height: 100 }, + { x: NaN, y: 0, width: 100, height: 100 }, + ), + ).toBe(false); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/resizeLimits.ts b/src/renderer/components/main/Grid/handles/resizeLimits.ts new file mode 100644 index 000000000..da7da7c84 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/resizeLimits.ts @@ -0,0 +1,65 @@ +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; + +export interface ResizeBounds { + x: number; + y: number; + width: number; + height: number; +} + +const { maxDimension, maxAbsCoordinate } = EDITOR_BOUNDS_LIMITS; + +const coordinateWithinLimit = (value: number): boolean => + Number.isFinite(value) && Math.abs(value) <= maxAbsCoordinate; + +const dimensionWithinLimit = (value: number): boolean => + Number.isFinite(value) && value > 0 && value <= maxDimension; + +// editor.rs numeric_metric_non_increasing 미러 - 범위 밖 현재값은 그대로 두거나 +// 절댓값(좌표)·크기(치수)가 줄어드는 후보만 받는다 +const nonIncreasing = ( + current: number, + candidate: number, + dimension: boolean, +): boolean => { + if (!Number.isFinite(current) || !Number.isFinite(candidate)) { + return Object.is(current, candidate); + } + if (dimension && (current <= 0 || candidate <= 0)) { + return Object.is(current, candidate); + } + return dimension + ? candidate <= current + : Math.abs(candidate) <= Math.abs(current); +}; + +// 백엔드 validate_bounds_metrics와 같은 판정. 저장 좌표(왼쪽·위)의 절댓값과 +// 치수(0 < v ≤ 상한)만 보고 오른쪽·아래 가장자리는 보지 않는다 +export const isBoundsWithinEditorLimits = (bounds: ResizeBounds): boolean => + coordinateWithinLimit(bounds.x) && + coordinateWithinLimit(bounds.y) && + dimensionWithinLimit(bounds.width) && + dimensionWithinLimit(bounds.height); + +/** + * 현재값을 아는 전이 판정 - 백엔드의 legacy 관용까지 미러한다. 이미 범위 밖인 + * 항목은 그대로 두거나 줄이는 후보를 통과시키므로, 폭이 범위 밖인 옛 요소도 + * 높이만 바꾸는 리사이즈는 막히지 않는다 + */ +export const isBoundsTransitionWithinEditorLimits = ( + current: ResizeBounds, + candidate: ResizeBounds, +): boolean => { + const coordinate = (before: number, after: number) => + coordinateWithinLimit(after) || + (!coordinateWithinLimit(before) && nonIncreasing(before, after, false)); + const dimension = (before: number, after: number) => + dimensionWithinLimit(after) || + (!dimensionWithinLimit(before) && nonIncreasing(before, after, true)); + return ( + coordinate(current.x, candidate.x) && + coordinate(current.y, candidate.y) && + dimension(current.width, candidate.width) && + dimension(current.height, candidate.height) + ); +}; diff --git a/src/renderer/components/main/Grid/handles/rotatableElement.ts b/src/renderer/components/main/Grid/handles/rotatableElement.ts new file mode 100644 index 000000000..2f7967e5b --- /dev/null +++ b/src/renderer/components/main/Grid/handles/rotatableElement.ts @@ -0,0 +1,7 @@ +// 공통 스타일 속성으로 회전하는 종류. 스프라이트는 전용 필드 patch 사용 +export type RotatableElementType = 'key' | 'stat' | 'graph' | 'knob'; + +export const isRotatableElementType = ( + type: string, +): type is RotatableElementType => + type === 'key' || type === 'stat' || type === 'graph' || type === 'knob'; diff --git a/src/renderer/components/main/Grid/handles/rotateCornerGeometry.test.ts b/src/renderer/components/main/Grid/handles/rotateCornerGeometry.test.ts new file mode 100644 index 000000000..bdcfa65c0 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/rotateCornerGeometry.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { rotatedRectCorners, rotatePointAround } from '@utils/element/rotation'; +import { rotationCursorForAngle } from '@utils/grid/cursorUtils'; +import { rotateCornerGeometry } from './rotateCornerGeometry'; + +describe('모서리 바깥 회전 영역', () => { + it.each([-180, -135, -45, 0, 15, 45, 90, 179])( + '%s°와 확대·축소에서 각 꼭짓점을 기준으로 바깥 영역을 회전한다', + (rotation) => { + for (const zoom of [0.25, 1, 3]) { + for (const [width, height] of [ + [200, 5], + [5, 200], + ]) { + const corners = rotatedRectCorners( + 100, + 100, + width * zoom, + height * zoom, + rotation, + ); + const handles = rotateCornerGeometry(corners); + expect(handles).toHaveLength(4); + handles.forEach((handle, index) => { + expect({ x: handle.x, y: handle.y }).toEqual(corners[index]); + const outside = rotatePointAround( + { x: handle.x - 16, y: handle.y - 16 }, + handle, + handle.rotation, + ); + const local = rotatePointAround(outside, corners[index], -rotation); + const dx = local.x - corners[index].x; + const dy = local.y - corners[index].y; + expect(dx).toBeCloseTo(index === 0 || index === 3 ? -16 : 16); + expect(dy).toBeCloseTo(index < 2 ? -16 : 16); + }); + } + } + }, + ); + + it('화면 방향에 따라 여덟 커서를 고르고 음수와 한 바퀴를 정규화한다', () => { + for (let index = 0; index < 8; index++) { + const expected = index === 0 ? 'rotate' : `rotate-${index * 45}`; + for (const turns of [-2, 0, 2]) { + expect(rotationCursorForAngle(index * 45 + turns * 360)).toBe(expected); + } + } + expect(rotationCursorForAngle(22.49)).toBe('rotate'); + expect(rotationCursorForAngle(22.51)).toBe('rotate-45'); + expect(rotationCursorForAngle(-22.51)).toBe('rotate-315'); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/rotateCornerGeometry.ts b/src/renderer/components/main/Grid/handles/rotateCornerGeometry.ts new file mode 100644 index 000000000..54ab5ca6c --- /dev/null +++ b/src/renderer/components/main/Grid/handles/rotateCornerGeometry.ts @@ -0,0 +1,20 @@ +import type { Point } from '@utils/element/rotation'; + +export const ROTATE_CORNER_OUTER_REACH = 26; +export const ROTATE_CORNER_INNER_REACH = 14; + +// 화면 좌표를 받아 줌과 무관하게 크기 조절 영역 바깥에 배치 +export const rotateCornerGeometry = (corners: readonly Point[]) => { + const topX = corners[1].x - corners[0].x; + const topY = corners[1].y - corners[0].y; + const angle = + Math.hypot(topX, topY) > 1e-6 + ? Math.atan2(topY, topX) + : Math.atan2(corners[3].y - corners[0].y, corners[3].x - corners[0].x) - + Math.PI / 2; + return corners.map((corner, index) => ({ + x: corner.x, + y: corner.y, + rotation: (angle * 180) / Math.PI + index * 90, + })); +}; diff --git a/src/renderer/components/main/Grid/handles/rotatedGroupResize.test.ts b/src/renderer/components/main/Grid/handles/rotatedGroupResize.test.ts new file mode 100644 index 000000000..e133c706f --- /dev/null +++ b/src/renderer/components/main/Grid/handles/rotatedGroupResize.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; +import { rotatePointAround } from '@utils/element/rotation'; +import type { GroupResizeHandle } from './groupResizePlan'; +import type { Bounds, ElementBounds } from './groupResizeUtils'; +import { + calculateRotatedGroupResizePlan, + type GroupRotationFrame, +} from './rotatedGroupResize'; +import { isBoundsTransitionWithinEditorLimits } from './resizeLimits'; + +const handles: GroupResizeHandle[] = [ + ['nw', -1, -1], + ['n', 0, -1], + ['ne', 1, -1], + ['w', -1, 0], + ['e', 1, 0], + ['sw', -1, 1], + ['s', 0, 1], + ['se', 1, 1], +].map(([id, x, y]) => { + const dx = x as -1 | 0 | 1; + const dy = y as -1 | 0 | 1; + return { + id: id as string, + cursor: 'nwse-resize', + dx, + dy, + x: (dx + 1) / 2, + y: (dy + 1) / 2, + type: dx === 0 ? 'edge-h' : dy === 0 ? 'edge-v' : 'corner', + }; +}); +const east = handles.find(({ id }) => id === 'e')!; +const frame: Bounds = { x: -100, y: 20, width: 400, height: 300 }; +const elements: ElementBounds[] = [ + { + element: { type: 'key', id: 'key', index: 0 }, + bounds: { x: -20, y: 70, width: 60, height: 30 }, + }, + { + element: { type: 'stat', id: 'stat', index: 0 }, + bounds: { x: 90, y: 95, width: 45, height: 15 }, + }, + { + element: { type: 'graph', id: 'graph', index: 0 }, + bounds: { x: 120, y: 80, width: 80, height: 60 }, + }, + { + element: { type: 'knob', id: 'knob', index: 0 }, + bounds: { x: 20, y: 180, width: 50, height: 50 }, + }, + { + element: { type: 'sprite', id: 'sprite', index: 0 }, + bounds: { x: -60, y: 140, width: 90, height: 45 }, + }, +]; +const pointOnFrame = (bounds: Bounds, rotation: number, x: number, y: number) => + rotatePointAround( + { x: bounds.x + bounds.width * x, y: bounds.y + bounds.height * y }, + { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }, + rotation, + ); +const resize = ( + rotationFrame: GroupRotationFrame, + entries: ElementBounds[], + deltaX: number, + deltaY: number, + handle = east, + snapSize = 0, +) => + calculateRotatedGroupResizePlan({ + rotationFrame, + startElementBounds: entries, + deltaX, + deltaY, + handle, + snapSize, + }); + +describe('회전한 그룹의 균등 리사이즈', () => { + it.each(handles)( + '$id 핸들은 반대 고정점과 멤버의 모든 시각 꼭짓점 배치를 보존한다', + (handle) => { + for (const rotation of [0, 30, 90, -135, 180]) { + const localDelta = { + x: handle.dx * frame.width * 0.5, + y: handle.dy * frame.height * 0.5, + }; + const delta = rotatePointAround(localDelta, { x: 0, y: 0 }, rotation); + const result = resize( + { bounds: frame, rotation }, + elements, + delta.x, + delta.y, + handle, + ).result; + const anchor = pointOnFrame( + frame, + rotation, + 1 - handle.x, + 1 - handle.y, + ); + const nextAnchor = pointOnFrame( + result.groupBounds, + rotation, + 1 - handle.x, + 1 - handle.y, + ); + expect(nextAnchor.x).toBeCloseTo(anchor.x, 8); + expect(nextAnchor.y).toBeCloseTo(anchor.y, 8); + expect(result.groupBounds.width / frame.width).toBeCloseTo(1.5, 10); + expect(result.groupBounds.height / frame.height).toBeCloseTo(1.5, 10); + result.elementBounds.forEach(({ element, bounds }, index) => { + expect(element).toBe(elements[index].element); + const before = elements[index].bounds; + const angle = [45, -30, 90, 135, 0][index]; + expect(bounds.width / before.width).toBeCloseTo(1.5, 10); + expect(bounds.height / before.height).toBeCloseTo(1.5, 10); + for (const [x, y] of [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + ]) { + const oldCorner = pointOnFrame(before, angle, x, y); + const newCorner = pointOnFrame(bounds, angle, x, y); + expect(newCorner.x).toBeCloseTo( + anchor.x + 1.5 * (oldCorner.x - anchor.x), + 8, + ); + expect(newCorner.y).toBeCloseTo( + anchor.y + 1.5 * (oldCorner.y - anchor.y), + 8, + ); + } + }); + } + }, + ); + + it('회전 프레임의 로컬 이동만 스냅하고 요소 좌표는 따로 반올림하지 않는다', () => { + const bounds = { x: 1.3, y: 4.7, width: 150, height: 90 }; + const result = resize({ bounds, rotation: 90 }, elements, 0, 17, east, 10); + const scale = 170 / 150; + expect(result.guides).toEqual({ type: 'clear' }); + for (let index = 0; index < elements.length; index += 1) { + const before = elements[index].bounds; + const after = result.result.elementBounds[index].bounds; + expect(after.width / before.width).toBeCloseTo(scale, 10); + expect(after.height / before.height).toBeCloseTo(scale, 10); + } + expect(result.result.elementBounds[0].bounds.x % 10).not.toBe(0); + }); + + it('좌표 상한에 닿으면 모든 요소의 배율을 함께 줄인다', () => { + const entries: ElementBounds[] = [ + { ...elements[0], bounds: { x: 32760, y: 0, width: 40, height: 40 } }, + { ...elements[1], bounds: { x: 32100, y: 50, width: 20, height: 30 } }, + ]; + const result = resize( + { bounds: { x: 32000, y: 0, width: 1000, height: 100 }, rotation: 90 }, + entries, + 0, + 1000, + ).result; + const scale = result.elementBounds[0].bounds.width / 40; + expect(scale).toBeGreaterThan(1); + expect(scale).toBeLessThan(2); + expect(result.elementBounds[0].bounds.x).toBeCloseTo( + EDITOR_BOUNDS_LIMITS.maxAbsCoordinate, + 6, + ); + result.elementBounds.forEach(({ bounds }, index) => { + expect( + isBoundsTransitionWithinEditorLimits(entries[index].bounds, bounds), + ).toBe(true); + expect(bounds.width / entries[index].bounds.width).toBeCloseTo(scale, 12); + expect(bounds.height / entries[index].bounds.height).toBeCloseTo( + scale, + 12, + ); + }); + }); + + it('한 요소의 치수 상한이 전체 확대를 제한한다', () => { + const entries = [ + { ...elements[0], bounds: { x: 0, y: 0, width: 32000, height: 100 } }, + { ...elements[1], bounds: { x: 1, y: 1, width: 20, height: 30 } }, + ]; + const result = resize( + { bounds: { x: 0, y: 0, width: 32000, height: 100 }, rotation: 0 }, + entries, + 32000, + 0, + ).result; + const scale = EDITOR_BOUNDS_LIMITS.maxDimension / 32000; + expect(result.elementBounds[0].bounds.width).toBe( + EDITOR_BOUNDS_LIMITS.maxDimension, + ); + expect(result.elementBounds[1].bounds.width).toBe(20 * scale); + expect(result.elementBounds[1].bounds.height).toBe(30 * scale); + }); + + it('축소 하한과 이미 얇은 스프라이트 축을 같은 배율로 보존한다', () => { + const entries = [ + { ...elements[0], bounds: { x: 0, y: 0, width: 40, height: 20 } }, + { ...elements[4], bounds: { x: 100, y: 20, width: 400, height: 0.1 } }, + ]; + const result = resize( + { bounds: { x: 0, y: 0, width: 500, height: 40 }, rotation: 30 }, + entries, + -500, + -500, + ).result; + expect(result.elementBounds[0].bounds.width).toBe(20); + expect(result.elementBounds[0].bounds.height).toBe(10); + expect(result.elementBounds[1].bounds.width).toBe(200); + expect(result.elementBounds[1].bounds.height).toBe(0.05); + }); + + it('범위 밖 legacy 치수의 확대를 막고 감소는 허용한다', () => { + const entries = [ + { ...elements[0], bounds: { x: 0, y: 0, width: 40000, height: 100 } }, + ]; + const rotationFrame = { bounds: entries[0].bounds, rotation: 0 }; + expect( + resize(rotationFrame, entries, 40000, 0).result.elementBounds, + ).toEqual(entries); + expect( + resize(rotationFrame, entries, -20000, 0).result.elementBounds[0].bounds + .width, + ).toBe(20000); + }); + + it('이동이 없거나 입력이 유효하지 않으면 시작 값을 그대로 돌려준다', () => { + const rotationFrame = { bounds: frame, rotation: 45 }; + for (const [x, y] of [ + [0, 0], + [Infinity, 3], + [1, NaN], + ]) { + const result = resize(rotationFrame, elements, x, y).result; + expect(result.groupBounds).toEqual(frame); + expect(result.elementBounds).toEqual(elements); + } + }); +}); diff --git a/src/renderer/components/main/Grid/handles/rotatedGroupResize.ts b/src/renderer/components/main/Grid/handles/rotatedGroupResize.ts new file mode 100644 index 000000000..e3e3b7463 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/rotatedGroupResize.ts @@ -0,0 +1,190 @@ +import { snapToGrid } from '@hooks/Grid/utils'; +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; +import { + SPRITE_CONSTRAINTS, + type SpriteTransform, +} from '@src/types/key/sprites'; +import { rotatePointAround } from '@utils/element/rotation'; +import { + GROUP_RESIZE_MIN_SIZE, + type GroupResizeHandle, + type GroupResizePlan, +} from './groupResizePlan'; +import type { Bounds, ElementBounds } from './groupResizeUtils'; +import { isBoundsTransitionWithinEditorLimits } from './resizeLimits'; +import { screenDeltaToLocal } from './rotatedResize'; + +export interface GroupRotationFrame { + bounds: Bounds; + rotation: number; +} + +interface RotatedGroupResizeOptions { + rotationFrame: GroupRotationFrame; + handle: GroupResizeHandle; + startElementBounds: ElementBounds[]; + deltaX: number; + deltaY: number; + snapSize: number; + maxSpriteScale?: number; +} + +// 자세 이동값이 개별 clamp되기 전에 그룹 전체 확대를 멈춘다 +export const spriteGroupScaleLimit = ( + sprites: ReadonlyArray<{ + idleTransform: SpriteTransform; + poses: ReadonlyArray<{ transform: SpriteTransform }>; + }>, +): number => { + let limit = Number.POSITIVE_INFINITY; + for (const sprite of sprites) { + for (const transform of [ + sprite.idleTransform, + ...sprite.poses.map((pose) => pose.transform), + ]) { + for (const value of [transform.x, transform.y]) { + if (value === 0) continue; + const bound = + value > 0 + ? SPRITE_CONSTRAINTS.offset.max + : SPRITE_CONSTRAINTS.offset.min; + limit = Math.min(limit, Math.max(1, bound / value)); + } + } + } + return limit; +}; + +export const calculateRotatedGroupResizePlan = ({ + rotationFrame, + handle, + startElementBounds, + deltaX, + deltaY, + snapSize, + maxSpriteScale = Number.POSITIVE_INFINITY, +}: RotatedGroupResizeOptions): GroupResizePlan => { + const start = rotationFrame.bounds; + const unchanged = (): GroupResizePlan => ({ + result: { + groupBounds: { ...start }, + elementBounds: startElementBounds.map(({ element, bounds }) => ({ + element, + bounds: { ...bounds }, + })), + handle, + }, + guides: { type: 'clear' }, + }); + const validBounds = (bounds: Bounds) => + [bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite) && + bounds.width > 0 && + bounds.height > 0; + if ( + !validBounds(start) || + !Number.isFinite(rotationFrame.rotation) || + !Number.isFinite(deltaX) || + !Number.isFinite(deltaY) || + startElementBounds.length === 0 || + startElementBounds.some(({ bounds }) => !validBounds(bounds)) + ) { + return unchanged(); + } + + const local = screenDeltaToLocal(deltaX, deltaY, rotationFrame.rotation); + const horizontal = + handle.dx !== 0 && + (handle.dy === 0 || + Math.abs(local.x / start.width) >= Math.abs(local.y / start.height)); + const size = horizontal ? start.width : start.height; + const direction = horizontal ? handle.dx : handle.dy; + const delta = horizontal ? local.x : local.y; + const snappedDelta = snapSize > 0 ? snapToGrid(delta, snapSize) : delta; + const candidateScale = 1 + (direction * snappedDelta) / size; + if (!Number.isFinite(candidateScale) || candidateScale === 1) + return unchanged(); + + // 반대 모서리 또는 반대 변의 중앙을 화면 좌표에서 고정 + const center = { + x: start.x + start.width / 2, + y: start.y + start.height / 2, + }; + const anchor = rotatePointAround( + { + x: center.x - (handle.dx * start.width) / 2, + y: center.y - (handle.dy * start.height) / 2, + }, + center, + rotationFrame.rotation, + ); + if (!Number.isFinite(anchor.x) || !Number.isFinite(anchor.y)) + return unchanged(); + + let minScale = 0; + let maxScale = Math.min(Number.MAX_VALUE, maxSpriteScale); + const { maxDimension, maxAbsCoordinate } = EDITOR_BOUNDS_LIMITS; + for (const { bounds } of startElementBounds) { + const guarded = [bounds.width, bounds.height].filter( + (value) => value >= GROUP_RESIZE_MIN_SIZE, + ); + if (guarded.length === 0) minScale = 1; + for (const value of guarded) { + let minimum = GROUP_RESIZE_MIN_SIZE / value; + while (value * minimum < GROUP_RESIZE_MIN_SIZE) + minimum += minimum * Number.EPSILON; + minScale = Math.max(minScale, minimum); + } + maxScale = Math.min( + maxScale, + Math.max(maxDimension, bounds.width) / bounds.width, + Math.max(maxDimension, bounds.height) / bounds.height, + ); + // 모든 저장 좌표가 같은 배율을 허용해야 배치가 유지된다 + for (const axis of ['x', 'y'] as const) { + const origin = bounds[axis]; + const slope = origin - anchor[axis]; + if (slope === 0) continue; + const limit = Math.max(maxAbsCoordinate, Math.abs(origin)); + const first = 1 + (-limit - origin) / slope; + const second = 1 + (limit - origin) / slope; + minScale = Math.max(minScale, Math.min(first, second)); + maxScale = Math.min(maxScale, Math.max(first, second)); + } + } + let scale = Math.max( + Math.min(minScale, 1), + Math.min(Math.max(maxScale, 1), candidateScale), + ); + const project = (bounds: Bounds): Bounds => + scale === 1 + ? { ...bounds } + : { + x: bounds.x + (bounds.x - anchor.x) * (scale - 1), + y: bounds.y + (bounds.y - anchor.y) * (scale - 1), + width: bounds.width * scale, + height: bounds.height * scale, + }; + + // 한계의 부동소수 잔차도 요소별 보정 없이 공통 배율로 되돌린다 + for (let attempt = 0; attempt < 8; attempt += 1) { + const elementBounds = startElementBounds.map(({ element, bounds }) => ({ + element, + bounds: project(bounds), + })); + if ( + elementBounds.every(({ bounds }, index) => + isBoundsTransitionWithinEditorLimits( + startElementBounds[index].bounds, + bounds, + ), + ) + ) { + return { + result: { groupBounds: project(start), elementBounds, handle }, + guides: { type: 'clear' }, + }; + } + scale += (1 - scale) * 2 ** -20; + } + return unchanged(); +}; diff --git a/src/renderer/components/main/Grid/handles/rotatedResize.test.ts b/src/renderer/components/main/Grid/handles/rotatedResize.test.ts new file mode 100644 index 000000000..6d6b021be --- /dev/null +++ b/src/renderer/components/main/Grid/handles/rotatedResize.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { + anchorRotatedResize, + resizeCursorForHandle, + screenDeltaToLocal, +} from './rotatedResize'; + +const near = (value: number, expected: number) => + expect(value).toBeCloseTo(expected, 6); + +describe('screenDeltaToLocal', () => { + it('회전 0은 그대로다', () => { + expect(screenDeltaToLocal(10, -4, 0)).toEqual({ x: 10, y: -4 }); + }); + + it('90° 회전한 요소에서 화면 아래 이동은 로컬 +x다', () => { + const local = screenDeltaToLocal(0, 10, 90); + near(local.x, 10); + near(local.y, 0); + }); +}); + +describe('anchorRotatedResize', () => { + const start = { x: 100, y: 100, width: 60, height: 40 }; + + it('회전 0은 로컬 결과 그대로다', () => { + const local = { x: 100, y: 100, width: 80, height: 40 }; + expect(anchorRotatedResize(start, local, 0)).toBe(local); + }); + + it('90° 회전 요소의 오른쪽 핸들을 늘리면 화면에서 아래로 자라고 왼쪽(화면 위) 변은 고정된다', () => { + // 로컬: 왼쪽 변 고정, 폭 60→80 + const local = { x: 100, y: 100, width: 80, height: 40 }; + const result = anchorRotatedResize(start, local, 90); + // 로컬 중심이 +10 x 이동 → 화면에서는 +10 y + near(result.x + result.width / 2, 130); + near(result.y + result.height / 2, 130); + expect(result.width).toBe(80); + expect(result.height).toBe(40); + }); + + it('크기는 바꾸지 않고 중심 이동만 회전시킨다', () => { + for (const rotation of [-135, -30, 17, 60, 180]) { + const local = { x: 90, y: 95, width: 75, height: 50 }; + const result = anchorRotatedResize(start, local, rotation); + expect(result.width).toBe(75); + expect(result.height).toBe(50); + const startCx = 130; + const startCy = 120; + const shift = Math.hypot( + local.x + local.width / 2 - startCx, + local.y + local.height / 2 - startCy, + ); + near( + Math.hypot( + result.x + result.width / 2 - startCx, + result.y + result.height / 2 - startCy, + ), + shift, + ); + } + }); +}); + +describe('resizeCursorForHandle', () => { + it('회전 0은 기본 커서와 같다', () => { + expect(resizeCursorForHandle(1, 0, 0)).toBe('ew-resize'); + expect(resizeCursorForHandle(0, 1, 0)).toBe('ns-resize'); + expect(resizeCursorForHandle(1, 1, 0)).toBe('nwse-resize'); + expect(resizeCursorForHandle(1, -1, 0)).toBe('nesw-resize'); + expect(resizeCursorForHandle(-1, -1, 0)).toBe('nwse-resize'); + }); + + it('90° 회전하면 가로·세로 커서가 서로 바뀐다', () => { + expect(resizeCursorForHandle(1, 0, 90)).toBe('ns-resize'); + expect(resizeCursorForHandle(0, 1, 90)).toBe('ew-resize'); + expect(resizeCursorForHandle(1, 1, 90)).toBe('nesw-resize'); + }); + + it('45° 회전하면 변 핸들이 대각 커서가 된다', () => { + expect(resizeCursorForHandle(1, 0, 45)).toBe('nwse-resize'); + expect(resizeCursorForHandle(0, 1, 45)).toBe('nesw-resize'); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/rotatedResize.ts b/src/renderer/components/main/Grid/handles/rotatedResize.ts new file mode 100644 index 000000000..d98051a76 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/rotatedResize.ts @@ -0,0 +1,102 @@ +import { rotatePointAround } from '@utils/element/rotation'; +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; +import type { CursorType } from '@utils/grid/cursorUtils'; +import type { Bounds } from './groupResizeUtils'; +import { isBoundsTransitionWithinEditorLimits } from './resizeLimits'; + +// 회전한 요소의 리사이즈. 핸들·드래그는 요소 로컬 축(회전한 틀)을 따르고 +// 저장은 축 정렬 논리 상자로 남긴다 + +// 화면 이동량을 요소 로컬 축 이동량으로 +export const screenDeltaToLocal = ( + deltaX: number, + deltaY: number, + rotation: number, +): { x: number; y: number } => + rotatePointAround({ x: deltaX, y: deltaY }, ORIGIN, -rotation); + +const ORIGIN = Object.freeze({ x: 0, y: 0 }); + +// 로컬 프레임에서 반대 변을 고정하고 계산한 상자를, 화면에서도 그 변이 고정되도록 +// 논리 상자로 되돌린다. 중심 이동량만 회전시키면 되고 크기는 그대로다 +export const anchorRotatedResize = ( + start: Bounds, + local: Bounds, + rotation: number, +): Bounds => { + if (rotation === 0) return local; + const startCx = start.x + start.width / 2; + const startCy = start.y + start.height / 2; + const shift = rotatePointAround( + { + x: local.x + local.width / 2 - startCx, + y: local.y + local.height / 2 - startCy, + }, + ORIGIN, + rotation, + ); + return { + x: startCx + shift.x - local.width / 2, + y: startCy + shift.y - local.height / 2, + width: local.width, + height: local.height, + }; +}; + +// 회전 뒤 새로 움직이는 저장 좌표도 제한하면서 반대 변의 앵커를 보존한다 +export const constrainRotatedResize = ( + start: Bounds, + candidate: Bounds, +): Bounds => { + if (isBoundsTransitionWithinEditorLimits(start, candidate)) return candidate; + const { maxDimension, maxAbsCoordinate } = EDITOR_BOUNDS_LIMITS; + let fraction = 1; + for (const field of ['x', 'y', 'width', 'height'] as const) { + const before = start[field]; + const after = candidate[field]; + if (!Number.isFinite(before) || !Number.isFinite(after)) + return { ...start }; + const coordinate = field === 'x' || field === 'y'; + const limit = Math.max( + coordinate ? maxAbsCoordinate : maxDimension, + Math.abs(before), + ); + const lower = coordinate ? -limit : 0; + if (after > limit) + fraction = Math.min(fraction, (limit - before) / (after - before)); + if (after < lower) + fraction = Math.min(fraction, (lower - before) / (after - before)); + } + fraction = Math.max(0, fraction); + for (let attempt = 0; attempt < 8 && fraction > 0; attempt += 1) { + const result = { + x: start.x + (candidate.x - start.x) * fraction, + y: start.y + (candidate.y - start.y) * fraction, + width: start.width + (candidate.width - start.width) * fraction, + height: start.height + (candidate.height - start.height) * fraction, + }; + if (isBoundsTransitionWithinEditorLimits(start, result)) return result; + fraction *= 1 - 2 ** -20; + } + return { ...start }; +}; + +const RESIZE_CURSORS: readonly CursorType[] = [ + 'ew-resize', + 'nwse-resize', + 'ns-resize', + 'nesw-resize', +]; + +// 핸들 방향(dx, dy)을 회전시킨 화면 각도로 커서를 고른다. 45° 단위 4종 +export const resizeCursorForHandle = ( + dx: number, + dy: number, + rotation: number, +): CursorType => { + const base = (Math.atan2(dy, dx) * 180) / Math.PI; + let angle = (base + rotation) % 180; + if (angle < 0) angle += 180; + const index = Math.round(angle / 45) % 4; + return RESIZE_CURSORS[index]; +}; diff --git a/src/renderer/components/main/Grid/handles/rotationCompilerGuard.test.ts b/src/renderer/components/main/Grid/handles/rotationCompilerGuard.test.ts new file mode 100644 index 000000000..ae1fe0ea9 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/rotationCompilerGuard.test.ts @@ -0,0 +1,40 @@ +/** + * React Compiler 회귀 가드 - 회전 핸들·패널 입력은 컴파일 대상으로 남고, + * 프리뷰 모듈 상태를 읽는 선택 틀 훅만 'use no memo'로 제외를 명시한다 + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { transformSync } from '@babel/core'; +import { describe, expect, it } from 'vitest'; + +const compile = (relativePath: string): string => { + const filename = resolve(process.cwd(), relativePath); + const result = transformSync(readFileSync(filename, 'utf8'), { + filename, + configFile: false, + babelrc: false, + parserOpts: { plugins: ['jsx', 'typescript'] }, + plugins: [['babel-plugin-react-compiler', {}]], + }); + return result?.code ?? ''; +}; + +const COMPILED = [ + 'src/renderer/components/main/Grid/handles/CanvasRotateHandle.tsx', + 'src/renderer/components/main/Grid/PropertiesPanel/batch/geometry/SelectionRotationInput.tsx', +]; + +describe('요소 회전 컴파일러 가드', () => { + it.each(COMPILED)('%s는 컴파일 대상으로 남는다', (path) => { + expect(compile(path)).toContain('react/compiler-runtime'); + }); + + it("useSelectionRotationFrame은 'use no memo'로 제외를 명시한다", () => { + const path = + 'src/renderer/hooks/Grid/selection/useSelectionRotationFrame.ts'; + expect(readFileSync(resolve(process.cwd(), path), 'utf8')).toMatch( + /^'use no memo';/, + ); + expect(compile(path)).not.toContain('react/compiler-runtime'); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/selectionOutline.ts b/src/renderer/components/main/Grid/handles/selectionOutline.ts new file mode 100644 index 000000000..ddb2a4b15 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/selectionOutline.ts @@ -0,0 +1,4 @@ +// 선택 테두리 규격 - 상자 바깥쪽으로 2px를 두르고 선 중심은 가장자리에서 1px 바깥이다. +// 테두리·리사이즈 핸들·스프라이트 기준점 표식이 같은 프레임에 놓이도록 한 곳에서 정한다 +export const SELECTION_BORDER_WIDTH = 2; +export const SELECTION_BORDER_CENTER = SELECTION_BORDER_WIDTH / 2; diff --git a/src/renderer/components/main/Grid/handles/selectionRotationGesture.test.ts b/src/renderer/components/main/Grid/handles/selectionRotationGesture.test.ts new file mode 100644 index 000000000..2d9999abc --- /dev/null +++ b/src/renderer/components/main/Grid/handles/selectionRotationGesture.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + CanonicalEditorDocumentV1, + EditorCommitRequest, + EditorCommitResult, +} from '@src/types/editor'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; +import type { SelectionRotationFrame } from './selectionRotationGesture'; + +const transport = vi.hoisted(() => { + let resolveCommit!: (result: EditorCommitResult) => void; + return { + get: vi.fn(), + commit: vi.fn( + (_request: EditorCommitRequest) => + new Promise((resolve) => { + resolveCommit = resolve; + }), + ), + onCommitted: vi.fn(() => + Object.assign(() => {}, { ready: Promise.resolve() }), + ), + resolve: (result: EditorCommitResult) => resolveCommit(result), + subscribe: vi.fn(async () => 1), + publish: vi.fn(async () => {}), + cancel: vi.fn(async () => {}), + }; +}); + +vi.mock('@api/modules/editor/editorApi', () => ({ editorApi: transport })); +vi.mock('@api/modules/editor/previewApi', () => ({ previewApi: transport })); + +const MODE = '4key'; +const IDS = [ + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', +]; +const TARGETS = IDS.map((id) => ({ type: 'key' as const, id })); +const initialDocument = (): CanonicalEditorDocumentV1 => ({ + schemaVersion: 1, + keys: { [MODE]: ['A', 'B'] }, + keyPositions: { + [MODE]: [ + { ...createDefaultKeyPosition(0, 0), id: IDS[0], rotation: 0 }, + { ...createDefaultKeyPosition(150, 40), id: IDS[1], rotation: 30 }, + ], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: {}, + layerGroups: {}, +}); +const loadHarness = async () => { + const [state, gesture, preview, geometry, session, keys, selection] = + await Promise.all([ + import('@src/renderer/editor/runtime/coordinator/editorStateCoordinator'), + import('@src/renderer/editor/runtime/gesture/editGestureController'), + import('@src/renderer/editor/runtime/gesture/previewOverlay'), + import('@utils/element/selectionRotation'), + import('./selectionRotationGesture'), + import('@stores/data/useKeyStore'), + import('@stores/grid/useGridSelectionStore'), + ]); + return { + ...state, + ...gesture, + ...preview, + ...geometry, + ...session, + ...keys, + ...selection, + }; +}; +type Harness = Awaited>; +let harness: Harness; + +const displayedDocument = (): CanonicalEditorDocumentV1 => { + const document = harness.captureEditorDocument(); + return { + ...document, + keyPositions: harness.composePreviewPositions( + 'keyPosition', + document.keyPositions, + ), + }; +}; +const frame = ( + rotation = 0, + bounds?: SelectionRotationFrame['bounds'], +): SelectionRotationFrame => { + const snapshot = harness.createSelectionRotationSnapshot( + displayedDocument(), + TARGETS, + MODE, + )!; + return { + snapshot, + rotation, + bounds: bounds ?? snapshot.bounds, + selectionKey: 'same-selection', + }; +}; +const requestAt = async (count: number) => { + await vi.waitFor(() => expect(transport.commit).toHaveBeenCalledTimes(count)); + return transport.commit.mock.calls[count - 1][0]; +}; + +describe('선택 회전 입력 전환', () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + window.__dmn_window_type = 'main'; + transport.get.mockResolvedValue({ + revision: 0, + document: initialDocument(), + }); + harness = await loadHarness(); + harness.useKeyStore.setState({ selectedKeyType: MODE }); + harness.useGridSelectionStore.setState({ selectedElements: TARGETS }); + await harness.editorCoordinator.start(); + }); + + afterEach(() => { + harness.editGestureController.cancel(); + harness.editorCoordinator.stop(); + harness.previewOverlay.clearAll(); + vi.restoreAllMocks(); + delete window.__dmn_window_type; + }); + + it('숫자 blur 직후 잡은 손잡이는 화면 기하를 기다렸다가 이어서 회전한다', async () => { + const initial = frame(); + const numeric = harness.createSelectionRotationGesture(initial)!; + expect(numeric.preview(45)).toBe(true); + const shown = frame(45, initial.bounds); + const shownDocument = structuredClone(displayedDocument()); + + // blur와 pointerdown은 같은 이벤트에서 실행되고 커밋 큐는 다음 microtask부터 시작 + numeric.commit(45); + const canvas = harness.createSelectionRotationGesture(shown)!; + expect(harness.captureEditorDocument()).not.toEqual(shownDocument); + expect(canvas.preview(60)).toBe(false); + expect(harness.editGestureController.activeGestureId()).toBeNull(); + + await requestAt(1); + expect(harness.captureEditorDocument()).toEqual(shownDocument); + expect(canvas.preview(60)).toBe(true); + const expected = structuredClone(displayedDocument()); + const secondGestureId = harness.editGestureController.activeGestureId(); + canvas.commit(60); + + transport.resolve({ revision: 1, changedFields: ['keyPositions'] }); + const secondRequest = await requestAt(2); + expect(secondRequest.gestureId).toBe(secondGestureId); + expect(secondRequest.changes!.keyPositions).toEqual(expected.keyPositions); + expect( + secondRequest.changes!.keyPositions![MODE].map( + ({ rotation }) => rotation, + ), + ).toEqual([60, 90]); + transport.resolve({ revision: 2, changedFields: ['keyPositions'] }); + await vi.waitFor(() => + expect(harness.editorCoordinator.getState()).toMatchObject({ + revision: 2, + phase: 'idle', + }), + ); + expect(harness.captureEditorDocument()).toEqual(expected); + }); + + it('화면을 그린 뒤 외부 기하가 바뀌면 오래된 틀로 새 회전을 시작하지 않는다', () => { + const shown = frame(); + const document = structuredClone(harness.captureEditorDocument()); + document.keyPositions[MODE][0].dx += 20; + harness.useKeyStore.setState({ canonicalPositions: document.keyPositions }); + const session = harness.createSelectionRotationGesture(shown)!; + expect(session.preview(45)).toBe(false); + session.commit(45); + expect(harness.editGestureController.activeGestureId()).toBeNull(); + expect(transport.commit).not.toHaveBeenCalled(); + expect(harness.captureEditorDocument()).toEqual(document); + }); + + it('선택 변경으로 취소된 세션의 늦은 move와 up은 다시 저장하지 않는다', () => { + const initial = structuredClone(harness.captureEditorDocument()); + const session = harness.createSelectionRotationGesture(frame())!; + expect(session.preview(30)).toBe(true); + harness.useGridSelectionStore.setState({ selectedElements: [TARGETS[0]] }); + expect(session.preview(60)).toBe(false); + session.commit(60); + expect(transport.commit).not.toHaveBeenCalled(); + expect(harness.captureEditorDocument()).toEqual(initial); + expect(displayedDocument()).toEqual(initial); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/selectionRotationGesture.ts b/src/renderer/components/main/Grid/handles/selectionRotationGesture.ts new file mode 100644 index 000000000..7439b3676 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/selectionRotationGesture.ts @@ -0,0 +1,57 @@ +import { editGestureController } from '@src/renderer/editor/runtime/gesture/editGestureController'; +import { + commitSelectionRotation, + previewSelectionRotation, +} from '@src/renderer/editor/runtime/operations/selectionRotationOps'; +import { wrapDegrees } from '@utils/element/rotation'; +import type { useSelectionRotationFrame } from '@hooks/Grid/selection/useSelectionRotationFrame'; +import type { CanvasRotationSession } from './CanvasRotateHandle'; + +export type SelectionRotationFrame = NonNullable< + ReturnType +>; + +export const createSelectionRotationGesture = ( + frame: SelectionRotationFrame, +): CanvasRotationSession | null => { + // 비대칭 배치도 연속 회전 중 같은 선택틀 중심을 사용 + const snapshot = { + ...structuredClone(frame.snapshot), + center: { + x: frame.bounds.x + frame.bounds.width / 2, + y: frame.bounds.y + frame.bounds.height / 2, + }, + }; + let gestureId: string | undefined; + return { + preview: (rotation) => { + if (gestureId && editGestureController.activeGestureId() !== gestureId) { + return false; + } + const applied = previewSelectionRotation( + snapshot, + wrapDegrees(rotation - frame.rotation), + ); + if (applied) gestureId = editGestureController.activeGestureId(); + return applied; + }, + commit: (rotation) => { + if (!gestureId || editGestureController.activeGestureId() !== gestureId) + return; + const persisted = commitSelectionRotation( + snapshot, + wrapDegrees(rotation - frame.rotation), + { gestureId }, + ); + editGestureController.settleCommit(persisted); + void persisted.catch((error) => { + console.error('Failed to rotate selection', error); + }); + }, + cancel: () => { + if (gestureId && editGestureController.activeGestureId() === gestureId) { + editGestureController.cancel(); + } + }, + }; +}; diff --git a/src/renderer/components/main/Grid/handles/useGradientAxisDragSession.ts b/src/renderer/components/main/Grid/handles/useGradientAxisDragSession.ts index 22757b615..9de5d173e 100644 --- a/src/renderer/components/main/Grid/handles/useGradientAxisDragSession.ts +++ b/src/renderer/components/main/Grid/handles/useGradientAxisDragSession.ts @@ -14,6 +14,7 @@ import { type ContinuousInputStrategy, } from '@utils/animation/rafLatestScheduler'; import { beginDragCursor, endDragCursor } from '@utils/dom/dragCursor'; +import { suppressNextClick } from '@utils/dom/suppressNextClick'; import { applyGradientAxisMagnet, buildGradientAxisGeometry, @@ -174,7 +175,14 @@ export const useGradientAxisDragSession = ({ clientX: number, clientY: number, end: GradientAxisEnd, - ): number => gradientAxisPointerAngle(clientX, clientY, end, clientOrigin()); + ): number => + gradientAxisPointerAngle( + clientX, + clientY, + end, + clientOrigin(), + geoRef.current?.rotation ?? 0, + ); const angleFromClient = ( clientX: number, @@ -382,18 +390,6 @@ export const useGradientAxisDragSession = ({ // 창 포커스 상실 - pointerup이 오지 않으므로 유령 드래그 방지 취소 const handleWindowBlur = () => cancelActiveDrag(); - // 드래그 제스처의 후속 click이 그리드 선택 해제로 새지 않게 1회 억제 - const suppressNextClick = () => { - const swallow = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - window.removeEventListener('click', swallow, true); - }; - window.addEventListener('click', swallow, true); - // click이 아예 안 오는 경로(cancel 등) 대비 - 다음 틱에 정리 - setTimeout(() => window.removeEventListener('click', swallow, true), 0); - }; - // 프레스 즉시 grabbing - 키와 같은 정책(호버 무변화, 잡는 동안만 grabbing). // pointerdown preventDefault로 :active가 안 걸리고, 캡처 중에도 커서는 // 히트테스트 기준(크로뮴)이라 문서 전역 클래스로 드래그 내내 고정 diff --git a/src/renderer/components/main/Grid/handles/useGroupResizeSession.ts b/src/renderer/components/main/Grid/handles/useGroupResizeSession.ts index e0ecc81f7..8e225767b 100644 --- a/src/renderer/components/main/Grid/handles/useGroupResizeSession.ts +++ b/src/renderer/components/main/Grid/handles/useGroupResizeSession.ts @@ -17,7 +17,7 @@ import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { calculateGroupResizePlan, - calculateMaxGroupShrink, + calculateMinGroupSize, GROUP_RESIZE_MIN_SIZE, type GroupResizeGuidePlan, type GroupResizeHandle, @@ -30,6 +30,11 @@ import { type ElementBounds, type SelectedElement, } from './groupResizeUtils'; +import { + calculateRotatedGroupResizePlan, + spriteGroupScaleLimit, + type GroupRotationFrame, +} from './rotatedGroupResize'; interface GroupResizeData extends Bounds { elementBounds: ElementBounds[]; @@ -43,9 +48,11 @@ interface ResizeState { startGroupBounds: Bounds | null; startElementBounds: ElementBounds[]; nonResizableElementBounds?: ElementBounds[]; - maxShrinkX: number; - maxShrinkY: number; + minGroupWidth: number; + minGroupHeight: number; handle?: GroupResizeHandle; + rotationFrame?: GroupRotationFrame; + maxSpriteScale?: number; } interface UseGroupResizeSessionOptions { @@ -55,9 +62,11 @@ interface UseGroupResizeSessionOptions { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions?: CanonicalEditorDocumentV1['spritePositions']; selectedKeyType: string; pluginElements: PluginDisplayElementInternal[]; zoom: number; + rotationFrame?: GroupRotationFrame; onGroupResizeStart?: (handle: GroupResizeHandle) => void; onGroupResize?: (result: GroupResizeResult) => void; onGroupResizeEnd?: () => void; @@ -88,9 +97,11 @@ export const useGroupResizeSession = ({ statPositions, graphPositions, knobPositions, + spritePositions, selectedKeyType, pluginElements, zoom, + rotationFrame, onGroupResizeStart, onGroupResize, onGroupResizeEnd, @@ -104,8 +115,8 @@ export const useGroupResizeSession = ({ startMouseY: 0, startGroupBounds: null, startElementBounds: [], - maxShrinkX: 0, - maxShrinkY: 0, + minGroupWidth: 0, + minGroupHeight: 0, }); const activeResizeCleanupRef = useRef<(() => void) | null>(null); @@ -161,19 +172,35 @@ export const useGroupResizeSession = ({ }, startElementBounds: resizableElementBounds, nonResizableElementBounds, - maxShrinkX: calculateMaxGroupShrink( + minGroupWidth: calculateMinGroupSize( resizableElementBounds, groupData.width, 'x', GROUP_RESIZE_MIN_SIZE, ), - maxShrinkY: calculateMaxGroupShrink( + minGroupHeight: calculateMinGroupSize( resizableElementBounds, groupData.height, 'y', GROUP_RESIZE_MIN_SIZE, ), handle, + rotationFrame: rotationFrame + ? { + bounds: { ...rotationFrame.bounds }, + rotation: rotationFrame.rotation, + } + : undefined, + maxSpriteScale: rotationFrame + ? spriteGroupScaleLimit( + (spritePositions?.[selectedKeyType] ?? []).filter((position) => + resizableElementBounds.some( + ({ element }) => + element.type === 'sprite' && element.id === position.id, + ), + ), + ) + : undefined, }; let resizeStarted = false; @@ -188,8 +215,10 @@ export const useGroupResizeSession = ({ startGroupBounds, startElementBounds, nonResizableElementBounds, - maxShrinkX, - maxShrinkY, + minGroupWidth, + minGroupHeight, + rotationFrame: startRotationFrame, + maxSpriteScale, } = resizeRef.current; if (!activeHandle || !startGroupBounds) return; @@ -204,31 +233,42 @@ export const useGroupResizeSession = ({ const sizeMatchGuidesEnabled = gridSettings?.sizeMatchGuides !== false; const selectedIds = selectedElements.map((element) => element.id); - const smartSnap = suppressSmartSnap - ? ({ type: 'suppressed' } as const) - : getOtherElements && alignmentGuidesEnabled - ? ({ - type: 'enabled', - otherElements: getOtherElements(selectedIds), - spacingGuidesEnabled, - sizeMatchGuidesEnabled, - } as const) - : ({ type: 'unchanged' } as const); - const plan = calculateGroupResizePlan({ - handle: activeHandle, - startMouseX, - startMouseY, - pointerX: moveEvent.clientX, - pointerY: moveEvent.clientY, - zoom, - snapSize, - startGroupBounds, - startElementBounds, - nonResizableElementBounds, - maxShrinkX, - maxShrinkY, - smartSnap, - }); + const smartSnap = + suppressSmartSnap || startRotationFrame + ? ({ type: 'suppressed' } as const) + : getOtherElements && alignmentGuidesEnabled + ? ({ + type: 'enabled', + otherElements: getOtherElements(selectedIds), + spacingGuidesEnabled, + sizeMatchGuidesEnabled, + } as const) + : ({ type: 'unchanged' } as const); + const plan = startRotationFrame + ? calculateRotatedGroupResizePlan({ + rotationFrame: startRotationFrame, + handle: activeHandle, + startElementBounds, + deltaX: (moveEvent.clientX - startMouseX) / zoom, + deltaY: (moveEvent.clientY - startMouseY) / zoom, + snapSize, + maxSpriteScale, + }) + : calculateGroupResizePlan({ + handle: activeHandle, + startMouseX, + startMouseY, + pointerX: moveEvent.clientX, + pointerY: moveEvent.clientY, + zoom, + snapSize, + startGroupBounds, + startElementBounds, + nonResizableElementBounds, + minGroupWidth, + minGroupHeight, + smartSnap, + }); applyGuidePlan(smartGuidesStore, plan.guides); diff --git a/src/renderer/components/main/Grid/layers/CounterPreviewBody.tsx b/src/renderer/components/main/Grid/layers/CounterPreviewBody.tsx index f4e07961f..9d86b3aac 100644 --- a/src/renderer/components/main/Grid/layers/CounterPreviewBody.tsx +++ b/src/renderer/components/main/Grid/layers/CounterPreviewBody.tsx @@ -18,6 +18,7 @@ export interface CounterPreviewPosition { width?: number; height?: number; hidden?: boolean; + rotation?: number; counter?: unknown; className?: string; useInlineStyles?: boolean; @@ -77,6 +78,7 @@ const CounterPreviewBody = ({ undefined, 'counterFill', { x: dx, y: dy }, + position.rotation, ); const style = computeOutsideStyle( @@ -86,6 +88,7 @@ const CounterPreviewBody = ({ width, height, counterSettings.gap, + position.rotation, ); const fill = toCssRgba(fillColor, '#FFFFFF'); diff --git a/src/renderer/components/main/Grid/layers/DuplicateGhostHost.tsx b/src/renderer/components/main/Grid/layers/DuplicateGhostHost.tsx new file mode 100644 index 000000000..af355ae59 --- /dev/null +++ b/src/renderer/components/main/Grid/layers/DuplicateGhostHost.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { elementRotationTransform } from '@utils/element/rotation'; + +// 복제 배치 미리보기의 공통 껍데기 - 커서를 중심에 둔 배치, 반투명, 드래그 프리뷰 층. +// 배경·보더·라운딩 같은 표면 표현은 요소 종류마다 달라 호출부가 넘긴다 +interface DuplicateGhostHostProps { + width: number; + height: number; + /** 커서 위치 (그리드 좌표) */ + cursor: { x: number; y: number }; + /** 요소 배치 회전 - 상자 중심 기준 */ + rotation?: number; + surfaceStyle?: React.CSSProperties; + /** 요소의 사용자 클래스 - 커스텀 CSS가 고스트에도 닿게 한다 */ + className?: string; + dataAttributes?: Record; + children?: React.ReactNode; +} + +const DuplicateGhostHost = ({ + width, + height, + cursor, + rotation = 0, + surfaceStyle, + className, + dataAttributes, + children, +}: DuplicateGhostHostProps) => ( +
+ {children} +
+); + +export default DuplicateGhostHost; diff --git a/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx b/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx index 0939e685b..612541709 100644 --- a/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx +++ b/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx @@ -19,20 +19,17 @@ const hookState = vi.hoisted(() => ({ wasMoved: false, pressMoved: false })); // 수식키 판별을 ctrlKey로 고정 vi.mock('@utils/core/platform', () => ({ isMac: () => false })); -vi.mock('@hooks/Grid', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useDraggable: () => ({ - ref: () => {}, - dx: 0, - dy: 0, - wasMoved: hookState.wasMoved, - isDragging: false, - recentPressMovedRef: { current: false }, - }), - }; -}); +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/drag/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: hookState.wasMoved, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); vi.mock('@hooks/Grid/drag/useSelectionDrag', () => ({ useSelectionDrag: () => ({ diff --git a/src/renderer/components/main/Grid/layers/GraphItem.tsx b/src/renderer/components/main/Grid/layers/GraphItem.tsx index ed1f38313..4c45e1118 100644 --- a/src/renderer/components/main/Grid/layers/GraphItem.tsx +++ b/src/renderer/components/main/Grid/layers/GraphItem.tsx @@ -10,6 +10,7 @@ import { interface GraphPosition { hidden?: boolean; + rotation?: number; dx?: number; dy?: number; width?: number; @@ -152,6 +153,7 @@ const GraphItem = ({ ref={attachRef} dx={renderDx} dy={renderDy} + rotation={position.rotation} width={width} height={height} zIndex={position.zIndex ?? zIndex} diff --git a/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx b/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx index d382a3f1e..dd2875f2e 100644 --- a/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx +++ b/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx @@ -19,20 +19,17 @@ const hookState = vi.hoisted(() => ({ wasMoved: false, pressMoved: false })); // 수식키 판별을 ctrlKey로 고정 vi.mock('@utils/core/platform', () => ({ isMac: () => false })); -vi.mock('@hooks/Grid', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useDraggable: () => ({ - ref: () => {}, - dx: 0, - dy: 0, - wasMoved: hookState.wasMoved, - isDragging: false, - recentPressMovedRef: { current: false }, - }), - }; -}); +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/drag/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: hookState.wasMoved, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); vi.mock('@hooks/Grid/drag/useSelectionDrag', () => ({ useSelectionDrag: () => ({ diff --git a/src/renderer/components/main/Grid/layers/KnobItem.tsx b/src/renderer/components/main/Grid/layers/KnobItem.tsx index f000e0b5e..b22045011 100644 --- a/src/renderer/components/main/Grid/layers/KnobItem.tsx +++ b/src/renderer/components/main/Grid/layers/KnobItem.tsx @@ -26,9 +26,11 @@ import { useGridElementInteraction, type GridElementInteractionProps, } from '@hooks/Grid/useGridElementInteraction'; +import { elementRotationTransform } from '@utils/element/rotation'; interface KnobPosition { hidden?: boolean; + rotation?: number; dx?: number; dy?: number; width?: number; @@ -244,7 +246,9 @@ const KnobItem = ({ }); if (position?.hidden) return null; - const transform = `translate(calc(${renderDx}px + var(--key-offset-x, 0px)), calc(${renderDy}px + var(--key-offset-y, 0px)))`; + const transform = `translate(calc(${renderDx}px + var(--key-offset-x, 0px)), calc(${renderDy}px + var(--key-offset-y, 0px)))${elementRotationTransform( + position.rotation, + )}`; return (
= {}, +): ReactiveSpritePosition => + ({ + id: 'ghost-sprite', + dx: 0, + dy: 0, + width: 200, + height: 120, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: 'hand.png', + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + pressDurationMs: 300, + transitionMs: 90, + transitionEasing: 'linear', + referenceNaturalSize: null, + ...overrides, + } as ReactiveSpritePosition); + +let container: HTMLDivElement; +let root: Root; + +const renderGhost = (zoom = 1) => { + act(() => { + root.render( + , + ); + }); + return container.querySelector('[data-sprite-ghost="true"]'); +}; + +describe('SpriteDuplicateGhost', () => { + it('배치 회전은 커서 중심의 고스트 상자에만 적용한다', () => { + act(() => + root.render( + , + ), + ); + const ghost = container.querySelector( + '[data-sprite-ghost="true"]', + )!; + expect(ghost.style.transform).toBe( + 'translate3d(200px, 140px, 0) rotate(90deg)', + ); + expect(ghost.style.transformOrigin).toBe('50% 50%'); + expect( + ghost + .querySelector('img')! + .style.getPropertyValue('--dmn-sprite-transform-default'), + ).toBe('translate(4px, -2px) rotate(-179deg) scale(1.2)'); + expect(ghost.querySelector('[data-sprite-element] > img')).not.toBeNull(); + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('이미지 호스트에 보더를 두지 않아 놓는 순간 이미지가 튀지 않는다', () => { + // 보더가 있으면 absolute 이미지가 padding box 기준으로 밀려 + // 확정된 SpriteItem 위치와 어긋난다 + const ghost = renderGhost(); + expect(ghost).not.toBeNull(); + expect(ghost?.style.borderWidth).toBe(''); + expect(ghost?.style.borderTopStyle).toBe(''); + }); + + it('활동 영역 점선은 별도 층에 그리고 굵기를 줌으로 보정한다', () => { + renderGhost(4); + const guide = container.querySelector( + '[data-sprite-activity-guide="true"]', + ); + expect(guide?.style.borderTopStyle).toBe('dashed'); + expect(guide?.style.borderWidth).toBe('0.25px'); + expect(guide?.style.borderRadius).toBe('1px'); + // 아이템 가이드와 같은 토큰을 쓴다 + expect(guide?.style.borderColor).toBe(ACTIVITY_AREA_GUIDE_COLOR); + }); + + it('이미지는 활동 영역 상자를 그대로 채운다 (아이템과 같은 규칙)', () => { + const image = renderGhost()?.querySelector('img'); + expect(image?.style.left).toBe('0px'); + expect(image?.style.top).toBe('0px'); + expect(image?.style.width).toBe('200px'); + expect(image?.style.height).toBe('120px'); + }); + + it('이미지 로드 실패는 깨진 img 대신 아이템과 같은 자리표시자를 그린다', () => { + const ghost = renderGhost(); + const image = ghost?.querySelector('img'); + expect(image).not.toBeNull(); + + act(() => { + image!.dispatchEvent(new Event('error')); + }); + expect(ghost?.querySelector('img')).toBeNull(); + expect( + ghost?.querySelector('[data-sprite-placeholder="true"]'), + ).not.toBeNull(); + }); + + it('기본 이미지가 없으면 자리표시자를 그린다', () => { + act(() => { + root.render( + , + ); + }); + expect(container.querySelector('img')).toBeNull(); + expect( + container.querySelector('[data-sprite-placeholder="true"]'), + ).not.toBeNull(); + }); + + it('사용자 클래스와 [data-sprite-element] 층을 아이템과 같게 둬 커스텀 CSS가 닿는다', () => { + act(() => { + root.render( + , + ); + }); + const ghost = container.querySelector( + '[data-sprite-ghost="true"]', + ); + expect(ghost?.classList.contains('left-hand')).toBe(true); + // 문서의 `.클래스 [data-sprite-element] > img` 선택자가 고스트에서도 성립한다 + const image = container.querySelector( + '.left-hand [data-sprite-element] > img', + ); + expect(image).not.toBeNull(); + const layer = ghost?.querySelector('[data-sprite-element]'); + expect(layer?.style.position).toBe('relative'); + expect(layer?.style.width).toBe('100%'); + expect(layer?.style.height).toBe('100%'); + }); + + it('기본 모드는 transform을 변수로만 실어 사용자 --sprite-transform이 이긴다', () => { + act(() => { + root.render( + , + ); + }); + const image = container.querySelector('img')!; + expect(image.style.transform).toBe(''); + expect(image.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + 'translate(4px, 0px) rotate(15deg) scale(1.2)', + ); + }); + + it('useInlineStyles=true는 아이템처럼 인라인 transform으로 승격한다', () => { + act(() => { + root.render( + , + ); + }); + const image = container.querySelector('img')!; + expect(image.style.transform).toBe( + 'translate(4px, 0px) rotate(15deg) scale(1.2)', + ); + }); + + it('클래스가 없으면 호스트 클래스에 빈 조각을 남기지 않는다', () => { + const ghost = renderGhost(); + expect(ghost?.className.includes('undefined')).toBe(false); + expect(ghost?.className.includes('null')).toBe(false); + }); +}); diff --git a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx new file mode 100644 index 000000000..a97e456a9 --- /dev/null +++ b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx @@ -0,0 +1,100 @@ +import DuplicateGhostHost from './DuplicateGhostHost'; +import { resolveImageSource } from '@utils/media/imageSource'; +import { + isErrorForCurrentSrc, + useFailedImageSrcs, +} from '@hooks/overlay/useFailedImageSrcs'; +import SpriteImagePlaceholder from '@components/main/common/SpriteImagePlaceholder'; +import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; +import { + placeSpriteVisual, + spriteIdleVisual, +} from '@utils/sprite/spritePlacement'; +import { + ACTIVITY_AREA_GUIDE_COLOR, + activityAreaGuideMetrics, +} from '@utils/grid/activityAreaGuide'; +import { DEFAULT_SPRITE_SIZE } from '@src/types/key/sprites'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; + +interface SpriteDuplicateGhostProps { + position: ReactiveSpritePosition; + /** 커서 위치 (그리드 좌표) - 고스트는 커서를 중심에 둔다 */ + cursor: { x: number; y: number }; + zoom: number; +} + +// 복제 배치 미리보기. 이미지 원점 규칙을 SpriteItem과 똑같이 맞춘다 - +// 호스트에 보더를 두면 안쪽 이미지가 밀려 놓는 순간 위치가 튄다. +// 사용자 클래스와 [data-sprite-element] 층도 아이템과 같아야 커스텀 CSS가 +// 고스트에 닿아 놓는 순간 외형이 바뀌지 않는다 +const SpriteDuplicateGhost = ({ + position, + cursor, + zoom, +}: SpriteDuplicateGhostProps) => { + const width = position.width || DEFAULT_SPRITE_SIZE; + const height = position.height || DEFAULT_SPRITE_SIZE; + const baseSrc = resolveImageSource(position.baseImage); + // 이미지가 없거나 유실됐으면 아이템과 같은 자리표시자 - 놓는 순간 보일 것과 같게 + const { failedImageSrcs, markFailed } = useFailedImageSrcs( + position.baseImage, + ); + const ghostImage = baseSrc && !failedImageSrcs.has(baseSrc) ? baseSrc : null; + + return ( + +
+ {ghostImage ? ( + { + if (!isErrorForCurrentSrc(event.currentTarget, ghostImage)) { + return; + } + markFailed(ghostImage); + }} + /> + ) : ( + + )} +
+
+ + ); +}; + +export default SpriteDuplicateGhost; diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx new file mode 100644 index 000000000..a7fe7b84a --- /dev/null +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -0,0 +1,654 @@ +/** + * SpriteItem 캔버스 렌더 계약 테스트 + * 활동 영역 테두리는 선택 시 선택 색으로 바뀌고, hidden이면 렌더하지 않는다 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import SpriteItem from '@components/main/Grid/layers/SpriteItem'; +import { useSpriteEditPreviewStore } from '@stores/grid/useSpriteEditPreviewStore'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; +import type { SpritePose } from '@src/types/key/sprites'; +import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@utils/core/platform', () => ({ isMac: () => false })); + +const selectionPointerDown = vi.hoisted(() => vi.fn()); + +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/drag/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: false, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); + +vi.mock('@hooks/Grid/drag/useSelectionDrag', () => ({ + useSelectionDrag: () => ({ + handlePointerDown: selectionPointerDown, + movedDuringPressRef: { current: false }, + pressMovedRef: { current: false }, + }), +})); + +const SPRITE_ID = '00000000-0000-4000-8000-000000000801'; + +const spritePosition = ( + overrides: Partial = {}, +): CanonicalReactiveSpritePosition => + makeCanonicalSpritePosition({ + id: SPRITE_ID, + dx: 10, + dy: 20, + layerName: null, + groupId: null, + ...overrides, + }); + +let container: HTMLDivElement; +let root: Root; + +const renderSprite = ( + position: CanonicalReactiveSpritePosition, + options: Partial< + Pick< + React.ComponentProps, + | 'isSelected' + | 'zoom' + | 'onClick' + | 'onContextMenu' + | 'onCtrlClick' + | 'onShiftClick' + > + > = {}, +) => { + act(() => { + root.render( + {}} + activeTool="move" + {...options} + />, + ); + }); + return container.querySelector('[data-sprite-element="true"]'); +}; + +const idleGhost = () => + container.querySelector('[data-sprite-idle-ghost="true"]'); + +describe('SpriteItem 렌더', () => { + it('배치 각도를 바꿔도 이미지의 로컬 자세와 CSS 채널은 그대로 둔다', () => { + const position = spritePosition({ + baseImage: 'hand.png', + idleTransform: { x: 4, y: -2, rotation: 179, scale: 1.5 }, + }); + const surface = renderSprite(position)!; + const imageStyle = surface.querySelector('img')!.getAttribute('style'); + for (const rotation of [179, -179, 180, -180, 0]) { + const updated = renderSprite({ ...position, rotation })!; + expect(updated.querySelector('img')!.getAttribute('style')).toBe( + imageStyle, + ); + const layout = updated.parentElement!; + expect(layout.style.transformOrigin).toBe('50% 50%'); + expect(layout.style.transform.endsWith(`rotate(${rotation}deg)`)).toBe( + rotation !== 0, + ); + expect(updated.matches('[data-sprite-element]')).toBe(true); + } + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + useSpriteEditPreviewStore.setState({ preview: null }); + }); + + it('이미지가 없으면 자리표시자만 그리고 선택 표식은 공통 아웃라인이 맡는다', () => { + const idle = renderSprite(spritePosition()); + expect(idle).not.toBeNull(); + expect(idle?.dataset.selected).toBeUndefined(); + expect( + idle?.querySelector('[data-sprite-placeholder="true"]'), + ).not.toBeNull(); + // 상자가 곧 이미지라 별도 가이드·표식이 없다 - 핸들은 오버레이 층 몫 + expect( + idle?.querySelector('[data-sprite-activity-guide="true"]'), + ).toBeNull(); + expect(idle?.querySelector('[data-sprite-pivot-marker="true"]')).toBeNull(); + + const selected = renderSprite(spritePosition(), { isSelected: true }); + expect(selected?.dataset.selected).toBe('true'); + }); + + it('이미지 호스트는 보더가 없어 오버레이와 같은 원점을 쓴다', () => { + // 보더가 있으면 absolute 이미지가 padding box 기준으로 1px 밀린다 + const host = renderSprite(spritePosition({ baseImage: 'hand.png' })); + expect(host?.style.borderWidth).toBe(''); + expect(host?.className).not.toContain('border'); + }); + + it('기본 이미지는 요소 상자를 그대로 채운다', () => { + const host = renderSprite( + spritePosition({ + baseImage: 'hand.png', + width: 120, + height: 80, + pivot: { x: 0.25, y: 1 }, + referenceNaturalSize: { source: 'hand.png', width: 60, height: 40 }, + }), + ); + const img = host?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + expect(img?.style.left).toBe('0px'); + expect(img?.style.top).toBe('0px'); + expect(img?.style.width).toBe('120px'); + expect(img?.style.height).toBe('80px'); + expect(img?.style.transformOrigin).toBe('25% 100%'); + }); + + it('상자 밖으로 변환된 실제 그림의 클릭과 우클릭은 요소로 전달한다', () => { + const onClick = vi.fn(); + const onContextMenu = vi.fn(); + const onCtrlClick = vi.fn(); + const onShiftClick = vi.fn(); + const onGridContextMenu = vi.fn(); + const host = renderSprite( + spritePosition({ + baseImage: 'sprite.png', + width: 120, + height: 80, + rotation: 45, + pivot: { x: 0.25, y: 0.75 }, + idleTransform: { x: 15, y: 0, rotation: 170, scale: 1 }, + useInlineStyles: true, + }), + { onClick, onContextMenu, onCtrlClick, onShiftClick }, + )!; + const img = host.querySelector('img')!; + expect(img.style.pointerEvents).toBe('auto'); + expect(img.style.transform).toBe( + 'translate(15px, 0px) rotate(170deg) scale(1)', + ); + expect(img.style.transformOrigin).toBe('25% 75%'); + + document.body.addEventListener('contextmenu', onGridContextMenu); + const contextmenu = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + button: 2, + }); + try { + act(() => { + img.dispatchEvent(new MouseEvent('click', { bubbles: true })); + img.dispatchEvent( + new MouseEvent('click', { bubbles: true, ctrlKey: true }), + ); + img.dispatchEvent( + new MouseEvent('click', { bubbles: true, shiftKey: true }), + ); + img.dispatchEvent(contextmenu); + }); + } finally { + document.body.removeEventListener('contextmenu', onGridContextMenu); + } + expect(onClick).toHaveBeenCalledTimes(1); + expect(onCtrlClick).toHaveBeenCalledTimes(1); + expect(onShiftClick).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(contextmenu.defaultPrevented).toBe(true); + expect(onGridContextMenu).not.toHaveBeenCalled(); + }); + + it('선택한 그림의 press는 요소 드래그로 전달하고 브라우저 이미지 드래그는 막는다', () => { + selectionPointerDown.mockClear(); + const host = renderSprite(spritePosition({ baseImage: 'sprite.png' }), { + isSelected: true, + })!; + const img = host.querySelector('img')!; + expect(img.style.pointerEvents).toBe('auto'); + expect(img.draggable).toBe(false); + + const dragstart = new Event('dragstart', { + bubbles: true, + cancelable: true, + }); + act(() => { + img.dispatchEvent(new Event('pointerdown', { bubbles: true })); + img.dispatchEvent(dragstart); + }); + expect(selectionPointerDown).toHaveBeenCalledTimes(1); + expect(selectionPointerDown.mock.calls[0][0].target).toBe(img); + expect(dragstart.defaultPrevented).toBe(true); + }); + + it('hidden 스프라이트는 렌더하지 않는다', () => { + const node = renderSprite(spritePosition({ hidden: true })); + expect(node).toBeNull(); + }); + + it('useInlineStyles=false는 이미지 외관 채널을 변수로만 싣는다', () => { + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: false, + idleTransform: { x: 4, y: -2, rotation: 30, scale: 1.5 }, + }), + ); + + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + // 인라인 선언이 비어야 사용자 CSS가 !important 없이 이긴다 + expect(img?.style.transform).toBe(''); + expect(img?.style.objectFit).toBe(''); + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + 'translate(4px, -2px) rotate(30deg) scale(1.5)', + ); + // 정적 idle 렌더라 transition 채널 없음 + expect(img?.style.getPropertyValue('--dmn-sprite-transition-default')).toBe( + '', + ); + // 배치·기준점은 모드와 무관하게 인라인 + expect(img?.style.width).toBe('200px'); + expect(img?.style.transformOrigin).toBe('50% 50%'); + }); + + it('useInlineStyles=true는 이미지 외관 채널을 인라인 선언으로 승격한다', () => { + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + idleTransform: { x: 4, y: -2, rotation: 30, scale: 1.5 }, + }), + ); + + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + expect(img?.style.transform).toBe( + 'translate(4px, -2px) rotate(30deg) scale(1.5)', + ); + // 상자가 비트맵 비율이라 늘리기가 곧 맞춤 - 인라인 모드는 fill로 승격 + expect(img?.style.objectFit).toBe('fill'); + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + '', + ); + }); +}); + +describe('SpriteItem 자세 편집 프리뷰', () => { + const pose = ( + poseId: string, + overrides: Partial = {}, + ): SpritePose => ({ + imageOverrideMetrics: null, + poseId, + triggers: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], + transform: { x: 10, y: 5, rotation: 45, scale: 2 }, + imageOverride: null, + ...overrides, + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + useSpriteEditPreviewStore.setState({ preview: null }); + }); + + it('무효 상태의 기준 이미지 크기는 캔버스 배율에 반영되고 최신 기준 크기를 덮지 않는다', () => { + const previewPose = pose('pose-1', { + imageOverride: 'pose.png', + imageOverrideMetrics: { source: 'pose.png', width: 32, height: 32 }, + }); + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: previewPose.poseId, + fallbackPose: previewPose, + preferFallback: true, + referenceNaturalSize: { source: null, width: 64, height: 32 }, + }), + ); + const position = spritePosition({ width: 200, height: 150 }); + let host = renderSprite(position); + let img = host?.querySelector('img'); + expect(img?.style.width).toBe('100px'); + expect(img?.style.height).toBe('150px'); + + host = renderSprite({ + ...position, + referenceNaturalSize: { source: null, width: 32, height: 32 }, + }); + img = host?.querySelector('img'); + expect(img?.style.width).toBe('200px'); + expect(img?.style.height).toBe('150px'); + }); + + it('발행된 자세는 composed poses의 최신 값이 스냅샷보다 우선한다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { + transform: { x: 99, y: 99, rotation: 0, scale: 1 }, + }), + preferFallback: false, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + poses: [ + pose('pose-1', { + imageOverride: 'data:image/png;base64,override', + }), + ], + }), + ); + + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + expect(img?.style.transform).toBe( + 'translate(10px, 5px) rotate(45deg) scale(2)', + ); + expect(img?.getAttribute('src')).toContain('override'); + }); + + it('자세 편집 중에는 기본 자세를 반투명 고스트로 뒤에 남긴다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1'), + preferFallback: false, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + poses: [pose('pose-1')], + }), + ); + + const ghost = idleGhost(); + expect(ghost).not.toBeNull(); + expect(ghost?.getAttribute('src')).toContain('base'); + expect(ghost?.style.opacity).toBe('0.3'); + expect(ghost?.style.pointerEvents).toBe('none'); + expect( + node?.querySelector('img:not([data-sprite-idle-ghost])') + ?.style.pointerEvents, + ).toBe('auto'); + // 고스트는 기본 자세 그대로, 실제 이미지는 자세 변환 + expect(ghost?.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + expect(node?.querySelectorAll('img').length).toBe(2); + + // 발행을 거두면 고스트도 사라진다 + act(() => useSpriteEditPreviewStore.getState().clear()); + expect(idleGhost()).toBeNull(); + }); + + it('기본 자세와 같은 그림이면 고스트를 겹쳐 그리지 않는다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + }), + preferFallback: true, + }), + ); + renderSprite(spritePosition({ baseImage: 'data:image/png;base64,base' })); + expect(idleGhost()).toBeNull(); + }); + + it('크기가 다른 자세 이미지는 같은 기준점을 축에 맞춰 원본 비율로 그린다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: 'data:image/png;base64,override', + imageOverrideMetrics: { + source: 'data:image/png;base64,override', + width: 50, + height: 200, + }, + }), + preferFallback: true, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + width: 200, + height: 200, + pivot: { x: 0.5, y: 1 }, + referenceNaturalSize: { + source: 'data:image/png;base64,base', + width: 100, + height: 100, + }, + }), + ); + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + // 배율 2 → 100x400, 기준점(0.5, 1)이 P(100, 200)에 놓인다 → left 50, top -200 + expect(img?.getAttribute('src')).toContain('override'); + expect((img as HTMLElement | null)?.style.width).toBe('100px'); + expect((img as HTMLElement | null)?.style.height).toBe('400px'); + expect((img as HTMLElement | null)?.style.left).toBe('50px'); + expect((img as HTMLElement | null)?.style.top).toBe('-200px'); + }); + + it('무효 draft 편집은 canonical 값 대신 스냅샷을 우선한다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { + triggers: [], + transform: { x: 3, y: 4, rotation: 0, scale: 1 }, + }), + preferFallback: true, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + poses: [pose('pose-1')], + }), + ); + + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + expect(img?.style.transform).toBe( + 'translate(3px, 4px) rotate(0deg) scale(1)', + ); + }); + + it('canonical에 없는 draft 자세는 발행 스냅샷으로 그리고 이미지는 base로 남는다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'draft-1', + fallbackPose: pose('draft-1', { + triggers: [], + transform: { x: 7, y: 0, rotation: 0, scale: 1 }, + }), + preferFallback: false, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + poses: [], + }), + ); + + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + expect(img?.style.transform).toBe( + 'translate(7px, 0px) rotate(0deg) scale(1)', + ); + expect(img?.getAttribute('src')).toContain('base64,base'); + }); + + it('다른 스프라이트의 발행은 무시하고 회수하면 대기 상태로 복귀한다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: '00000000-0000-4000-8000-000000000999', + poseId: 'pose-1', + fallbackPose: pose('pose-1'), + preferFallback: false, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + idleTransform: { x: 1, y: 2, rotation: 0, scale: 1 }, + }), + ); + const img = () => + node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + expect(img()?.style.transform).toBe( + 'translate(1px, 2px) rotate(0deg) scale(1)', + ); + + // 자기 발행으로 전환 후 회수하면 대기 상태로 복귀 + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1'), + preferFallback: false, + }), + ); + expect(img()?.style.transform).toBe( + 'translate(10px, 5px) rotate(45deg) scale(2)', + ); + act(() => useSpriteEditPreviewStore.getState().clear()); + expect(img()?.style.transform).toBe( + 'translate(1px, 2px) rotate(0deg) scale(1)', + ); + }); + + // 캔버스가 깨진 아이콘을 그리면 송출 화면과 미리보기가 갈린다. + // 오버레이는 같은 상황에서 base 폴백 후 이미지 없음으로 내려간다 + it('실패한 이미지는 기본 이미지로 폴백하고 둘 다 실패하면 자리표시자로 간다', () => { + const node = renderSprite( + spritePosition({ + baseImage: '/base.png', + poses: [pose('pose-1', { imageOverride: '/override.png' })], + }), + ); + const img = () => + node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { imageOverride: '/override.png' }), + preferFallback: true, + }), + ); + expect(img()?.getAttribute('src')).toBe('/override.png'); + + act(() => { + img()?.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(img()?.getAttribute('src')).toBe('/base.png'); + + act(() => { + img()?.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(img()).toBeNull(); + // 이미지가 없는 상태이므로 자리표시자와 상시 가이드로 함께 내려간다 + expect( + node?.querySelector('[data-sprite-placeholder="true"]'), + ).not.toBeNull(); + }); + + // 스키마가 빈 문자열을 막지 않아 플러그인·임포트로 들어온다. + // 해석기는 공백 override를 이미지 없음으로 보고 base를 쓰므로, 프리뷰가 + // 여기서 자리표시자로 내려가면 캔버스와 송출 화면이 갈린다 + it('공백뿐인 override 자세를 프리뷰해도 기본 이미지를 그린다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { imageOverride: ' ' }), + preferFallback: true, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: '/base.png', + poses: [pose('pose-1', { imageOverride: ' ' })], + }), + ); + + expect( + node + ?.querySelector('img:not([data-sprite-idle-ghost])') + ?.getAttribute('src'), + ).toBe('/base.png'); + expect(node?.querySelector('[data-sprite-placeholder="true"]')).toBeNull(); + }); +}); diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx new file mode 100644 index 000000000..446fa0d93 --- /dev/null +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -0,0 +1,250 @@ +import { elementRotationTransform } from '@utils/element/rotation'; +import React from 'react'; +import { + useGridElementInteraction, + type GridElementInteractionProps, +} from '@hooks/Grid/useGridElementInteraction'; +import { useSpriteEditPreview } from '@stores/grid/useSpriteEditPreviewStore'; +import { isSameSpriteTransform } from '@utils/sprite/spriteGeometry'; +import { + isErrorForCurrentSrc, + useFailedImageSrcs, +} from '@hooks/overlay/useFailedImageSrcs'; +import { + resolveImageSource, + toRenderableImageRef, +} from '@utils/media/imageSource'; +import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; +import SpriteImagePlaceholder from '@components/main/common/SpriteImagePlaceholder'; +import { resolvePoseImage } from '@utils/sprite/poseResolver'; +import { + placeSpriteVisual, + spriteIdleVisual, + spritePoseVisual, +} from '@utils/sprite/spritePlacement'; +import { DEFAULT_SPRITE_SIZE } from '@src/types/key/sprites'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; + +interface SpriteItemProps extends GridElementInteractionProps { + position: CanonicalReactiveSpritePosition; + zIndex?: number; +} + +// 자세 편집 중 기본 자세를 뒤에 남기는 고스트의 투명도 +const IDLE_GHOST_OPACITY = 0.3; + +// 캔버스의 스프라이트는 정적이다: 평소엔 기본 자세, 자세 팝업이 열려 있으면 +// 그 자세를 그린다 (편집창 전용 프리뷰). 키 눌림 라이브 반응은 오버레이 창 몫. +// 선택·기준점·자세 핸들은 그리드 오버레이 층(SpriteCanvasHandles)이 그린다 +const SpriteItem = ({ + index, + elementId, + position, + onPositionChange, + onClick, + onDoubleClick, + onCtrlClick, + onShiftClick, + isSelected = false, + selectedElements = [], + onMultiDrag, + onMultiDragStart, + onMultiDragEnd, + activeTool, + onEraserClick, + onContextMenu, + setReferenceRef, + zoom = 1, + panX = 0, + panY = 0, + zIndex = 0, + isViewportTransforming = false, +}: SpriteItemProps) => { + const { + dx = 0, + dy = 0, + width = DEFAULT_SPRITE_SIZE, + height = DEFAULT_SPRITE_SIZE, + className, + } = position; + + // 편집 중 프리뷰 - 자세 팝업이면 그 자세를 그린다. 드래그·스크럽 중이거나 무효 + // draft면 발행된 스냅샷을 우선하고, 평소에는 canonical 자세를 따른다 + // transform과 이미지는 같은 자세에서 함께 파생해 혼합 상태를 막는다 + const posePreview = useSpriteEditPreview(elementId); + const previewPose = posePreview + ? posePreview.preferFallback + ? posePreview.fallbackPose + : position.poses.find((pose) => pose.poseId === posePreview.poseId) ?? + posePreview.fallbackPose + : null; + const effectivePosition = + posePreview?.referenceNaturalSize && + toRenderableImageRef(position.baseImage) === null && + !position.referenceNaturalSize + ? { + ...position, + referenceNaturalSize: posePreview.referenceNaturalSize, + } + : position; + + // 유실 이미지를 그대로 두면 캔버스에 깨진 아이콘이 박히고, 송출 화면(오버레이)은 + // 폴백을 그려 미리보기가 결과와 어긋난다. 폴백 규칙은 오버레이와 같다. + // 훅 인자는 저장된 경로로 고정 - 편집 중 draft override를 넣으면 스크럽마다 + // 키가 바뀌어 실패 집합이 초기화된다 + const { failedImageSrcs, markFailed } = useFailedImageSrcs( + position.baseImage, + ...position.poses.map((pose) => pose.imageOverride), + ); + const baseSrc = resolveImageSource(position.baseImage); + const fallbackSrc = baseSrc && !failedImageSrcs.has(baseSrc) ? baseSrc : null; + let imageSrc = resolveImageSource( + resolvePoseImage(previewPose?.imageOverride, position.baseImage), + ); + // 이미지·원본 크기는 한 벌로 움직인다 - 폴백도 기본 이미지 배치로 함께 간다 + let visual = previewPose + ? spritePoseVisual(effectivePosition, previewPose) + : spriteIdleVisual(position); + if (imageSrc && failedImageSrcs.has(imageSrc)) { + imageSrc = fallbackSrc; + visual = spriteIdleVisual(position); + } + const placement = placeSpriteVisual(effectivePosition, visual); + const transform = previewPose + ? previewPose.transform + : position.idleTransform; + + // 자세 편집 중에는 기본 자세를 반투명 고스트로 뒤에 남겨 "어디서 얼마나 + // 움직였는지"가 보인다. 기본 자세와 같은 그림이면 겹쳐 그릴 이유가 없다 + const idlePlacement = placeSpriteVisual(position, spriteIdleVisual(position)); + const showIdleGhost = + previewPose !== null && + fallbackSrc !== null && + (imageSrc !== fallbackSrc || + !isSameSpriteTransform(transform, position.idleTransform)); + + const { + attachRef, + dx: renderDx, + dy: renderDy, + handleClick, + handleContextMenu, + handleDoubleClick, + handleSelectionDragPointerDown, + isDraggingOrResizing, + isSelectionMode, + } = useGridElementInteraction({ + index, + elementId, + initialX: dx, + initialY: dy, + elementWidth: width || DEFAULT_SPRITE_SIZE, + elementHeight: height || DEFAULT_SPRITE_SIZE, + onPositionChange, + onClick, + onDoubleClick, + onCtrlClick, + onShiftClick, + isSelected, + selectedElements, + onMultiDrag, + onMultiDragStart, + onMultiDragEnd, + activeTool, + onEraserClick, + onContextMenu, + setReferenceRef, + zoom, + panX, + panY, + isViewportTransforming, + }); + + if (position.hidden) return null; + + const rootTransform = `translate(calc(${renderDx}px + var(--key-offset-x, 0px)), calc(${renderDy}px + var(--key-offset-y, 0px)))${elementRotationTransform( + position.rotation, + )}`; + + return ( +
e.preventDefault()} + > +
+ {showIdleGhost ? ( + + ) : null} + {imageSrc ? ( + { + if (!isErrorForCurrentSrc(event.currentTarget, imageSrc)) return; + markFailed(imageSrc); + }} + /> + ) : ( + + )} +
+
+ ); +}; + +export default SpriteItem; diff --git a/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.test.tsx b/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.test.tsx index e37d0c668..21a8f3d51 100644 --- a/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.test.tsx +++ b/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.test.tsx @@ -2,8 +2,23 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { CanonicalKeyPosition } from '@src/types/editor'; +import type { + CanonicalEditorDocumentV1, + CanonicalKeyPosition, +} from '@src/types/editor'; +import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; +import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; import GridSelectionOverlays from './GridSelectionOverlays'; +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 { useSpriteStore } from '@stores/data/useSpriteStore'; +import { + useGridSelectionStore, + type SelectedElement, +} from '@stores/grid/useGridSelectionStore'; +import { useSelectionRotationStore } from '@stores/grid/useSelectionRotationStore'; ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -39,44 +54,75 @@ vi.mock('../handles/GradientAxisHandle', async () => { }; }); +vi.mock('../handles/SpriteCanvasHandles', async () => { + const { createElement } = await import('react'); + return { + default: () => createElement('div', { 'data-sprite-handles': '' }), + }; +}); + const keyPosition = ( id: string, dx: number, dy: number, + rotation = 0, ): CanonicalKeyPosition => - ({ id, dx, dy, width: 30, height: 40 } as CanonicalKeyPosition); + ({ id, dx, dy, width: 30, height: 40, rotation } as CanonicalKeyPosition); const FIRST_ID = '00000000-0000-4000-8000-000000000201'; const SECOND_ID = '00000000-0000-4000-8000-000000000202'; +const PLUGIN_ELEMENT: PluginDisplayElementInternal = { + id: 'one', + fullId: 'plugin-1', + pluginId: 'plugin', + html: '
Plugin
', + position: { x: 100, y: 30 }, + measuredSize: { width: 50, height: 20 }, +}; describe('GridSelectionOverlays', () => { let host: HTMLDivElement; let root: Root; const renderOverlays = ({ - selectedElements = [{ type: 'key' as const, id: FIRST_ID, index: 0 }], + selectedElements = [ + { type: 'key', id: FIRST_ID, index: 0 }, + ] as SelectedElement[], + spritePositions = {} as CanonicalEditorDocumentV1['spritePositions'], + pluginElements = [] as PluginDisplayElementInternal[], hasGradientEditSession = false, + hasSpritePoseSession = false, previewElementBounds = null as readonly unknown[] | null, + firstKeyRotation = 0, } = {}) => { + const positions = { + '4key': [ + keyPosition(FIRST_ID, 10, 20, firstKeyRotation), + keyPosition(SECOND_ID, 50, 60), + ], + }; act(() => { + useKeyStore.setState({ + selectedKeyType: '4key', + canonicalPositions: positions, + }); + useGridSelectionStore.setState({ selectedElements }); + useSpriteStore.setState({ positions: spritePositions }); root.render( { }; beforeEach(() => { + useStatItemStore.setState({ positions: {} }); + useGraphItemStore.setState({ positions: {} }); + useKnobItemStore.setState({ positions: {} }); + useSpriteStore.setState({ positions: {} }); + useSelectionRotationStore.setState({ + selectionKey: null, + referenceRotation: 0, + }); host = document.createElement('div'); document.body.appendChild(host); root = createRoot(host); @@ -115,6 +169,15 @@ describe('GridSelectionOverlays', () => { expect(host.querySelector('[data-resize-handles]')).not.toBeNull(); expect(host.querySelector('[data-group-resize-handles]')).toBeNull(); expect(host.querySelector('[data-gradient-axis]')).not.toBeNull(); + expect(host.querySelector('[data-sprite-handles]')).not.toBeNull(); + }); + + it('스프라이트 자세 편집 중에는 리사이즈 핸들만 숨긴다', () => { + renderOverlays({ hasSpritePoseSession: true }); + + expect(host.querySelector('[data-grid-selection-outline]')).not.toBeNull(); + expect(host.querySelector('[data-resize-handles]')).toBeNull(); + expect(host.querySelector('[data-sprite-handles]')).not.toBeNull(); }); it('그라데이션 편집 중에는 윤곽과 리사이즈 핸들만 숨긴다', () => { @@ -149,5 +212,154 @@ describe('GridSelectionOverlays', () => { ?.getAttribute('data-group-resize-handles'), ).toBe('2'); expect(host.querySelector('[data-resize-handles]')).toBeNull(); + expect( + host.querySelector('[data-rotation-handles="selection"]'), + ).toBeNull(); + }); + + it.each([0, 30])( + '기존 각도 %s°의 다중 선택은 공통 틀만 표시한다', + (rotation) => { + renderOverlays({ + selectedElements: [ + { type: 'key', id: FIRST_ID, index: 0 }, + { type: 'key', id: SECOND_ID, index: 1 }, + ], + firstKeyRotation: rotation, + }); + expect( + host.querySelectorAll('[data-grid-selection-outline]'), + ).toHaveLength(0); + expect(host.querySelectorAll('[data-group-resize-handles]')).toHaveLength( + 1, + ); + expect( + host.querySelectorAll('[data-rotation-handles="selection"]'), + ).toHaveLength(1); + expect(host.querySelectorAll('[data-rotate-corner]')).toHaveLength(4); + expect(host.querySelector('[data-rotation-handles="native"]')).toBeNull(); + }, + ); + + it('회전 요소가 든 플러그인 혼합 선택은 공통 틀이 없어 그룹 리사이즈도 닫는다', () => { + const mixed = [ + { type: 'key', id: FIRST_ID, index: 0 }, + { type: 'plugin', id: 'plugin-1' }, + ] as SelectedElement[]; + renderOverlays({ + selectedElements: mixed, + pluginElements: [PLUGIN_ELEMENT], + firstKeyRotation: 30, + }); + expect(host.querySelector('[data-group-resize-handles]')).toBeNull(); + expect( + host.querySelector('[data-rotation-handles="selection"]'), + ).toBeNull(); + const outlines = host.querySelectorAll( + '[data-grid-selection-outline]', + ); + expect(outlines).toHaveLength(2); + expect(outlines[0].style.transform).toBe('rotate(30deg)'); + expect(outlines[1].style.transform).toBe(''); + + // 회전이 없으면 기존 논리 상자 리사이즈 그대로 + renderOverlays({ + selectedElements: mixed, + pluginElements: [PLUGIN_ELEMENT], + }); + expect(host.querySelector('[data-group-resize-handles]')).not.toBeNull(); + expect(host.querySelector('[data-grid-selection-outline]')).toBeNull(); + }); + + it.each([0, 30])( + '스프라이트 자세 45°와 배치 %s°의 플러그인 혼합 선택을 구분한다', + (rotation) => { + renderOverlays({ + selectedElements: [ + { type: 'sprite', id: FIRST_ID }, + { type: 'plugin', id: PLUGIN_ELEMENT.fullId }, + ], + pluginElements: [PLUGIN_ELEMENT], + spritePositions: { + '4key': [ + makeCanonicalSpritePosition({ + id: FIRST_ID, + rotation, + idleTransform: { x: 10, y: -5, rotation: 45, scale: 1.2 }, + }), + ], + }, + }); + const outlines = host.querySelectorAll( + '[data-grid-selection-outline]', + ); + expect(outlines).toHaveLength(rotation === 0 ? 0 : 2); + if (rotation === 0) { + expect( + host.querySelector('[data-group-resize-handles]'), + ).not.toBeNull(); + } else { + expect(host.querySelector('[data-group-resize-handles]')).toBeNull(); + expect(outlines[0].style.transform).toBe('rotate(30deg)'); + } + expect( + host.querySelector('[data-rotation-handles="selection"]'), + ).toBeNull(); + }, + ); + + it('그라데이션 편집은 회전 혼합 선택의 개별 윤곽도 숨긴다', () => { + renderOverlays({ + selectedElements: [ + { type: 'key', id: FIRST_ID }, + { type: 'plugin', id: PLUGIN_ELEMENT.fullId }, + ], + pluginElements: [PLUGIN_ELEMENT], + firstKeyRotation: 30, + hasGradientEditSession: true, + }); + expect(host.querySelector('[data-grid-selection-outline]')).toBeNull(); + expect(host.querySelector('[data-group-resize-handles]')).toBeNull(); + expect( + host.querySelector('[data-rotation-handles="selection"]'), + ).toBeNull(); + }); + + it('그라데이션 편집은 다중 선택의 회전 진입점도 숨긴다', () => { + renderOverlays({ + selectedElements: [ + { type: 'key', id: FIRST_ID, index: 0 }, + { type: 'key', id: SECOND_ID, index: 1 }, + ], + hasGradientEditSession: true, + }); + expect( + host.querySelector('[data-rotation-handles="selection"]'), + ).toBeNull(); + expect(host.querySelector('[data-group-resize-handles]')).toBeNull(); + expect(host.querySelector('[data-gradient-axis]')).not.toBeNull(); + }); + + it('단독 스프라이트는 배치 회전을 표시하고 자세 편집 중에는 자세 핸들에 자리를 내준다', () => { + const options = { + selectedElements: [{ type: 'sprite' as const, id: FIRST_ID }], + spritePositions: { + '4key': [makeCanonicalSpritePosition({ id: FIRST_ID, rotation: 90 })], + }, + }; + renderOverlays(options); + expect( + host.querySelectorAll('[data-rotation-handles="native"]'), + ).toHaveLength(1); + expect(host.querySelectorAll('[data-rotate-corner]')).toHaveLength(4); + expect( + (host.querySelector('[data-grid-selection-outline]') as HTMLElement).style + .transform, + ).toBe('rotate(90deg)'); + renderOverlays({ ...options, hasSpritePoseSession: true }); + expect(host.querySelector('[data-rotation-handles="native"]')).toBeNull(); + expect(host.querySelector('[data-sprite-handles]')).not.toBeNull(); + renderOverlays(options); + expect(host.querySelectorAll('[data-rotate-corner]')).toHaveLength(4); }); }); diff --git a/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.tsx b/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.tsx index 593dd7246..bb3b31d4d 100644 --- a/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.tsx +++ b/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.tsx @@ -2,11 +2,21 @@ import type { ComponentProps, ReactNode } from 'react'; import ResizeHandles from '../handles/ResizeHandles'; import GroupResizeHandles from '../handles/GroupResizeHandles'; import GradientAxisOverlay from '../handles/GradientAxisHandle'; +import SpriteCanvasHandles from '../handles/SpriteCanvasHandles'; +import NativeRotateHandle from '../handles/NativeRotateHandle'; +import SpriteRotateHandle from '../handles/SpriteRotateHandle'; +import SelectionRotateHandle from '../handles/SelectionRotateHandle'; +import { useSelectionRotationFrame } from '@hooks/Grid/selection/useSelectionRotationFrame'; +import { isRotatableElementType } from '../handles/rotatableElement'; +import { SELECTION_BORDER_WIDTH } from '../handles/selectionOutline'; import { getElementBounds, + getElementRotation, + isAspectLockedElement, isElementResizable, type Bounds, } from '../handles/groupResizeUtils'; +import { matchSpriteAnchorPreset } from '@utils/sprite/spriteGeometry'; import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; @@ -20,12 +30,15 @@ interface GridSelectionOverlaysProps { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; mode: string; pluginElements: PluginDisplayElementInternal[]; zoom: number; panX: number; panY: number; hasGradientEditSession: boolean; + // 스프라이트 자세 편집 중 - 자세 프레임이 리사이즈 핸들을 대신한다 + hasSpritePoseSession: boolean; previewBounds: Bounds | null; previewGroupBounds: Bounds | null; previewElementBounds: readonly unknown[] | null; @@ -43,12 +56,14 @@ const GridSelectionOverlays = ({ statPositions, graphPositions, knobPositions, + spritePositions, mode, pluginElements, zoom, panX, panY, hasGradientEditSession, + hasSpritePoseSession, previewBounds, previewGroupBounds, previewElementBounds, @@ -59,26 +74,29 @@ const GridSelectionOverlays = ({ onGroupResizeEnd, getOtherElements, }: GridSelectionOverlaysProps) => { - const selectionOutlines: ReactNode[] = []; - const hideOutlinesForGroupPreview = - selectedElements.length > 1 && previewElementBounds !== null; - if (!hasGradientEditSession && !hideOutlinesForGroupPreview) { - selectedElements.forEach((element) => { - if ( - selectedElements.length > 1 && - !isElementResizable( + const selectionFrame = useSelectionRotationFrame(); + // 공통 틀이 없는 회전 혼합 선택은 개별 윤곽만 표시하고 그룹 리사이즈는 닫는다 + const rotatedWithoutFrame = + selectedElements.length > 1 && + !selectionFrame && + selectedElements.some( + (element) => + getElementRotation( element, positions, statPositions, graphPositions, knobPositions, mode, - pluginElements, - ) - ) { - return; - } - + spritePositions, + ) !== 0, + ); + const selectionOutlines: ReactNode[] = []; + if ( + !hasGradientEditSession && + (selectedElements.length === 1 || rotatedWithoutFrame) + ) { + selectedElements.forEach((element) => { const bounds = getElementBounds( element, positions, @@ -87,21 +105,40 @@ const GridSelectionOverlays = ({ knobPositions, mode, pluginElements, + spritePositions, ); if (!bounds) return; const displayBounds = selectedElements.length === 1 && previewBounds ? previewBounds : bounds; + const outlineLeft = + displayBounds.x * zoom + panX - SELECTION_BORDER_WIDTH; + const outlineTop = displayBounds.y * zoom + panY - SELECTION_BORDER_WIDTH; + const outlineWidth = + displayBounds.width * zoom + SELECTION_BORDER_WIDTH * 2; + const outlineHeight = + displayBounds.height * zoom + SELECTION_BORDER_WIDTH * 2; + // 선택 틀과 핸들은 회전한 얼굴을 추종 + const rotation = getElementRotation( + element, + positions, + statPositions, + graphPositions, + knobPositions, + mode, + spritePositions, + ); selectionOutlines.push(
candidate.id === singleElement.id, + ) ?? null + : null; + const occupiedHandle = + spriteAtRest && + Math.abs(spriteAtRest.idleTransform.x) < 1e-6 && + Math.abs(spriteAtRest.idleTransform.y) < 1e-6 + ? matchSpriteAnchorPreset(spriteAtRest.pivot) + : null; + return ( <> {selectionOutlines} @@ -154,27 +220,87 @@ const GridSelectionOverlays = ({ onResizeEnd={onResizeEnd} elementId={singleElement.id} getOtherElements={getOtherElements} + // 스프라이트는 그림 레이어라 늘리지 않는다 + lockAspect={isAspectLockedElement(singleElement)} + occupiedHandle={occupiedHandle} + rotation={singleRotation} /> )} - {selectedElements.length > 1 && !hasGradientEditSession && ( - + )} + {selectedElements.length > 1 && + !hasGradientEditSession && + !rotatedWithoutFrame && ( + + entry.type === 'sprite' && + (entry.idleTransform.x !== 0 || + entry.idleTransform.y !== 0 || + entry.idleTransform.scale !== 1), + )) + ? { + bounds: selectionFrame.bounds, + rotation: selectionFrame.rotation, + } + : undefined + } + onGroupResizeStart={onResizeStart} + onGroupResize={onGroupResize} + onGroupResizeEnd={onGroupResizeEnd} + getOtherElements={getOtherElements} + /> + )} + {showSingleResizeHandles && spriteAtRest && singleBounds && ( + )} + {selectedElements.length > 1 && + !hasGradientEditSession && + !previewElementBounds && + selectionFrame && ( + + )} + {/* 온캔버스 스프라이트 핸들 - 선택 중엔 기준점, 자세 편집 중엔 자세 프레임 */} + ); }; diff --git a/src/renderer/components/main/Modal/FloatingPopup.canvasEditorOverlay.test.tsx b/src/renderer/components/main/Modal/FloatingPopup.canvasEditorOverlay.test.tsx new file mode 100644 index 000000000..405269b11 --- /dev/null +++ b/src/renderer/components/main/Modal/FloatingPopup.canvasEditorOverlay.test.tsx @@ -0,0 +1,98 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import FloatingPopup from './floatingPopup/FloatingPopup'; + +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +// 온캔버스 편집 오버레이(그라데이션 축 핸들·자세 손끝 기즈모)는 팝업 밖 DOM이지만 +// 조작 자체가 그 팝업의 편집이다. capture 단계 바깥 클릭 판정이 이걸 예외로 +// 인정하지 않으면 노브를 누르는 순간 팝업이 닫혀 핸들을 쓸 수 없다 +describe('FloatingPopup 온캔버스 편집 오버레이', () => { + let host: HTMLDivElement; + let root: Root; + const onClose = vi.fn(); + + // 두 닫힘 경로 모두 예외를 알아야 한다 - PickerSurface(자세 팝업)는 + // autoClose=false라 capture 판정을 타고, 기본 팝업은 mousedown 판정을 탄다 + const renderPopup = async (autoClose: boolean) => { + await act(async () => + root.render( + + + , + ), + ); + }; + + // 캔버스 오버레이는 팝업과 무관한 자리에 렌더된다 (Grid 안, body 포털 밖) + const canvasNode = (marker?: string) => { + const overlay = document.createElement('div'); + if (marker) overlay.setAttribute(marker, 'true'); + const knob = document.createElement('div'); + overlay.appendChild(knob); + host.appendChild(overlay); + return knob; + }; + + const press = (node: Element, type: 'pointerdown' | 'mousedown') => + act(() => { + node.dispatchEvent( + type === 'pointerdown' + ? new PointerEvent(type, { bubbles: true, cancelable: true }) + : new MouseEvent(type, { bubbles: true, cancelable: true }), + ); + }); + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + onClose.mockClear(); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('capture 판정에서 마커가 붙은 오버레이 조작은 팝업을 닫지 않는다', async () => { + await renderPopup(false); + + await press(canvasNode('data-dmn-canvas-editor-overlay'), 'pointerdown'); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('capture 판정에서 마커 없는 캔버스 클릭은 그대로 팝업을 닫는다', async () => { + await renderPopup(false); + + await press(canvasNode(), 'pointerdown'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('autoClose 판정도 같은 마커를 예외로 본다', async () => { + await renderPopup(true); + + await press(canvasNode('data-dmn-canvas-editor-overlay'), 'mousedown'); + expect(onClose).not.toHaveBeenCalled(); + + await press(canvasNode(), 'mousedown'); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/components/main/Modal/PopupExit.test.tsx b/src/renderer/components/main/Modal/PopupExit.test.tsx index 36d1f8d74..44337d41a 100644 --- a/src/renderer/components/main/Modal/PopupExit.test.tsx +++ b/src/renderer/components/main/Modal/PopupExit.test.tsx @@ -127,6 +127,37 @@ describe('PopupExit', () => { expect(onCommit).toHaveBeenCalledWith('fill'); }); + it('splits the session when the child key changes while open', async () => { + const onMount = vi.fn(); + const onCommit = vi.fn(); + + const renderKeyed = async (target: string) => { + await act(async () => { + root.render( + + + , + ); + }); + }; + + await renderKeyed('fill'); + expect(onMount).toHaveBeenCalledTimes(1); + + // 닫힘 없이 대상만 교체 (상태 행 전환) - 세션이 끊겨 새 인스턴스가 마운트된다 + await renderKeyed('stroke'); + expect(onMount).toHaveBeenCalledTimes(2); + // 이전 세션의 언마운트 커밋은 자기 대상으로 간다 + expect(onCommit).toHaveBeenCalledWith('fill'); + expect(onCommit).not.toHaveBeenCalledWith('stroke'); + }); + // 이 조합이 실제 payoff다 - 호출부가 대상을 비워도 팝업이 퇴장 상태를 거친다 it('drives a real popup through the closing state before unmount', async () => { vi.spyOn(Element.prototype, 'getClientRects').mockReturnValue([ diff --git a/src/renderer/components/main/Modal/PopupExit.tsx b/src/renderer/components/main/Modal/PopupExit.tsx index 0a91858a1..250c3ec1e 100644 --- a/src/renderer/components/main/Modal/PopupExit.tsx +++ b/src/renderer/components/main/Modal/PopupExit.tsx @@ -37,9 +37,11 @@ const PopupExit = ({ open, children }: PopupExitProps) => { if (!mounted || !retained) return null; + // 호출부가 child에 key를 주면 열린 채 대상이 바뀔 때도 세션이 끊긴다 + // (상태 행 전환처럼 닫힘 없이 편집 대상을 교체하는 경우) return React.cloneElement(retained as React.ReactElement<{ open: boolean }>, { open, - key: session, + key: `${session}:${retained.key ?? ''}`, }); }; diff --git a/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx b/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx index a89d612a1..18858b2fe 100644 --- a/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx @@ -188,6 +188,7 @@ const TabCssModal = ({ isOpen, onClose, showAlert }: TabCssModalProps) => { onCommit: commitCssEnabled, onError: (error) => { console.error('Failed to toggle tab CSS:', error); + showAlert?.(t('common.saveFailed')); }, }); diff --git a/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.test.tsx b/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.test.tsx index 56153c219..d528f7f06 100644 --- a/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.test.tsx +++ b/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.test.tsx @@ -12,8 +12,12 @@ const testState = vi.hoisted(() => ({ selectedKeyType: 'tab-a', noteEffect: true, noteSettings: null as unknown, + keyMappings: {} as Record, + customTabs: [] as { id: string }[], +})); +const apiMocks = vi.hoisted(() => ({ + noteTabSet: vi.fn(), })); -const apiMocks = vi.hoisted(() => ({ noteTabSet: vi.fn() })); vi.mock('@api/modules/editor/noteTabApi', () => ({ noteTabApi: { @@ -22,8 +26,10 @@ vi.mock('@api/modules/editor/noteTabApi', () => ({ })); vi.mock('@stores/data/useKeyStore', () => ({ - useKeyStore: (selector: (state: { selectedKeyType: string }) => unknown) => - selector({ selectedKeyType: testState.selectedKeyType }), + useKeyStore: Object.assign( + (selector: (state: typeof testState) => unknown) => selector(testState), + { getState: () => testState }, + ), })); vi.mock('@stores/useSettingsStore', () => ({ @@ -43,28 +49,35 @@ vi.mock('@contexts/useTranslation', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -vi.mock('@hooks/ui/usePopupPresence', () => ({ - useModalPresence: (open: boolean) => ({ - mounted: open, - state: 'open', - cycle: open ? 1 : 0, - }), +vi.mock('@components/main/common/checkbox/Checkbox', () => ({ + default: () => , })); - -vi.mock('../settings/NoteSetting', () => ({ +vi.mock('@components/main/common/TabSwitch', () => ({ + default: () => , +})); +vi.mock( + '@components/main/Grid/PropertiesPanel/controls/PropertyInputs', + () => ({ + PropertyRow: ({ + label, + children, + }: { + label: string; + children: React.ReactNode; + }) => , + PropertySection: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }), +); +vi.mock('../../Modal', () => ({ default: ({ - settings, - onSave, + children, + motionState, }: { - settings: NoteSettings; - onSave: (settings: NoteSettings) => Promise; - }) => ( -
- -
- ), + children: React.ReactNode; + motionState?: string; + }) =>
{children}
, })); import TabNoteSettingModal from './TabNoteSettingModal'; @@ -82,17 +95,50 @@ const deferred = (): Deferred => { return { promise, resolve }; }; -describe('TabNoteSettingModal 로드 세대', () => { +describe('TabNoteSettingModal 편집 세션', () => { let container: HTMLDivElement; let root: Root; let noteTabGet: ReturnType; let noteTabSet: ReturnType; + const speedInput = () => + container.querySelector( + '[data-label="noteSetting.speed"] input', + ); + const saveButton = () => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'noteSetting.save', + )!; + const editSpeed = (speed: number) => + act(() => { + const input = speedInput()!; + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, String(speed)); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; testState.selectedKeyType = 'tab-a'; testState.noteEffect = true; testState.noteSettings = getDefaultNoteSettings(); + testState.keyMappings = { 'tab-a': [], 'tab-b': [] }; + testState.customTabs = []; + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1), + ); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); noteTabGet = vi.fn(); noteTabSet = apiMocks.noteTabSet; noteTabSet.mockReset().mockResolvedValue({ success: true, tabId: 'tab-b' }); @@ -113,6 +159,7 @@ describe('TabNoteSettingModal 로드 세대', () => { act(() => root.unmount()); container.remove(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it('닫힌 이전 탭의 늦은 응답을 버리고 현재 탭 설정만 저장한다', async () => { @@ -145,28 +192,171 @@ describe('TabNoteSettingModal 로드 세대', () => { await Promise.resolve(); }); - expect( - container - .querySelector('[data-note-speed]') - ?.getAttribute('data-note-speed'), - ).toBe(String(settingsB.speed)); + expect(speedInput()?.value).toBe(String(settingsB.speed)); await act(async () => { first.resolve({ tabId: 'tab-a', settings: settingsA }); await Promise.resolve(); }); - expect( - container - .querySelector('[data-note-speed]') - ?.getAttribute('data-note-speed'), - ).toBe(String(settingsB.speed)); + expect(speedInput()?.value).toBe(String(settingsB.speed)); await act(async () => { - container.querySelector('button')?.click(); + saveButton().click(); await new Promise((resolve) => window.setTimeout(resolve, 0)); }); expect(noteTabSet).toHaveBeenCalledWith('tab-b', settingsB); }); + + it('저장 실패를 편집 화면에 전파해서 성공으로 닫히지 않게 한다', async () => { + noteTabGet.mockResolvedValue({ tabId: 'tab-a', settings: { speed: 700 } }); + const error = new Error('tab settings write failed'); + const onClose = vi.fn(); + noteTabSet.mockRejectedValueOnce(error); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await act(async () => { + root.render(); + }); + await act(async () => { + saveButton().click(); + }); + expect(onClose).not.toHaveBeenCalled(); + expect(speedInput()?.value).toBe('700'); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'common.saveFailed', + ); + await act(async () => saveButton().click()); + expect(noteTabSet).toHaveBeenLastCalledWith('tab-a', { speed: 700 }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('저장 press 직후 외부 모드가 바뀌어도 처음 연 탭에 저장한다', async () => { + const firstSpeed = (testState.noteSettings as NoteSettings).speed + 10; + const second = deferred(); + noteTabGet + .mockResolvedValueOnce({ + tabId: 'tab-a', + settings: { speed: firstSpeed }, + }) + .mockImplementationOnce(() => second.promise); + const onClose = vi.fn(); + await act(async () => { + root.render(); + }); + editSpeed(firstSpeed + 7); + act(() => { + const save = saveButton(); + save.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + pointerId: 9, + isPrimary: true, + button: 0, + }), + ); + save.dispatchEvent( + new PointerEvent('pointerup', { + bubbles: true, + pointerId: 9, + isPrimary: true, + button: 0, + }), + ); + testState.selectedKeyType = 'tab-b'; + root.render(); + }); + expect(speedInput()?.value).toBe(String(firstSpeed + 7)); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + expect(noteTabSet).toHaveBeenCalledWith('tab-a', { speed: firstSpeed + 7 }); + expect(noteTabGet).toHaveBeenCalledTimes(1); + }); + + it('조회 중 모드가 바뀌어도 원래 탭을 열고 재개방할 때 새 탭을 읽는다', async () => { + const first = deferred(); + const second = deferred(); + noteTabGet + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + const onClose = vi.fn(); + await act(async () => + root.render(), + ); + testState.selectedKeyType = 'tab-b'; + await act(async () => + root.render(), + ); + await act(async () => + first.resolve({ tabId: 'tab-a', settings: { speed: 710 } }), + ); + expect(speedInput()?.value).toBe('710'); + expect(noteTabGet).toHaveBeenCalledTimes(1); + + await act(async () => + root.render(), + ); + await act(async () => + root.render(), + ); + await act(async () => + second.resolve({ tabId: 'tab-b', settings: { speed: 820 } }), + ); + expect(speedInput()?.value).toBe('820'); + await act(async () => saveButton().click()); + expect(noteTabSet).toHaveBeenCalledWith('tab-b', { speed: 820 }); + }); + + it('열었던 탭이 삭제되면 다른 탭에 쓰지 않고 안내와 초안을 유지한다', async () => { + noteTabGet.mockResolvedValue({ tabId: 'tab-a', settings: { speed: 700 } }); + const onClose = vi.fn(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await act(async () => + root.render(), + ); + editSpeed(777); + + testState.selectedKeyType = 'tab-b'; + testState.keyMappings = { 'tab-b': [] }; + await act(async () => + root.render(), + ); + await act(async () => saveButton().click()); + + expect(noteTabSet).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(speedInput()?.value).toBe('777'); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'common.editTargetMissing', + ); + + await act(async () => + root.render(), + ); + noteTabGet.mockResolvedValueOnce({ + tabId: 'tab-b', + settings: { speed: 820 }, + }); + await act(async () => + root.render(), + ); + expect(speedInput()?.value).toBe('820'); + expect(container.querySelector('[role="alert"]')).toBeNull(); + await act(async () => saveButton().click()); + expect(noteTabSet).toHaveBeenCalledWith('tab-b', { speed: 820 }); + }); + + it('아직 키가 없는 사용자 탭도 존재하면 저장한다', async () => { + testState.keyMappings = { 'tab-b': [] }; + testState.customTabs = [{ id: 'tab-a' }]; + noteTabGet.mockResolvedValue({ tabId: 'tab-a', settings: { speed: 700 } }); + const onClose = vi.fn(); + await act(async () => + root.render(), + ); + await act(async () => saveButton().click()); + expect(noteTabSet).toHaveBeenCalledWith('tab-a', { speed: 700 }); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.tsx b/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.tsx index 488154346..131d95161 100644 --- a/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.tsx @@ -9,8 +9,8 @@ import type { NoteSettings, TabNoteSettings, } from '@src/types/settings/noteSettings'; -import { useTranslation } from '@contexts/useTranslation'; import { noteTabApi } from '@api/modules/editor/noteTabApi'; +import { useTranslation } from '@contexts/useTranslation'; interface TabNoteSettingModalProps { isOpen: boolean; @@ -18,14 +18,26 @@ interface TabNoteSettingModalProps { } const TabNoteSettingModal = ({ isOpen, onClose }: TabNoteSettingModalProps) => { - const { t: _t } = useTranslation(); + const { t } = useTranslation(); const globalSettings = useSettingsStore((s) => s.noteSettings); const noteEffect = useSettingsStore((s) => s.noteEffect); const selectedKeyType = useKeyStore((s) => s.selectedKeyType); const [tabOverride, setTabOverride] = useState(null); const [loading, setLoading] = useState(true); + const [editingTabId, setEditingTabId] = useState(selectedKeyType); + const [wasOpen, setWasOpen] = useState(isOpen); + const [saveErrorMessage, setSaveErrorMessage] = useState(null); const loadGenerationRef = useRef(0); + if (isOpen !== wasOpen) { + setWasOpen(isOpen); + if (isOpen) { + setEditingTabId(selectedKeyType); + setLoading(true); + setSaveErrorMessage(null); + } + } + // 모달 열릴 때 현재 탭의 오버라이드 로드 useEffect(() => { if (!isOpen) return; @@ -35,7 +47,7 @@ const TabNoteSettingModal = ({ isOpen, onClose }: TabNoteSettingModalProps) => { setLoading(true); window.api.noteTab - .get(selectedKeyType) + .get(editingTabId) .then((res) => { if (!isCurrentGeneration()) return; setTabOverride(res.settings ?? null); @@ -56,25 +68,30 @@ const TabNoteSettingModal = ({ isOpen, onClose }: TabNoteSettingModalProps) => { loadGenerationRef.current += 1; } }; - }, [isOpen, selectedKeyType]); + }, [isOpen, editingTabId]); const handleSave = async (normalized: NoteSettings) => { - try { - // 전역 설정과 비교하여 다른 값만 오버라이드로 저장 - const override: TabNoteSettings = {}; - const keys = Object.keys(normalized) as (keyof NoteSettings)[]; - for (const key of keys) { - if (normalized[key] !== globalSettings[key]) { - (override as Record)[key] = - normalized[key]; - } + const { keyMappings, customTabs } = useKeyStore.getState(); + if ( + !Object.prototype.hasOwnProperty.call(keyMappings, editingTabId) && + !customTabs.some((tab) => tab.id === editingTabId) + ) { + setSaveErrorMessage(t('common.editTargetMissing')); + throw new Error('Tab note settings target no longer exists'); + } + setSaveErrorMessage(null); + // 전역 설정과 비교하여 다른 값만 오버라이드로 저장 + const override: TabNoteSettings = {}; + const keys = Object.keys(normalized) as (keyof NoteSettings)[]; + for (const key of keys) { + if (normalized[key] !== globalSettings[key]) { + (override as Record)[key] = + normalized[key]; } - // 모든 값이 전역과 동일하면 오버라이드 제거 - const hasOverride = Object.keys(override).length > 0; - await noteTabApi.set(selectedKeyType, hasOverride ? override : null); - } catch (error) { - console.error('Failed to save tab note settings', error); } + // 모든 값이 전역과 동일하면 오버라이드 제거 + const hasOverride = Object.keys(override).length > 0; + await noteTabApi.set(editingTabId, hasOverride ? override : null); }; // 퇴장 모션이 도는 동안 DOM을 유지한다 @@ -96,6 +113,7 @@ const TabNoteSettingModal = ({ isOpen, onClose }: TabNoteSettingModalProps) => { settings={mergedSettings} onClose={onClose} onSave={handleSave} + saveErrorMessage={saveErrorMessage} /> ); }; diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx index 4c53f7a79..1aad44fb8 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx @@ -133,6 +133,39 @@ describe('ImagePicker 비동기 완료와 대상 전환', () => { expect(onIdleImageChange).toHaveBeenCalledWith('/tmp/picked.png'); }); + // 연결 콜백이 던져도 재진입 가드는 반드시 풀려야 한다. + // 안 풀리면 그 뒤로 이미지 선택 버튼이 영영 죽는다 + it('연결 콜백이 던져도 로그만 남기고 다음 선택이 가능하다', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + onIdleImageChange.mockImplementationOnce(() => { + throw new Error('boom'); + }); + mount(true); + clickPreview(); + await finishLoad(); + + expect(consoleError).toHaveBeenCalled(); + + clickPreview(); + await finishLoad({ success: true, imagePath: '/tmp/second.png' }); + expect(onIdleImageChange).toHaveBeenLastCalledWith('/tmp/second.png'); + consoleError.mockRestore(); + }); + + // 콜백 안에서 다시 누르는 경로 - 가드가 콜백보다 먼저 풀리면 두 번째 파일창이 뜬다 + it('연결 콜백 중 다시 눌러도 두 번째 파일창을 열지 않는다', async () => { + onIdleImageChange.mockImplementationOnce(() => { + clickPreview(); + }); + mount(true); + clickPreview(); + await finishLoad(); + + expect(apiMocks.imageLoad).toHaveBeenCalledTimes(1); + }); + it('대기 중 모드가 바뀌면 연결하지 않는다', async () => { mount(true); clickPreview(); diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx index 6504f6e6c..30e8e7b35 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx @@ -1,17 +1,23 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { useTranslation } from '@contexts/useTranslation'; import PickerSurface from '@components/main/Grid/PropertiesPanel/controls/PickerSurface'; import Checkbox from '@components/main/common/checkbox/Checkbox'; import Dropdown from '@components/main/common/dropdown/Dropdown'; import { NumberInput } from '@components/main/common/numberInput/NumberInput'; import TabSwitch from '@components/main/common/TabSwitch'; +import { + AngleGlyph, + ScaleGlyph, +} from '@components/main/common/TransformGlyphs'; import { PropertySection } from '@components/main/Grid/PropertiesPanel/controls/PropertyInputs'; import { resolveImageSource } from '@utils/media/imageSource'; -import { canDecodeImage } from '@utils/media/assetProbe'; +import { + pickValidatedImagePath, + runImageCompletion, +} from '@utils/media/pickValidatedImage'; import { useEditSessionCompletionGuard } from '@src/renderer/contexts/EditSessionScope'; import type { CompletionBinding } from '@src/renderer/contexts/EditSessionScope'; -import { imageApi } from '@api/modules/resources/resourceApi'; import { DEFAULT_IMAGE_MODE, IDENTITY_IMAGE_TRANSFORM, @@ -72,43 +78,6 @@ interface ImagePickerProps { completionBinding?: CompletionBinding; } -// 회전·크기 입력의 접두 글리프 - X/Y 글자와 같은 자리 -const AngleGlyph = () => ( - -); - -const ScaleGlyph = () => ( - -); - const STATE_MODES = { idle: 'idle', active: 'active', @@ -156,50 +125,35 @@ const ImagePicker = ({ // 열려 있는 동안 편집 상태를 캔버스 프리뷰로 발행 useEditStatePreviewPublisher(open ? previewAnchor : null, effectiveMode); - useEffect(() => { + // 입력 상태 탭이 사라지면 저장된 모드도 idle로 되돌린다. + // effect의 동기 setState는 캐스케이드 렌더라 렌더 중 보정 패턴을 쓴다 + const [lastShowActiveState, setLastShowActiveState] = + useState(showActiveState); + if (lastShowActiveState !== showActiveState) { + setLastShowActiveState(showActiveState); if (!showActiveState) setMode(STATE_MODES.idle); - }, [showActiveState]); - - const showInvalidImageAlert = (): void => { - void window.api.ui.dialog - .alert(t('imagePicker.invalidImage'), { - confirmText: t('common.ok') || '확인', - }) - .catch((error) => { - console.error('Failed to open invalid image alert:', error); - }); - }; + } const handleImageClick = async (stateMode: string): Promise => { if (loadingImageRef.current) return; loadingImageRef.current = true; setIsLoadingImage(true); - try { - const result = await imageApi.load(); - if (!result?.success || !result.imagePath) { - // errorCode가 없는 실패는 사용자 취소 - if (result?.errorCode) showInvalidImageAlert(); - return; - } - // 시그니처를 통과해도 WebView가 못 그리는 파일이 있다. 직전 값을 덮기 전에 확인한다 - if (!(await canDecodeImage(result.imagePath))) { - showInvalidImageAlert(); - return; - } + const picked = await pickValidatedImagePath(t); + if (picked) { // 파일 복사는 이미 끝났다. 대상이 갈렸으면 연결만 하지 않는다 - // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과) - if (!canBindCompletion()) return; - if (stateMode === STATE_MODES.idle) { - onIdleImageChange?.(result.imagePath); - } else { - onActiveImageChange?.(result.imagePath); - } - } catch (error) { - console.error('Failed to load image', error); - } finally { - loadingImageRef.current = false; - setIsLoadingImage(false); + // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과). + // 재진입 가드는 연결까지 끝난 뒤에 푼다 - 콜백 안에서 다시 누르는 경로 차단 + runImageCompletion(() => { + if (!canBindCompletion()) return; + if (stateMode === STATE_MODES.idle) { + onIdleImageChange?.(picked); + } else { + onActiveImageChange?.(picked); + } + }); } + loadingImageRef.current = false; + setIsLoadingImage(false); }; const handleReset = (): void => { diff --git a/src/renderer/components/main/Modal/content/pickers/font/FontPickerOpenButton.tsx b/src/renderer/components/main/Modal/content/pickers/font/FontPickerOpenButton.tsx index 4ea36bff1..f12b30efa 100644 --- a/src/renderer/components/main/Modal/content/pickers/font/FontPickerOpenButton.tsx +++ b/src/renderer/components/main/Modal/content/pickers/font/FontPickerOpenButton.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, type ReactNode } from 'react'; import { preloadFontPickerFonts } from './fontPickerPreload'; +import { ACTION_BUTTON_CHROME_CLASS } from '@utils/cardRecipes'; interface FontPickerOpenButtonProps { activePageKey: string | null; @@ -40,7 +41,7 @@ const FontPickerOpenButton = ({ return ( + ), +})); +vi.mock('@components/main/common/SettingRow', () => ({ + SettingCard: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SettingRow: ({ + label, + children, + }: { + label: React.ReactNode; + children: React.ReactNode; + }) => ( +
+ {label} + {children} +
+ ), + SettingToggleRow: ({ + label, + checked, + onToggle, + }: { + label: string; + checked: boolean; + onToggle: () => void; + }) => ( + + ), +})); +vi.mock('@components/main/SettingsPreview', () => ({ default: () => null })); +vi.mock('@components/main/SettingsPanel/ShortcutsPanelContent', () => ({ + default: () => null, +})); +vi.mock('@components/main/SettingsPanel/CssPanelContent', () => ({ + default: () => null, +})); +vi.mock('@components/main/SettingsPanel/SettingsSidePanel', () => ({ + default: ({ + activePanel, + pages, + }: { + activePanel: string; + pages: { key: string; content: React.ReactNode }[]; + }) => pages.find((page) => page.key === activePanel)?.content, +})); +vi.mock('@components/main/SettingsPanel/PluginsPanelContent', () => ({ + default: ({ onRemove }: { onRemove: (id: string) => void }) => ( + + ), +})); +vi.mock('@components/main/Modal/content/dialogs/PluginDataDeleteModal', () => ({ + PluginDataDeleteModal: ({ + isOpen, + onConfirm, + }: { + isOpen: boolean; + onConfirm: (withData: boolean) => void; + }) => + isOpen ? ( + + ) : null, +})); + +import Settings from './Settings'; + +describe('Settings 실패 안내와 상태 보존', () => { + let root: Root; + let host: HTMLDivElement; + const showAlert = vi.fn(); + const showConfirm = vi.fn(); + const button = (text: string) => + Array.from(host.querySelectorAll('button')).find( + (node) => node.textContent === text, + )!; + const choose = (label: string, value: string) => + act(() => { + const select = host.querySelector( + `[data-label="${label}"] select`, + )!; + select.value = value; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + const confirm = () => + act(async () => { + await showConfirm.mock.lastCall![1](); + }); + + beforeEach(async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('__APP_VERSION__', 'test'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + for (const mock of Object.values(mocks)) + mock.mockReset().mockResolvedValue(undefined); + mocks.hasPluginData.mockResolvedValue(true); + showAlert.mockReset(); + showConfirm.mockReset(); + mocks.getSoundState.mockResolvedValue({ + requested: { kind: 'defaultDevice' }, + effective: { kind: 'defaultDevice' }, + error: null, + errorCode: null, + asioAvailable: false, + }); + useSettingsStore.setState({ + ...getDefaultSettingsState(), + language: 'ko', + angleMode: 'd3d11', + jsPlugins: [ + { + id: 'plugin-a', + name: 'plugin-a.js', + content: '// @id plugin-a', + enabled: true, + }, + ] as never, + }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(async () => + root.render(), + ); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('전체 초기화가 실패하면 선택과 데이터를 보존하고 안내한다', async () => { + const before = useKeyStore.getState().keyMappings; + const selection = [{ type: 'key' as const, id: 'key-a', index: 0 }]; + useGridSelectionStore.getState().setSelectedElements(selection); + mocks.resetAll.mockRejectedValue(new Error('reset failed')); + act(() => button('settings.resetData').click()); + await confirm(); + expect(mocks.resetAll).toHaveBeenCalledOnce(); + expect(useKeyStore.getState().keyMappings).toBe(before); + expect(useGridSelectionStore.getState().selectedElements).toEqual( + selection, + ); + expect(showAlert).toHaveBeenCalledWith('common.actionFailed'); + }); + + it('OBS 토큰 재생성이 실패하면 자동 재시도 없이 안내한다', async () => { + mocks.regenerateToken.mockRejectedValue(new Error('token failed')); + act(() => button('settings.obsTokenRegen').click()); + await confirm(); + expect(mocks.regenerateToken).toHaveBeenCalledOnce(); + expect(showAlert).toHaveBeenCalledWith('common.actionFailed'); + }); + + it('각도 모드 저장이 실패하면 기존 모드를 유지하고 재시작하지 않는다', async () => { + mocks.update.mockRejectedValue(new Error('settings failed')); + choose('settings.graphicsOption', 'gl'); + await confirm(); + expect(useSettingsStore.getState().angleMode).toBe('d3d11'); + expect(mocks.restart).not.toHaveBeenCalled(); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('각도 모드 저장 후 재시작만 실패하면 저장한 모드를 유지한다', async () => { + mocks.restart.mockRejectedValue(new Error('restart failed')); + choose('settings.graphicsOption', 'gl'); + await confirm(); + expect(useSettingsStore.getState().angleMode).toBe('gl'); + expect(showAlert).toHaveBeenCalledWith('common.restartFailed'); + }); + + it('언어 저장 실패가 기존 선택을 바꾸지 않는다', async () => { + const failure = Promise.reject(new Error('language failed')); + void failure.catch(() => {}); + mocks.changeLanguage.mockReturnValue(failure); + choose('settings.language', 'en'); + await act(async () => { + await Promise.resolve(); + }); + expect(useSettingsStore.getState().language).toBe('ko'); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('플러그인 제거가 거절되면 저장된 데이터를 삭제하지 않는다', async () => { + mocks.removePlugin.mockResolvedValue({ success: false }); + act(() => button('settings.managePlugins').click()); + await act(async () => + host.querySelector('[data-remove-plugin]')!.click(), + ); + await act(async () => + host.querySelector('[data-delete-with-data]')!.click(), + ); + expect(mocks.removePlugin).toHaveBeenCalledWith('plugin-a'); + expect(mocks.clearPluginData).not.toHaveBeenCalled(); + expect(showAlert).toHaveBeenCalledWith('settings.jsPluginRemoveFailed'); + }); + + it('OBS 종료 후 설정 저장만 실패하면 실제 종료 상태를 유지한다', async () => { + mocks.stopObs.mockResolvedValue({ + running: false, + port: 1234, + clientCount: 0, + token: 'original', + }); + mocks.update.mockRejectedValue(new Error('settings failed')); + const toggle = host.querySelector( + '[data-toggle="settings.obsMode"]', + )!; + await act(async () => toggle.click()); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + expect(mocks.stopObs).toHaveBeenCalledOnce(); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('OBS 종료 응답과 뒤따르는 설정 저장까지 종료 정산이 기다린다', async () => { + let finishStop!: (status: unknown) => void; + let finishSettings!: () => void; + mocks.stopObs.mockReturnValue( + new Promise((resolve) => { + finishStop = resolve; + }), + ); + mocks.update.mockReturnValue( + new Promise((resolve) => { + finishSettings = resolve; + }), + ); + act(() => + host + .querySelector('[data-toggle="settings.obsMode"]')! + .click(), + ); + const drain = beginEditorWriteBarrier(); + let settled = false; + const result = drain().then((value) => { + settled = true; + return value; + }); + await act(async () => { + await Promise.resolve(); + }); + const settledBeforeStop = settled; + await act(async () => { + finishStop({ + running: false, + port: 1234, + clientCount: 0, + token: 'original', + }); + }); + const settledBeforeSettings = settled; + await act(async () => { + finishSettings(); + await result; + }); + expect(settledBeforeStop).toBe(false); + expect(settledBeforeSettings).toBe(false); + expect(await result).toBe(true); + expect(mocks.update).toHaveBeenCalledWith({ obsModeEnabled: false }); + }); + + it('OBS 종료 명령 자체가 실패하면 실행 상태를 되돌리고 안내한다', async () => { + mocks.stopObs.mockRejectedValue(new Error('stop failed')); + const drain = beginEditorWriteBarrier(); + const toggle = host.querySelector( + '[data-toggle="settings.obsMode"]', + )!; + await act(async () => toggle.click()); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + expect(mocks.update).not.toHaveBeenCalled(); + expect(showAlert).toHaveBeenCalledWith('settings.obsStopFailed'); + expect(await drain()).toBe(false); + }); + + it('오버레이 기준점 저장 실패를 되돌리고 안내한다', async () => { + const before = useSettingsStore.getState().overlayResizeAnchor; + mocks.setAnchor.mockRejectedValue(new Error('anchor failed')); + choose( + 'settings.resizeAnchor', + before === 'top-left' ? 'bottom-right' : 'top-left', + ); + await act(async () => { + await Promise.resolve(); + }); + expect(useSettingsStore.getState().overlayResizeAnchor).toBe(before); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('사운드 출력 저장 실패를 안내하고 실제 장치 선택을 다시 읽는다', async () => { + mocks.setSoundBackend.mockRejectedValue(new Error('sound failed')); + const select = Array.from(host.querySelectorAll('select')).find((node) => + Array.from(node.options).some( + (option) => option.textContent === 'Speaker', + ), + )!; + act(() => { + select.value = Array.from(select.options).find( + (option) => option.textContent === 'Speaker', + )!.value; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(select.value).toBe('defaultDevice'); + expect(mocks.getSoundState).toHaveBeenCalledTimes(2); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('데이터 없는 플러그인은 사전 조회와 뒤따르는 제거까지 정산한다', async () => { + let finishCheck!: (value: boolean) => void; + let finishRemove!: (value: unknown) => void; + mocks.hasPluginData.mockReturnValue( + new Promise((resolve) => { + finishCheck = resolve; + }), + ); + mocks.removePlugin.mockReturnValue( + new Promise((resolve) => { + finishRemove = resolve; + }), + ); + act(() => button('settings.managePlugins').click()); + act(() => + host.querySelector('[data-remove-plugin]')!.click(), + ); + const drain = beginEditorWriteBarrier(); + let settled = false; + const result = drain().then((value) => { + settled = true; + return value; + }); + await act(async () => { + await Promise.resolve(); + }); + const settledBeforeCheck = settled; + await act(async () => { + finishCheck(false); + }); + const settledBeforeRemove = settled; + await act(async () => { + finishRemove({ success: true }); + await result; + }); + expect(settledBeforeCheck).toBe(false); + expect(settledBeforeRemove).toBe(false); + expect(await result).toBe(true); + expect(mocks.removePlugin).toHaveBeenCalledOnce(); + expect(mocks.clearPluginData).not.toHaveBeenCalled(); + }); + + it('데이터가 있는 플러그인은 확인창을 열고 정산 대기를 해제한다', async () => { + let finishCheck!: (value: boolean) => void; + mocks.hasPluginData.mockReturnValue( + new Promise((resolve) => { + finishCheck = resolve; + }), + ); + act(() => button('settings.managePlugins').click()); + act(() => + host.querySelector('[data-remove-plugin]')!.click(), + ); + const drain = beginEditorWriteBarrier(); + let settled = false; + const result = drain().then((value) => { + settled = true; + return value; + }); + await act(async () => { + await Promise.resolve(); + }); + const settledBeforeCheck = settled; + await act(async () => { + finishCheck(true); + await result; + }); + expect(settledBeforeCheck).toBe(false); + expect(await result).toBe(true); + expect(host.querySelector('[data-delete-with-data]')).not.toBeNull(); + expect(mocks.removePlugin).not.toHaveBeenCalled(); + expect(mocks.clearPluginData).not.toHaveBeenCalled(); + }); + + it('플러그인 사전 조회가 실패하면 삭제하지 않고 정산도 실패한다', async () => { + mocks.hasPluginData.mockRejectedValue(new Error('read failed')); + act(() => button('settings.managePlugins').click()); + const drain = beginEditorWriteBarrier(); + await act(async () => + host.querySelector('[data-remove-plugin]')!.click(), + ); + expect(await drain()).toBe(false); + expect(mocks.removePlugin).not.toHaveBeenCalled(); + expect(mocks.clearPluginData).not.toHaveBeenCalled(); + expect(showAlert).toHaveBeenCalledWith('settings.jsPluginRemoveFailed'); + }); + + it('플러그인 제거 성공 후에만 선택한 저장 데이터를 삭제한다', async () => { + mocks.removePlugin.mockResolvedValue({ success: true }); + act(() => button('settings.managePlugins').click()); + await act(async () => + host.querySelector('[data-remove-plugin]')!.click(), + ); + await act(async () => + host.querySelector('[data-delete-with-data]')!.click(), + ); + expect(mocks.clearPluginData).toHaveBeenCalledWith('plugin-a/'); + expect(mocks.removePlugin.mock.invocationCallOrder[0]).toBeLessThan( + mocks.clearPluginData.mock.invocationCallOrder[0], + ); + }); + + it('언어 저장 응답을 기다리는 동안 중복 선택을 막고 성공 후 반영한다', async () => { + let finish!: () => void; + mocks.changeLanguage.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + choose('settings.language', 'en'); + const select = host.querySelector( + '[data-label="settings.language"] select', + )!; + expect(select.disabled).toBe(true); + expect(useSettingsStore.getState().language).toBe('ko'); + await act(async () => { + finish(); + await Promise.resolve(); + }); + expect(select.disabled).toBe(false); + expect(useSettingsStore.getState().language).toBe('en'); + expect(mocks.changeLanguage).toHaveBeenCalledOnce(); + }); + + it.each([ + 'settings.alwaysOnTop', + 'settings.overlayLock', + 'settings.noteEffect', + 'settings.trayEnabled', + 'settings.autoUpdate', + 'settings.developerMode', + 'settings.keyCounter', + ])('%s 저장 실패를 표시한다', async (label) => { + mocks.update.mockRejectedValue(new Error('write failed')); + mocks.setLock.mockRejectedValue(new Error('write failed')); + const toggle = host.querySelector( + `[data-toggle="${label}"]`, + )!; + expect(toggle).not.toBeNull(); + const before = toggle.getAttribute('aria-pressed'); + await act(async () => toggle.click()); + expect(toggle.getAttribute('aria-pressed')).toBe(before); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); +}); diff --git a/src/renderer/components/main/Settings.obsController.test.tsx b/src/renderer/components/main/Settings.obsController.test.tsx index 27f194b8e..1826491af 100644 --- a/src/renderer/components/main/Settings.obsController.test.tsx +++ b/src/renderer/components/main/Settings.obsController.test.tsx @@ -483,7 +483,8 @@ describe('Settings OBS controller surface', () => { }, ); - it('OBS start 뒤 settings 저장 실패도 optimistic 상태를 rollback하고 finally에서 gate를 연다', async () => { + // 시작 명령은 끝났고 저장만 실패한 경우 - 서버는 실제로 떠 있으므로 표시를 되돌리지 않는다 + it('OBS start 뒤 settings 저장만 실패하면 실제 실행 상태를 유지하고 finally에서 gate를 연다', async () => { obsHarness.settingsUpdate.mockRejectedValueOnce( new Error('settings update failure'), ); @@ -497,13 +498,16 @@ describe('Settings OBS controller surface', () => { expect(obsHarness.settingsUpdate).toHaveBeenCalledWith({ obsModeEnabled: true, }); - expect(obsToggle().getAttribute('aria-pressed')).toBe('false'); - expect(showAlert).toHaveBeenCalledWith('settings.obsStartFailed'); + expect(obsToggle().getAttribute('aria-pressed')).toBe('true'); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); obsHarness.settingsUpdate.mockResolvedValue(undefined); act(() => obsToggle().click()); await settle(); - expect(obsHarness.start).toHaveBeenCalledTimes(2); + expect(obsHarness.stop).toHaveBeenCalledOnce(); + expect(obsHarness.settingsUpdate).toHaveBeenLastCalledWith({ + obsModeEnabled: false, + }); }); it('OBS URL copy 성공은 완료 알림, clipboard 실패는 URL fallback을 표시한다', async () => { diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 85f10041e..3968646e1 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -1,4 +1,5 @@ -import React, { startTransition, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useSingleFlightAction } from '@hooks/useSingleFlightAction'; import { useLenis } from '@hooks/useLenis'; import { useTranslation } from '@contexts/useTranslation'; import { useSettingsStore } from '@stores/useSettingsStore'; @@ -7,6 +8,7 @@ import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import Dropdown from '@components/main/common/dropdown/Dropdown'; import ReloadButton from '@components/main/common/ReloadButton'; @@ -54,7 +56,7 @@ interface SettingsProps { showAlert: (msg: string, confirmText?: string) => void; showConfirm: ( msg: string, - onConfirm: () => void, + onConfirm: () => void | Promise, options?: { onCancel?: () => void; confirmText?: string; @@ -90,6 +92,7 @@ const Settings = ({ useCustomCSS, setUseCustomCSS, customCSSPath, + customCSSContent, useCustomJS, setUseCustomJS, jsPlugins, @@ -133,6 +136,8 @@ const Settings = ({ const enqueueResizeAnchor = useOverlayResizeAnchorController({ overlayResizeAnchor, setOverlayResizeAnchor, + t, + showAlert, }); // Lenis smooth scroll 적용 (전역 설정 사용) @@ -208,6 +213,7 @@ const Settings = ({ } catch (error) { setAlwaysOnTop(!next); console.error('Failed to toggle always-on-top', error); + showAlert(t('common.saveFailed')); } }; @@ -219,6 +225,7 @@ const Settings = ({ } catch (error) { setOverlayLocked(!next); console.error('Failed to toggle overlay lock', error); + showAlert(t('common.saveFailed')); } }; @@ -230,6 +237,7 @@ const Settings = ({ } catch (error) { setUseCustomCSS(!next); console.error('Failed to toggle custom CSS', error); + showAlert(t('common.saveFailed')); } }; @@ -241,6 +249,7 @@ const Settings = ({ } catch (error) { setUseCustomJS(!next); console.error('Failed to toggle custom JS', error); + showAlert(t('common.saveFailed')); } }; @@ -285,6 +294,7 @@ const Settings = ({ } catch (error) { setNoteEffect(!next); console.error('Failed to toggle note effect', error); + showAlert(t('common.saveFailed')); } }; @@ -309,12 +319,15 @@ const Settings = ({ if (isMacOS || angleModeChangeRef.current) return; angleModeChangeRef.current = true; const apply = async (): Promise => { - setAngleMode(val); + let saved = false; try { await settingsApi.update({ angleMode: val }); + saved = true; + setAngleMode(val); await appApi.restart(); } catch (error) { console.error('Failed to change angle mode', error); + showAlert(t(saved ? 'common.restartFailed' : 'common.saveFailed')); } finally { angleModeChangeRef.current = false; } @@ -339,6 +352,7 @@ const Settings = ({ } catch (error) { setTrayEnabled(!next); console.error('Failed to toggle tray mode', error); + showAlert(t('common.saveFailed')); } }; @@ -350,6 +364,7 @@ const Settings = ({ } catch (error) { setAutoUpdateEnabled(!next); console.error('Failed to toggle auto update', error); + showAlert(t('common.saveFailed')); } }; @@ -367,6 +382,7 @@ const Settings = ({ } catch (error) { setDeveloperModeEnabled(!next); console.error('Failed to toggle developer mode', error); + showAlert(t('common.saveFailed')); } }; @@ -378,6 +394,7 @@ const Settings = ({ } catch (error) { setKeyCounterEnabled(!next); console.error('Failed to toggle key counter', error); + showAlert(t('common.saveFailed')); } }; @@ -413,6 +430,7 @@ const Settings = ({ statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, }; assertCanonicalEditorDocument(candidate, 'keys_reset_all response'); @@ -445,6 +463,7 @@ const Settings = ({ } } catch (error) { console.error('Failed to reset presets', error); + showAlert(t('common.actionFailed')); } finally { resetAllRef.current = false; } @@ -462,12 +481,16 @@ const Settings = ({ } }; - const handleLanguageChange = (val: string): void => { - startTransition(() => { - setLanguage(val); - void i18n.changeLanguage(val as SupportedLocale); + const { run: handleLanguageChange, pending: languagePending } = + useSingleFlightAction(async (val: string) => { + try { + await i18n.changeLanguage(val as SupportedLocale); + setLanguage(val); + } catch (error) { + console.error('Failed to change language', error); + showAlert(t('common.saveFailed')); + } }); - }; return (
@@ -634,6 +657,7 @@ const Settings = ({ hoverPreview('keySoundOutput')} onMouseLeave={() => hoverPreview(null)} + onSaveFailed={() => showAlert(t('common.saveFailed'))} /> {/* 기타 설정 */} @@ -642,6 +666,7 @@ const Settings = ({ options={LANGUAGE_OPTIONS} value={language} onChange={handleLanguageChange} + disabled={languagePending} placeholder={t('settings.selectLanguage')} align="right" /> @@ -757,6 +782,7 @@ const Settings = ({ showAlert?.(msg)} onClose={() => setActiveSettingsPanel(null)} diff --git a/src/renderer/components/main/SettingsPanel/CssPanelContent.test.tsx b/src/renderer/components/main/SettingsPanel/CssPanelContent.test.tsx new file mode 100644 index 000000000..2cb941a45 --- /dev/null +++ b/src/renderer/components/main/SettingsPanel/CssPanelContent.test.tsx @@ -0,0 +1,191 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CustomCssHistoryItem } from '@src/types/plugin/api'; + +const mocks = vi.hoisted(() => ({ + setContent: vi.fn(), + historyGet: vi.fn(), + historyActivate: vi.fn(), + load: vi.fn(), + t: (key: string) => key, +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: mocks.t }), +})); +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ scrollContainerRef: null }), +})); +vi.mock('@api/modules/resources/cssApi', () => ({ cssApi: mocks })); + +import CssPanelContent from './CssPanelContent'; + +const deferred = () => { + let resolve!: (value: { success: boolean; error?: string }) => void; + let reject!: (error: Error) => void; + const promise = new Promise<{ success: boolean; error?: string }>( + (res, rej) => { + resolve = res; + reject = rej; + }, + ); + return { promise, resolve, reject }; +}; + +describe('내장 CSS 토큰 칩과 제거', () => { + let root: Root; + let host: HTMLDivElement; + let props: React.ComponentProps; + const showAlert = vi.fn(); + const onHistoryCountChange = vi.fn(); + const file: CustomCssHistoryItem = { + path: '/themes/keys.css', + status: 'available', + lastUsedAt: 1, + }; + const button = (key: string) => + Array.from(host.querySelectorAll('button')).find( + (node) => node.textContent === key, + ); + const removeButton = () => + host.querySelector( + 'button[aria-label="settings.cssEmbeddedRemove"]', + ); + const render = async ( + patch: Partial> = {}, + ) => { + props = { ...props, ...patch }; + await act(async () => root.render()); + }; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.setContent.mockReset().mockResolvedValue({ success: true }); + mocks.historyGet.mockReset().mockResolvedValue([]); + mocks.historyActivate.mockReset(); + mocks.load.mockReset(); + vi.stubGlobal('api', { css: { historyGet: mocks.historyGet } }); + showAlert.mockReset(); + onHistoryCountChange.mockReset(); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + props = { + useCustomCSS: true, + customCSSPath: null, + customCSSContent: '[data-key-element] { opacity: 0; }', + onToggleCustomCSS: vi.fn(), + onClose: vi.fn(), + showAlert, + onHistoryCountChange, + }; + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('파일 없는 원문은 상태 카드 칩으로 보이고 목록·개수에는 들어가지 않는다', async () => { + await render(); + expect(host.textContent).toContain('settings.cssEmbedded'); + expect(host.textContent).not.toContain('settings.noCssFile'); + expect(host.textContent).toContain('settings.cssHistoryEmpty'); + expect(removeButton()).not.toBeNull(); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(0); + }); + + it('비활성화해도 칩과 제거 버튼을 유지하고 별도 문구는 붙이지 않는다', async () => { + await render({ useCustomCSS: false }); + expect(host.textContent).toContain('settings.cssEmbedded'); + expect(host.textContent).not.toContain('settings.cssApplied'); + expect(removeButton()?.disabled).toBe(false); + }); + + it('원문도 없는 경우 기존 빈 상태를 유지한다', async () => { + await render({ customCSSContent: '' }); + expect(host.textContent).toContain('settings.noCssFile'); + expect(host.textContent).toContain('settings.cssHistoryEmpty'); + expect(removeButton()).toBeNull(); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(0); + }); + + it('파일 CSS를 내장 CSS로 중복 표시하지 않는다', async () => { + mocks.historyGet.mockResolvedValue([file]); + await render({ customCSSPath: file.path }); + expect(host.textContent).toContain('keys.css'); + expect(host.textContent).not.toContain('settings.cssEmbedded'); + expect(removeButton()).toBeNull(); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(1); + }); + + it('개수는 파일 이력만 세고 원문 유무에 흔들리지 않는다', async () => { + mocks.historyGet.mockResolvedValue([file]); + await render(); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(1); + await render({ customCSSContent: '', useCustomCSS: false }); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(1); + expect(host.textContent).toContain('keys.css'); + expect(removeButton()).toBeNull(); + }); + + it('제거는 원문만 비우고 한 번만 저장하며 완료 전에는 CSS를 교체하지 못한다', async () => { + const pending = deferred(); + mocks.setContent.mockReturnValue(pending.promise); + mocks.historyGet.mockResolvedValue([file]); + await render(); + act(() => { + removeButton()!.click(); + removeButton()!.click(); + }); + expect(mocks.setContent).toHaveBeenCalledTimes(1); + expect(mocks.setContent).toHaveBeenCalledWith(''); + expect(removeButton()?.disabled).toBe(true); + expect(button('settings.loadCss')?.disabled).toBe(true); + expect(button('settings.cssApply')?.disabled).toBe(true); + expect(host.textContent).toContain('settings.cssEmbedded'); + await act(async () => pending.resolve({ success: true })); + await render({ customCSSContent: '' }); + expect(removeButton()).toBeNull(); + expect(host.textContent).toContain('settings.noCssFile'); + expect(showAlert).not.toHaveBeenCalled(); + }); + + it('제거 실패 시 칩을 보존하고 알린 뒤 재시도할 수 있다', async () => { + mocks.setContent.mockRejectedValueOnce(new Error('IO_ERROR')); + await render(); + await act(async () => removeButton()!.click()); + expect(showAlert).toHaveBeenCalledWith('settings.cssEmbeddedRemoveFailed'); + expect(host.textContent).toContain('settings.cssEmbedded'); + expect(removeButton()?.disabled).toBe(false); + await act(async () => removeButton()!.click()); + expect(mocks.setContent).toHaveBeenCalledTimes(2); + }); + + it('실패 응답도 알림으로 전달한다', async () => { + mocks.setContent.mockResolvedValueOnce({ + success: false, + error: 'TOO_LARGE', + }); + await render(); + await act(async () => removeButton()!.click()); + expect(showAlert).toHaveBeenCalledWith('settings.cssEmbeddedRemoveFailed'); + expect(host.textContent).toContain('settings.cssEmbedded'); + }); + + it('늦은 제거 응답이 더 최신 CSS 상태를 덮어쓰지 않는다', async () => { + const pending = deferred(); + mocks.setContent.mockReturnValue(pending.promise); + await render(); + act(() => removeButton()!.click()); + await render({ customCSSPath: file.path, customCSSContent: '.new {}' }); + await act(async () => pending.resolve({ success: true })); + expect(host.textContent).toContain('keys.css'); + expect(host.textContent).not.toContain('settings.noCssFile'); + expect(removeButton()).toBeNull(); + }); +}); diff --git a/src/renderer/components/main/SettingsPanel/CssPanelContent.tsx b/src/renderer/components/main/SettingsPanel/CssPanelContent.tsx index 7cd7023ad..6dec0a66b 100644 --- a/src/renderer/components/main/SettingsPanel/CssPanelContent.tsx +++ b/src/renderer/components/main/SettingsPanel/CssPanelContent.tsx @@ -19,6 +19,8 @@ import { PANEL_ROW_NAME_INACTIVE_CLASS, PANEL_SECTION_CLASS, PANEL_STATUS_BADGE_CLASS, + PANEL_TOKEN_CHIP_CLASS, + PANEL_TOKEN_REMOVE_CLASS, } from '@components/main/SettingsPanel/panelChrome'; import { SettingToggleRow } from '@components/main/common/SettingRow'; import { SETTINGS_LABEL_CLASS, SETTINGS_ROW_CLASS } from '@utils/cardRecipes'; @@ -32,6 +34,7 @@ import type { CustomCssHistoryItem, } from '@src/types/plugin/api'; import { cssApi } from '@api/modules/resources/cssApi'; +import { useSingleFlightAction } from '@hooks/useSingleFlightAction'; const CSS_HISTORY_ERROR_CODES: ReadonlySet = new Set([ 'PATH_NOT_AUTHORIZED', @@ -46,6 +49,7 @@ const CSS_HISTORY_ERROR_CODES: ReadonlySet = new Set([ interface CssPanelContentProps { useCustomCSS: boolean; customCSSPath: string | null; + customCSSContent: string; onToggleCustomCSS: () => void; showAlert: (msg: string) => void; onClose: () => void; @@ -59,12 +63,14 @@ let cssHistoryCache: CustomCssHistoryItem[] | null = null; const CssPanelContent = ({ useCustomCSS, customCSSPath, + customCSSContent, onToggleCustomCSS, showAlert, onClose, onHistoryCountChange, }: CssPanelContentProps) => { const { t } = useTranslation(); + const hasEmbeddedCSS = !customCSSPath && customCSSContent.length > 0; const [history, setHistory] = React.useState( cssHistoryCache ?? [], @@ -111,10 +117,28 @@ const CssPanelContent = ({ void refreshHistory(); }, [onHistoryCountChange, refreshHistory]); + // 파일 없는 원문만 비운다 - reset은 마스터 토글까지 꺼서 누른 순간 부작용처럼 보인다 + const { run: handleRemoveEmbedded, pending: isRemoving } = + useSingleFlightAction(async () => { + if (!hasEmbeddedCSS || loadingNewRef.current || pendingPathRef.current) + return; + try { + const result = await cssApi.setContent(''); + if (!result.success) { + showAlert(t('settings.cssEmbeddedRemoveFailed')); + } + } catch (error) { + console.error('Failed to remove embedded CSS', error); + showAlert(t('settings.cssEmbeddedRemoveFailed')); + } + }); + + const isChangingCSS = isRemoving || isLoadingNew || pendingPath !== null; + // 성공 시 스토어 갱신은 css:content 이벤트 구독(useAppBootstrap)에 일임 // 응답으로 직접 쓰면 더 최신 이벤트를 이전 응답이 되덮을 수 있음 const handleActivate = async (item: CustomCssHistoryItem): Promise => { - if (pendingPathRef.current) return; + if (pendingPathRef.current || loadingNewRef.current || isRemoving) return; if (item.path === customCSSPath) return; pendingPathRef.current = item.path; setPendingPath(item.path); @@ -152,7 +176,7 @@ const CssPanelContent = ({ }; const handleLoadNew = async (): Promise => { - if (loadingNewRef.current) return; + if (loadingNewRef.current || pendingPathRef.current || isRemoving) return; loadingNewRef.current = true; setIsLoadingNew(true); try { @@ -187,21 +211,52 @@ const CssPanelContent = ({ checked={useCustomCSS} onToggle={onToggleCustomCSS} /> - {/* 적용 중인 파일 - 전체 경로는 툴팁 */} + {/* 현재 CSS - 파일이면 이름(경로는 툴팁), 파일 없는 원문이면 제거 가능한 토큰 칩 */}
- {t('settings.cssActiveFile')} - - - {customCSSPath && customCSSPath.length > 0 - ? pathBaseName(customCSSPath) - : t('settings.noCssFile')} + {t('settings.cssSource')} + {hasEmbeddedCSS ? ( + + {t('settings.cssEmbedded')} + + + ) : ( + + {customCSSPath && customCSSPath.length > 0 + ? pathBaseName(customCSSPath) + : t('settings.noCssFile')} + + )}
@@ -263,9 +318,9 @@ const CssPanelContent = ({ event.stopPropagation(); void handleActivate(item); }} - disabled={!available || pendingPath !== null} + disabled={!available || isChangingCSS} className={`${PANEL_PILL_CLASS} ${ - available && pendingPath === null + available && !isChangingCSS ? FILL_QUIET_CLASS : FILL_QUIET_DISABLED_CLASS }`} @@ -285,9 +340,9 @@ const CssPanelContent = ({
- )) + options.map((opt, index) => { + const isSelected = multiple + ? selectedValues.includes(opt.value) + : visualValue === opt.value; + return ( + + ); + }) )}
, ownerDocument.body, @@ -477,5 +544,6 @@ export const useDropdownRuntime = ({ ref, selected, toggleOpen, + triggerText, }; }; diff --git a/src/renderer/components/main/common/numberInput/NumberInput.scrub.test.tsx b/src/renderer/components/main/common/numberInput/NumberInput.scrub.test.tsx index b723abfca..f67faa0fc 100644 --- a/src/renderer/components/main/common/numberInput/NumberInput.scrub.test.tsx +++ b/src/renderer/components/main/common/numberInput/NumberInput.scrub.test.tsx @@ -4,6 +4,11 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NumberInput, OptionalNumberInput } from './NumberInput'; +import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; +import { + acquireHistoryEditorFlushLock, + resetHistoryEditorFlushLock, +} from '@src/renderer/editor/runtime/lifecycle/historyEditorFlushLock'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -86,6 +91,7 @@ describe('NumberInput 접두 스크럽', () => { act(() => root.unmount()); vi.unstubAllGlobals(); container.remove(); + resetHistoryEditorFlushLock(); }); it('드래그 중에는 preview만 흐르고 손을 떼면 onChange가 한 번 나간다', async () => { @@ -115,6 +121,74 @@ describe('NumberInput 접두 스크럽', () => { ); }); + it.each( + [ + { name: 'NumberInput', Input: NumberInput }, + { name: 'OptionalNumberInput', Input: OptionalNumberInput }, + ].flatMap((input) => + ['applied', 'locked'].map((boundary) => ({ ...input, boundary })), + ), + )( + '$name $boundary 취소는 드래그 전 값을 프리뷰로 다시 발행하지 않는다', + async ({ Input, boundary }) => { + const onPreview = vi.fn(); + const onChange = vi.fn(); + act(() => + root.render( + , + ), + ); + send('pointerdown', { clientX: 0 }); + send('pointermove', { clientX: 5 }); + await flushAfterPaintCommit(); + expect(onPreview).toHaveBeenLastCalledWith(15); + onPreview.mockClear(); + act(() => { + if (boundary === 'applied') + useCommittedApplyStore.getState().bump('historyUndo'); + else acquireHistoryEditorFlushLock('number-release'); + prefixEl().dispatchEvent(pointer('pointerup', { clientX: 5 })); + expect(onPreview).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + if (boundary === 'locked') + useCommittedApplyStore.getState().bump('historyUndo'); + root.render( + , + ); + }); + await flushAfterPaintCommit(); + expect(onPreview).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(inputEl().value).toBe('5'); + expect(captured.size).toBe(0); + }, + ); + + it('history 취소 사유를 외부 프리뷰 정리 콜백까지 전달한다', async () => { + const onCancel = vi.fn(); + const onChange = vi.fn(); + render({ onPreview: vi.fn(), onChange, onCancel }); + act(() => inputEl().focus()); + send('pointerdown', { clientX: 0 }); + send('pointermove', { clientX: 5 }); + await flushAfterPaintCommit(); + act(() => useCommittedApplyStore.getState().bump('historyRedo')); + send('pointerup', { clientX: 5 }); + act(() => inputEl().blur()); + expect(onCancel).toHaveBeenCalledExactlyOnceWith('history'); + expect(onChange).not.toHaveBeenCalled(); + }); + it('단위가 있는 값은 드래그 중과 부모 preview 뒤에 같은 문자열을 유지한다', async () => { const onPreview = vi.fn(); render({ onPreview, suffix: '%' }); @@ -510,6 +584,7 @@ describe('OptionalNumberInput 접두 스크럽', () => { act(() => root.unmount()); vi.unstubAllGlobals(); container.remove(); + resetHistoryEditorFlushLock(); }); it('값이 비어 있으면 placeholder 상속값에서 출발해 preview로 흐른다', async () => { diff --git a/src/renderer/components/main/common/numberInput/NumberInput.tsx b/src/renderer/components/main/common/numberInput/NumberInput.tsx index 9359be4f8..b2ecc4389 100644 --- a/src/renderer/components/main/common/numberInput/NumberInput.tsx +++ b/src/renderer/components/main/common/numberInput/NumberInput.tsx @@ -18,7 +18,7 @@ export interface NumberInputProps { /** 지정 시 타이핑은 preview로 흐르고 onChange는 blur/Enter 확정에만 호출됨 */ onPreview?: (value: number) => void; /** Escape 원복 시 호출 (게스처 취소 연동) */ - onCancel?: () => void; + onCancel?: (reason?: 'history') => void; min?: number; max?: number; prefix?: React.ReactNode; @@ -45,7 +45,7 @@ export interface OptionalNumberInputProps { /** 지정 시 타이핑은 preview로 흐르고 onChange는 blur/Enter 확정에만 호출됨 */ onPreview?: (value?: number) => void; /** Escape 원복 시 호출 (게스처 취소 연동) */ - onCancel?: () => void; + onCancel?: (reason?: 'history') => void; min?: number; max?: number; prefix?: React.ReactNode; diff --git a/src/renderer/components/main/common/numberInput/useNumericEditSession.ts b/src/renderer/components/main/common/numberInput/useNumericEditSession.ts index 3cc284676..831bfb6fd 100644 --- a/src/renderer/components/main/common/numberInput/useNumericEditSession.ts +++ b/src/renderer/components/main/common/numberInput/useNumericEditSession.ts @@ -27,7 +27,7 @@ interface NumericEditPolicyBase { decimalScale: number; isMixed: boolean; hasPrefix: boolean; - onCancel?: () => void; + onCancel?: (reason?: 'history') => void; onBlur?: (value?: number) => void; } @@ -472,7 +472,7 @@ export const useNumericEditSession = (policy: NumericEditPolicy) => { lastEmittedRef.current = null; invokeChange(final); }, - onCancel: () => { + onCancel: (reason) => { cancelPendingCommit(); const committed = committedValueRef.current; syncText( @@ -483,7 +483,12 @@ export const useNumericEditSession = (policy: NumericEditPolicy) => { hasUserInputRef.current = false; fieldError.clear(); digitPop.clear(); - restorePreview(); + // undo·redo가 스크럽을 끊으면 preview 복원 대신 history 취소를 그대로 전달 + if (reason === 'history') { + emittedRef.current = false; + lastEmittedRef.current = null; + onCancel?.('history'); + } else restorePreview(); }, }); diff --git a/src/renderer/components/main/settingsPluginLifecycleController.test.ts b/src/renderer/components/main/settingsPluginLifecycleController.test.ts index c1aa30768..70adc96ac 100644 --- a/src/renderer/components/main/settingsPluginLifecycleController.test.ts +++ b/src/renderer/components/main/settingsPluginLifecycleController.test.ts @@ -284,11 +284,11 @@ describe('Settings 플러그인 생명주기 controller', () => { expect(harness.setPluginToDelete).toHaveBeenCalledWith(null); }); - it('데이터 포함 제거는 remove 정산 뒤 prefix를 지우고 실패여도 모달 상태를 닫는다', async () => { + it('데이터 포함 제거는 remove 정산 뒤 prefix를 지우고, remove 실패 시 데이터는 남기되 모달 상태는 닫는다', async () => { const order: string[] = []; mocks.remove.mockImplementation(async () => { order.push('remove'); - return { success: false }; + return { success: true }; }); mocks.clearByPrefix.mockImplementation(async () => { order.push('clear'); @@ -301,6 +301,20 @@ describe('Settings 플러그인 생명주기 controller', () => { expect(order).toEqual(['remove', 'clear']); expect(mocks.clearByPrefix).toHaveBeenCalledWith('alpha/'); + + // 플러그인이 남아 있는데 저장 데이터만 지우면 안 된다 + order.length = 0; + mocks.clearByPrefix.mockClear(); + harness.setPendingPluginId.mockClear(); + mocks.remove.mockImplementation(async () => { + order.push('remove'); + return { success: false }; + }); + + await harness.removePluginWithData(alpha.id); + + expect(order).toEqual(['remove']); + expect(mocks.clearByPrefix).not.toHaveBeenCalled(); expect(harness.showAlert).toHaveBeenCalledWith( 'settings.jsPluginRemoveFailed', ); diff --git a/src/renderer/components/main/settingsPluginLifecycleController.ts b/src/renderer/components/main/settingsPluginLifecycleController.ts index df0051a55..d60817630 100644 --- a/src/renderer/components/main/settingsPluginLifecycleController.ts +++ b/src/renderer/components/main/settingsPluginLifecycleController.ts @@ -6,6 +6,7 @@ import { extractPluginId } from '@utils/plugin/components/pluginUtils'; import { classifyPluginAddResult } from '@utils/plugin/pluginAddResult'; import { jsApi } from '@api/modules/plugin/jsApi'; import { pluginApi } from '@api/modules/plugin/pluginApi'; +import { trackEditorWrite } from '@src/renderer/editor/runtime/lifecycle/editorWriteBarrier'; import type { I18nContextValue } from '@contexts/I18nContextDef'; import type { JsLoadResult, @@ -236,37 +237,41 @@ export const createSettingsPluginLifecycleController = ({ setPendingPluginId(pluginId); try { - // 실제 플러그인 네임스페이스 추출 (@id 또는 파일명 기반) - const pluginNamespace: string = extractPluginId( - plugin.content, - plugin.name, - ); - const pluginStorageNamespace = `${pluginNamespace}/`; + await trackEditorWrite( + (async () => { + // 실제 플러그인 네임스페이스 추출 (@id 또는 파일명 기반) + const pluginNamespace: string = extractPluginId( + plugin.content, + plugin.name, + ); + const pluginStorageNamespace = `${pluginNamespace}/`; - // 네임스페이스를 prefix로 사용하는 데이터가 있는지 확인 - // 백엔드에서 자동으로 "plugin_data_" 를 붙이므로 순수 네임스페이스만 전달 - const hasData: boolean = await pluginApi.storage.hasData( - pluginStorageNamespace, - ); - console.warn( - '[PluginRemove] namespace=', - pluginNamespace, - 'hasData=', - hasData, - ); + // 네임스페이스를 prefix로 사용하는 데이터가 있는지 확인 + // 백엔드에서 자동으로 "plugin_data_" 를 붙이므로 순수 네임스페이스만 전달 + const hasData: boolean = await pluginApi.storage.hasData( + pluginStorageNamespace, + ); + console.warn( + '[PluginRemove] namespace=', + pluginNamespace, + 'hasData=', + hasData, + ); - if (hasData) { - setPluginToDelete({ - id: pluginId, - name: plugin.name, - namespace: pluginNamespace, - }); - setDataDeleteModalOpen(true); - } else { - removingPluginRef.current = null; - setPendingPluginId(null); - await removePluginOnly(pluginId); - } + if (hasData) { + setPluginToDelete({ + id: pluginId, + name: plugin.name, + namespace: pluginNamespace, + }); + setDataDeleteModalOpen(true); + } else { + removingPluginRef.current = null; + setPendingPluginId(null); + await removePluginOnly(pluginId); + } + })(), + ); } catch (error) { console.error('Failed to check plugin data', error); showAlert?.(t('settings.jsPluginRemoveFailed')); @@ -319,6 +324,7 @@ export const createSettingsPluginLifecycleController = ({ const result: JsRemoveResult = await jsApi.remove(pluginId); if (!result.success) { showAlert?.(t('settings.jsPluginRemoveFailed')); + return; } // 2) 그 다음 스토리지 정리 → 클린업 중 재생성된 값까지 함께 제거 diff --git a/src/renderer/components/main/useObsSettingsController.ts b/src/renderer/components/main/useObsSettingsController.ts index d2da4e65b..fc63a07e5 100644 --- a/src/renderer/components/main/useObsSettingsController.ts +++ b/src/renderer/components/main/useObsSettingsController.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { obsApi } from '@api/modules/window/obsApi'; import { settingsApi } from '@api/modules/app/settingsApi'; +import { trackEditorWrite } from '@src/renderer/editor/runtime/lifecycle/editorWriteBarrier'; import type { I18nContextValue } from '@contexts/I18nContextDef'; import type { ObsStatus } from '@src/types/obs'; import { DEFAULT_OBS_PORT } from '@src/types/obs'; @@ -10,7 +11,7 @@ interface UseObsSettingsControllerOptions { showAlert: (msg: string, confirmText?: string) => void; showConfirm: ( msg: string, - onConfirm: () => void, + onConfirm: () => void | Promise, options?: { onCancel?: () => void; confirmText?: string; @@ -77,15 +78,27 @@ export const useObsSettingsController = ({ const next = !obsStatus.running; setObsStatus((prev) => ({ ...prev, running: next })); obsTogglingRef.current = true; + let commandCompleted = false; try { - const status = next ? await obsApi.start() : await obsApi.stop(); - setObsStatus(status); - await settingsApi.update({ obsModeEnabled: next }); + await trackEditorWrite( + (async () => { + const status = next ? await obsApi.start() : await obsApi.stop(); + commandCompleted = true; + setObsStatus(status); + await settingsApi.update({ obsModeEnabled: next }); + })(), + ); } catch (error) { console.error('Failed to toggle OBS mode', error); - setObsStatus((prev) => ({ ...prev, running: !next })); + if (!commandCompleted) { + setObsStatus((prev) => ({ ...prev, running: !next })); + } showAlert?.( - next ? t('settings.obsStartFailed') : t('settings.obsStopFailed'), + commandCompleted + ? t('common.saveFailed') + : next + ? t('settings.obsStartFailed') + : t('settings.obsStopFailed'), ); } finally { obsTogglingRef.current = false; @@ -115,6 +128,7 @@ export const useObsSettingsController = ({ setObsStatus(status); } catch (error) { console.error('Failed to regenerate OBS token', error); + showAlert(t('common.actionFailed')); } finally { regeneratingObsTokenRef.current = false; } diff --git a/src/renderer/components/main/useOverlayResizeAnchorController.ts b/src/renderer/components/main/useOverlayResizeAnchorController.ts index 07ff09168..1ba0f8010 100644 --- a/src/renderer/components/main/useOverlayResizeAnchorController.ts +++ b/src/renderer/components/main/useOverlayResizeAnchorController.ts @@ -1,15 +1,20 @@ import { useEffect, useRef } from 'react'; import { overlayApi } from '@api/modules/window/overlayApi'; +import type { I18nContextValue } from '@contexts/I18nContextDef'; import type { OverlayResizeAnchor } from '@src/types/settings/settings'; interface UseOverlayResizeAnchorControllerOptions { overlayResizeAnchor: OverlayResizeAnchor; setOverlayResizeAnchor: (anchor: OverlayResizeAnchor) => void; + t: I18nContextValue['t']; + showAlert: (msg: string, confirmText?: string) => void; } export const useOverlayResizeAnchorController = ({ overlayResizeAnchor, setOverlayResizeAnchor, + t, + showAlert, }: UseOverlayResizeAnchorControllerOptions) => { const pendingResizeAnchorRef = useRef(null); const applyingResizeAnchorRef = useRef(false); @@ -36,6 +41,7 @@ export const useOverlayResizeAnchorController = ({ confirmedResizeAnchorRef.current = requested; } catch (error) { console.error('Failed to set overlay anchor', error); + showAlert(t('common.saveFailed')); if (!pendingResizeAnchorRef.current) { setOverlayResizeAnchor(confirmedResizeAnchorRef.current); } diff --git a/src/renderer/components/overlay/WebGLTracksOGL.tsx b/src/renderer/components/overlay/WebGLTracksOGL.tsx index 96ed09848..804b87836 100644 --- a/src/renderer/components/overlay/WebGLTracksOGL.tsx +++ b/src/renderer/components/overlay/WebGLTracksOGL.tsx @@ -20,11 +20,13 @@ import { resolvedGlowSize, type TrackLayoutInput, } from '@stores/signals/noteBuffer'; +import { trackRectFromOrigin } from '@utils/layout/trackGeometry'; const vertexShader = ` attribute vec3 position; - attribute vec3 noteInfo; // x: startTime, y: endTime, z: trackX - attribute vec2 noteSize; // x: width, y: trackBottomY + attribute vec3 noteInfo; // x: startTime, y: endTime, z: origin x + attribute vec2 noteSize; // x: 교차축 폭, y: origin y + attribute vec2 noteDir; // 진행 방향 단위벡터 d (캔버스 좌표, y 아래 양수) attribute vec4 noteColorTop; attribute vec4 noteColorBottom; attribute float noteRadius; @@ -60,14 +62,14 @@ const vertexShader = ` varying vec2 vBorderGradient; // x: LUT 행, y: 각도 라디안 varying vec3 vBodyPaint; varying vec4 vGlowPaint; - varying float vTrackTopY; - varying float vTrackBottomY; + // 흐름 비율 (1 - s/H). 글로우 확장 정점의 raw 값을 보간하고 fragment에서 clamp + varying float vFlowRatioRaw; void main() { float startTime = noteInfo.x; float endTime = noteInfo.y; - float trackX = noteInfo.z; - float trackBottomY = noteSize.y; + // O: 히트라인의 c=0 코너, s는 O에서 진행 방향으로 잰 거리 + vec2 origin = vec2(noteInfo.z, noteSize.y); float noteWidth = noteSize.x; if (startTime == 0.0) { @@ -89,49 +91,47 @@ const vertexShader = ` rawNoteLength = max(0.0, (endTime - startTime) * uFlowSpeed / 1000.0); } - float noteLength = min(rawNoteLength, uTrackHeight); - float noteTopY; - float noteBottomY; - + // 활성 [0, len], 완료 [travel, travel+len] + float len = min(rawNoteLength, uTrackHeight); + float rawLo; + float rawHi; if (isActive) { - if (uReverse < 0.5) { - noteBottomY = trackBottomY; - noteTopY = trackBottomY - noteLength; - } else { - float trackTopY_local = trackBottomY - uTrackHeight; - noteTopY = trackTopY_local; - noteBottomY = trackTopY_local + noteLength; - } + rawLo = 0.0; + rawHi = len; } else { - if (uReverse < 0.5) { - float travel = (uTime - endTime) * uFlowSpeed / 1000.0; - noteBottomY = trackBottomY - travel; - noteTopY = noteBottomY - noteLength; - } else { - float travel = (uTime - endTime) * uFlowSpeed / 1000.0; - float trackTopY_local = trackBottomY - uTrackHeight; - noteTopY = trackTopY_local + travel; - noteBottomY = noteTopY + noteLength; - } + float travel = (uTime - endTime) * uFlowSpeed / 1000.0; + rawLo = travel; + rawHi = travel + len; } - float trackTopY = trackBottomY - uTrackHeight; - noteTopY = max(noteTopY, trackTopY); - noteBottomY = min(noteBottomY, trackBottomY); + // 리버스 = s 공간 미러 (진행 방향과 독립) + if (uReverse >= 0.5) { + float mirroredLo = uTrackHeight - rawHi; + rawHi = uTrackHeight - rawLo; + rawLo = mirroredLo; + } - // noteBottomY < noteTopY: 트랙을 벗어난 역방향 완료 노트 — - // 음수 길이 쿼드 래스터라이즈와 clamp(r, 0, 음수) undefined 방지 - // strict less로 길이 0(스폰 프레임 글로우 퍼프)은 보존 - if (noteBottomY <= trackTopY || noteBottomY < 0.0 || noteBottomY < noteTopY) { + // 컬링은 raw 구간으로 선판정 - 클램프 뒤에 판정하면 트랙을 벗어난 완료 노트가 + // 0길이로 접혀 잔류한다. 키쪽 경계는 strict less로 스폰 프레임 길이 0 글로우 퍼프 보존 + if (rawLo >= uTrackHeight || rawHi < 0.0 || rawHi < rawLo) { gl_Position = vec4(2.0, 2.0, 2.0, 0.0); vColorTop = vec4(0.0); vColorBottom = vec4(0.0); return; } - noteLength = noteBottomY - noteTopY; - float centerCanvasY = (noteTopY + noteBottomY) / 2.0; - float centerWorldY = uScreenHeight - centerCanvasY; + float sLo = clamp(rawLo, 0.0, uTrackHeight); + float sHi = clamp(rawHi, 0.0, uTrackHeight); + float noteLength = sHi - sLo; + + vec2 dir = noteDir; + vec2 perp = vec2(-dir.y, dir.x); + float centerS = (sLo + sHi) / 2.0; + vec2 centerCanvas = origin + dir * centerS + perp * (noteWidth / 2.0); + // 캔버스(y 아래) → 월드(y 위): y만 뒤집기 + vec2 centerWorld = vec2(centerCanvas.x, uScreenHeight - centerCanvas.y); + vec2 dirWorld = vec2(dir.x, -dir.y); + vec2 perpWorld = vec2(perp.x, -perp.y); // SDF의 1px AA가 primitive 경계에서 잘리지 않게 최소 halo 보장 // 길이 0·글로우 없음은 기존처럼 degenerate quad를 유지 @@ -140,14 +140,12 @@ const vertexShader = ` float expandedWidth = noteWidth + quadHalo * 2.0; float expandedLength = noteLength + quadHalo * 2.0; - vec3 transformed = vec3(position.x, position.y, position.z); - transformed.x *= expandedWidth; - transformed.y *= expandedLength; - transformed.x += trackX + noteWidth / 2.0; - transformed.y += centerWorldY; - transformed.z = 0.0; + // 쿼드 로컬: position.x = 교차축, position.y = 진행축 + vec2 planar = centerWorld + + dirWorld * (position.y * expandedLength) + + perpWorld * (position.x * expandedWidth); - gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(planar, 0.0, 1.0); vColorTop = noteColorTop; vColorBottom = noteColorBottom; @@ -163,16 +161,17 @@ const vertexShader = ` vBorderGradient = noteBorderGradientInfo; vBodyPaint = noteBodyPaint; vGlowPaint = noteGlowPaint; - vTrackTopY = trackTopY; - vTrackBottomY = trackBottomY; + // 확장 정점의 raw s로 흐름 비율 (1 = 히트라인쪽, 0 = 먼쪽) + float sVertex = centerS + position.y * expandedLength; + vFlowRatioRaw = 1.0 - sVertex / max(uTrackHeight, 0.0001); } `; const fragmentShader = ` precision highp float; - uniform float uCanvasBottomDomY; uniform float uDomPerPx; + uniform float uTrackHeight; uniform float uFadeTopPx; uniform float uFadeBottomPx; uniform sampler2D uGradientLUT; @@ -191,8 +190,7 @@ const fragmentShader = ` varying vec2 vBorderGradient; // x: LUT 행, y: 각도 라디안 varying vec3 vBodyPaint; // x: 행 (-1 = direct), y: 각도, z: 배율 varying vec4 vGlowPaint; // x: 행, y: 각도, z: 배율, w: LUT 알파 사용 - varying float vTrackTopY; - varying float vTrackBottomY; + varying float vFlowRatioRaw; // 트랙 rect 기준 CSS linear-gradient 투영으로 LUT 샘플 (전 표면 공유) vec4 sampleGradientLUT(float row, float angleRad, float nx, float ny, float boxW, float boxH) { @@ -206,12 +204,9 @@ const fragmentShader = ` } void main() { - // gl_FragCoord는 crop된 캔버스 기준 물리 픽셀 단위 - // uDomPerPx = cssHeight / drawingBufferHeight — 비정수 DPR의 backing 반올림 반영 - // max()는 uniform 미설정/0 방어 - float currentDOMY = uCanvasBottomDomY - gl_FragCoord.y * max(uDomPerPx, 0.0001); - float trackHeight = max(vTrackBottomY - vTrackTopY, 0.0001); - float gradientRatio = clamp((currentDOMY - vTrackTopY) / trackHeight, 0.0, 1.0); + // 흐름 비율: 0 = 먼쪽 끝, 1 = 키(히트라인)쪽. 버텍스 raw 보간 후 여기서 clamp + float trackHeight = max(uTrackHeight, 0.0001); + float gradientRatio = clamp(vFlowRatioRaw, 0.0, 1.0); float trackRelativeY = gradientRatio; vec4 baseColor = mix(vColorTop, vColorBottom, gradientRatio); @@ -263,7 +258,8 @@ const fragmentShader = ` float r = clamp(vRadius, 0.0, min(vHalfSize.x, vHalfSize.y)); vec2 q = abs(vLocalPos) - (vHalfSize - vec2(r)); float dist = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; - // AA 폭은 물리 픽셀 1개(CSS px 단위) - backing 배율이 올라가면 경계 램프가 그만큼 좁아진다 + // AA 폭은 물리 픽셀 1개(CSS px 단위) - backing 배율이 올라가면 경계 램프가 그만큼 좁아진다. + // uDomPerPx = cssHeight / drawingBufferHeight, 비정수 DPR의 backing 반올림 반영. max()는 미설정/0 방어 float aa = max(uDomPerPx, 0.0001); // 테두리 디코딩: width에 side mode 인코딩됨 (0~20=all, 100~120=vertical, 200~220=horizontal) @@ -368,6 +364,7 @@ const buildPlaneGeometry = (gl: OGLRenderingContext): Geometry => const INSTANCED_ATTRIBUTE_KEYS: readonly string[] = Object.freeze([ 'noteInfo', 'noteSize', + 'noteDir', 'noteColorTop', 'noteColorBottom', 'noteRadius', @@ -482,6 +479,7 @@ interface AppliedCrop { } const CROP_AA_PAD = 2; +const UP_DIRECTION = Object.freeze({ x: 0, y: -1 }); const computeTrackBounds = ( tracks: TrackLayoutInput[], @@ -494,11 +492,17 @@ const computeTrackBounds = ( let maxY = -Infinity; for (const track of tracks) { const pad = resolvedGlowSize(track) + CROP_AA_PAD; - minX = Math.min(minX, track.position.dx - pad); - maxX = Math.max(maxX, track.position.dx + track.width + pad); // 셰이더는 uTrackHeight 기준으로 노트를 클램프하므로 per-track height 대신 설정값 사용 - minY = Math.min(minY, track.position.dy - trackHeight - pad); - maxY = Math.max(maxY, track.position.dy + pad); + const rect = trackRectFromOrigin( + { x: track.position.dx, y: track.position.dy }, + track.direction ?? UP_DIRECTION, + trackHeight, + track.width, + ); + minX = Math.min(minX, rect.minX - pad); + maxX = Math.max(maxX, rect.maxX + pad); + minY = Math.min(minY, rect.minY - pad); + maxY = Math.max(maxY, rect.maxY + pad); } // 뷰포트 교집합 + floor/ceil 반올림 (1px 클리핑·떨림 방지) const x = Math.max(0, Math.floor(minX)); @@ -550,6 +554,7 @@ interface NoteBuffer { maybeRebaseEpoch(nowMs: number): boolean; noteInfo: Float32Array; noteSize: Float32Array; + noteDir: Float32Array; noteColorTop: Float32Array; noteColorBottom: Float32Array; noteRadius: Float32Array; @@ -654,6 +659,12 @@ export function WebGLTracksOGL({ data: noteBuffer.noteSize, usage: gl.DYNAMIC_DRAW, }); + geometry.addAttribute('noteDir', { + instanced: 1, + size: 2, + data: noteBuffer.noteDir, + usage: gl.DYNAMIC_DRAW, + }); geometry.addAttribute('noteColorTop', { instanced: 1, size: 4, @@ -757,7 +768,6 @@ export function WebGLTracksOGL({ value: noteSettings.speed || DEFAULT_NOTE_SETTINGS.speed, }, uScreenHeight: { value: window.innerHeight }, - uCanvasBottomDomY: { value: window.innerHeight }, uDomPerPx: { value: 1 }, uTrackHeight: { value: noteSettings.trackHeight || DEFAULT_NOTE_SETTINGS.trackHeight, @@ -1061,7 +1071,6 @@ export function WebGLTracksOGL({ bottom: windowH - (rect.y + rect.h), }); program.uniforms.uScreenHeight.value = windowH; - program.uniforms.uCanvasBottomDomY.value = rect.y + rect.h; // 비정수 DPR에서 backing 반올림까지 반영한 정확 CSS px/물리 px 비율 program.uniforms.uDomPerPx.value = rect.h / Math.max(renderer.gl.drawingBufferHeight, 1); diff --git a/src/renderer/components/overlay/counters/OutsideCounter.tsx b/src/renderer/components/overlay/counters/OutsideCounter.tsx index 9a0d78e5f..dae633cca 100644 --- a/src/renderer/components/overlay/counters/OutsideCounter.tsx +++ b/src/renderer/components/overlay/counters/OutsideCounter.tsx @@ -12,6 +12,7 @@ import { interface OutsideCounterProps { position: { + rotation?: number; dx?: number; dy?: number; width?: number; @@ -49,6 +50,7 @@ const OutsideCounter = ({ width, height, counterSettings.gap, + position.rotation, ); const fillColor = active diff --git a/src/renderer/components/overlay/counters/OverlayGraphItem.tsx b/src/renderer/components/overlay/counters/OverlayGraphItem.tsx index 592dde1fa..393ef8d74 100644 --- a/src/renderer/components/overlay/counters/OverlayGraphItem.tsx +++ b/src/renderer/components/overlay/counters/OverlayGraphItem.tsx @@ -10,6 +10,7 @@ const GRAPH_TICK_MS = 50; interface GraphPosition { hidden?: boolean; + rotation?: number; statType?: string; graphType?: string; graphSpeed?: number; @@ -175,6 +176,7 @@ const OverlayGraphItem = ({ position, index = 0 }: OverlayGraphItemProps) => { { }), ); - const transform = `translate3d(calc(${dx}px + var(--key-offset-x, 0px)), calc(${dy}px + var(--key-offset-y, 0px)), 0)`; + // 요소 회전은 배치 div에만 - 안쪽 KnobFace의 rotate는 HID 값 표시라 섞지 않는다 + const transform = `translate3d(calc(${dx}px + var(--key-offset-x, 0px)), calc(${dy}px + var(--key-offset-y, 0px)), 0)${elementRotationTransform( + position.rotation, + )}`; return (
; +} + +// 엔진이 거부한 easing - 문법이 유효해도 구형 WebKit(macOS 11)이나 OBS의 CEF는 +// linear()를 모른다. 한 번 거부당하면 다시 시도하지 않는다 +const rejectedEasings = new Set(); + +// 폴백 재생이 직접 쓴 transform을 문서의 idle로 되돌린다. +// 기본 모드는 비우면 CSS 변수 채널이 받고, 인라인 우선 모드는 React가 쓴 것과 +// 같은 선언을 되돌린다. 호출부가 항상 최신 문서를 넘겨야 재생 중 idleTransform· +// 인라인 우선이 바뀌어도 캡처해 둔 옛 값을 다시 덮지 않는다 +const restoreIdleTransform = ( + el: HTMLImageElement, + position: CanonicalReactiveSpritePosition, +) => { + el.style.transform = + position.useInlineStyles === true + ? spriteTransformToCss(position.idleTransform) + : ''; +}; + +// 단발(onPress) 재생 상태 - 재트리거 소유권 검증용 세대 포함. +// 늦게 도착한 이전 재생의 복원 콜백이 새 재생의 이미지를 덮지 못하게 한다 +interface OneShotPlayback { + generation: number; + animation: Animation | null; + timer: number | null; +} + +// 키 입력에 따라 자세가 바뀌는 반응형 스프라이트 잎 컴포넌트. +// whileHeld: 담당 키 시그널을 여기서만 읽어 눌림 변화가 이 잎의 리렌더로만 남고, +// 변환은 CSS transition이 컴포지터에서 보간한다 (계약 §9). +// onPress: 렌더는 항상 idle로 고정하고, 실입력 DOWN edge에서 WAAPI가 +// 자세→idle 한 방향 재생을 수행한다 (React state 미경유, 탭당 리렌더 0) +const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ + position, + keyCanonicalMap, +}: OverlaySpriteItemProps) { + useSignals(); + + const isOneShot = position.activation === 'onPress'; + + // 자세들이 참조하는 키 요소 id 전체. 해석기가 poses identity로 캐시해 둔 것을 그대로 쓴다 + const triggerIds = spriteTriggerIds(position.poses); + + // whileHeld만 렌더 중 시그널을 읽어 구독을 형성한다 - onPress는 edge 채널 전용이라 + // 눌림 레벨 변화에 리렌더될 이유가 없다 + const pressedIds = new Set(); + if (!isOneShot) { + for (const id of triggerIds) { + const canonical = keyCanonicalMap.get(id); + if (!canonical) continue; + if (getKeySignal(canonical).value) pressedIds.add(id); + } + } + + // onPress는 렌더가 idle 고정이라 자세 해석과 폴백 계산을 타지 않는다 + const heldTarget = isOneShot + ? null + : resolveSpriteTarget(position, pressedIds); + + const { failedImageSrcs, markFailed } = useFailedImageSrcs( + position.baseImage, + ...position.poses.map((pose) => pose.imageOverride), + ); + + const baseImageSrc = resolveImageSource(position.baseImage); + // 실패한 기본 이미지는 "기본 이미지 없음"으로 취급한다 - 깨진 노드를 남기면 + // 투명 오버레이 위에 회색 대체 박스가 상주한다 (whileHeld 분기와 같은 규칙) + const idleBaseSrc = + baseImageSrc && !failedImageSrcs.has(baseImageSrc) ? baseImageSrc : null; + + // easing 해석은 정규식·베지어 극값 계산이라 렌더마다 돌리지 않는다. + // 발산 easing은 도달 계산과 같은 기준으로 강등 (창 클리핑 방지) + const renderEasing = useMemo(() => { + const resolved = resolveSpriteRenderEasing(position.transitionEasing); + // onPress의 보간자는 WAAPI뿐이라 전환 선언을 내지 않는다 - 엔진 질의도 생략 + if (isOneShot) return resolved; + // CSS.supports가 없으면 판정할 방법이 없으니 통과시킨다 (테스트 DOM 포함) + if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') { + return resolved; + } + // whileHeld의 보간자는 CSS transition이라 WAAPI try/catch가 닿지 않는다. + // 무효한 timing-function은 선언 전체가 버려져 0ms 스냅이 되므로, 문법 게이트를 + // 통과한 값도 선언 전에 엔진에 직접 물어본다 + return CSS.supports('transition-timing-function', resolved) + ? resolved + : SPRITE_SAFE_FALLBACK_EASING; + }, [position.transitionEasing, isOneShot]); + // onPress는 전환 채널 미사용 - WAAPI가 유일한 보간자다 + const transitionCss = isOneShot + ? undefined + : `transform ${position.transitionMs}ms ${renderEasing}`; + + // 실패한 src는 baseImage로 폴백, 그것도 실패면 렌더 제외. + // 배치도 이미지와 한 벌 - 폴백이면 기본 이미지 배치로 함께 돌아간다 + const idlePlacement = placeSpriteVisual(position, spriteIdleVisual(position)); + let heldImageSrc = heldTarget + ? resolveImageSource(heldTarget.imageSrc) + : null; + let heldPlacement = heldTarget + ? placeSpriteVisual(position, heldTarget.visual) + : idlePlacement; + if (heldImageSrc && failedImageSrcs.has(heldImageSrc)) { + heldImageSrc = baseImageSrc; + heldPlacement = idlePlacement; + } + if (heldImageSrc && failedImageSrcs.has(heldImageSrc)) heldImageSrc = null; + + // 첫 자세 전환에서 cold decode가 겹치지 않도록 base와 모든 override를 선행 디코드 + const warmupSrcs = useMemo(() => { + const srcs = new Set(); + const base = resolveImageSource(position.baseImage); + if (base) srcs.add(base); + for (const pose of position.poses) { + const src = resolveImageSource(pose.imageOverride); + if (src) srcs.add(src); + } + return [...srcs]; + }, [position.baseImage, position.poses]); + + useEffect(() => { + for (const src of warmupSrcs) warmupImageSource(src); + }, [warmupSrcs]); + + // 기본 이미지가 없어도 자세 override만으로 유효한 onPress 스프라이트가 성립한다. + // 안정 img 노드를 유지하고 idle에는 숨겨야 edge 핸들러가 재생할 대상이 있다 + const hasOverrideImage = position.poses.some((pose) => + isRenderableImageRef(pose.imageOverride), + ); + const mountOneShotImage = + isOneShot && (idleBaseSrc !== null || hasOverrideImage); + + const imgRef = useRef(null); + const playbackRef = useRef({ + generation: 0, + animation: null, + timer: null, + }); + // edge 핸들러는 구독 effect보다 자주 갱신되는 값을 ref로 읽는다 (재구독 방지). + // layout 단계 갱신이라 구독 해제 cleanup도 최신 문서·실패 집합으로 복원한다 + const latestRef = useRef({ + position, + failedImageSrcs, + triggerIds, + keyCanonicalMap, + }); + useLayoutEffect(() => { + latestRef.current = { + position, + failedImageSrcs, + triggerIds, + keyCanonicalMap, + }; + }); + + // 구독 대상 canonical 집합의 내용 시그니처. 문서 clone·키 맵 재생성처럼 내용이 + // 같은 새 identity로 effect가 다시 돌면 진행 중 재생이 끊기므로 집합이 실제로 + // 바뀔 때만 재구독한다. 트리거→canonical 짝은 edge 시점에 ref로 읽는다 + const canonicalKey = useMemo(() => { + const canonicals = new Set(); + for (const id of triggerIds) { + const canonical = keyCanonicalMap.get(id); + if (canonical) canonicals.add(canonical); + } + return [...canonicals].sort().join('\n'); + }, [triggerIds, keyCanonicalMap]); + + // 직접 쓴 src·visibility를 현재 문서의 기본 이미지로 되돌린다. + // 재생 종료·취소, 구독 해제, 로드 실패가 같은 복원 규칙을 쓴다. + // excludedSrc는 방금 실패해 아직 실패 집합에 반영되지 않은 src + const restoreBaseImage = ( + el: HTMLImageElement, + excludedSrc?: string | null, + ) => { + const { position: pos, failedImageSrcs: failed } = latestRef.current; + const currentBase = resolveImageSource(pos.baseImage); + if ( + currentBase && + currentBase !== excludedSrc && + !failed.has(currentBase) + ) { + el.src = currentBase; + el.style.visibility = ''; + } else { + el.removeAttribute('src'); + el.style.visibility = 'hidden'; + } + // 재생이 바꾼 rect·축을 최신 문서의 기본 이미지 배치로 되돌린다 + applySpritePlacementStyle( + el, + placeSpriteVisual(pos, spriteIdleVisual(pos)), + ); + }; + + // 진행 중 재생 정리 - 세대 무효화로 늦은 콜백까지 차단 (멱등). + // 노드는 호출부가 넘긴다 - 언마운트 뒤 정리에서는 imgRef가 이미 비어 있다 + const stopPlayback = (el: HTMLImageElement | null) => { + const playback = playbackRef.current; + playback.generation += 1; + if (playback.animation) { + playback.animation.onfinish = null; + playback.animation.oncancel = null; + playback.animation.cancel(); + playback.animation = null; + } + if (playback.timer !== null) { + clearTimeout(playback.timer); + playback.timer = null; + // 폴백이 쓴 transform은 React prop이 그대로라 리렌더가 걷어내지 못한다. + // 취소도 정상 만료와 같은 복원을 거쳐야 자세 각도가 남지 않는다 + if (el) restoreIdleTransform(el, latestRef.current.position); + } + }; + + // 재생이 DOM에 직접 쓰는 값(src·rect·축·transform)을 결정하는 문서 내용의 서명. + // 문서 복제처럼 내용이 같은 새 identity에는 바뀌지 않는다 + const visualSignature = useMemo( + () => + stableStringify({ + baseImage: position.baseImage, + width: position.width, + height: position.height, + pivot: position.pivot, + referenceNaturalSize: position.referenceNaturalSize, + idleTransform: position.idleTransform, + useInlineStyles: position.useInlineStyles, + // 자세 기준점·이동값도 재생이 DOM에 직접 쓰는 축·transform을 바꾼다 + poses: position.poses.map((pose) => [ + pose.poseId, + pose.imageOverride, + pose.imageOverrideMetrics, + pose.pivot, + pose.transform, + ]), + }), + [position], + ); + // 재생 중 문서 내용이 바뀌면 React가 idle 스타일 차이만 다시 쓰고 직접 쓴 자세 배치는 + // 남아, 남은 재생 동안 src·rect·축이 서로 다른 세대로 섞인다. 문서 편집은 재생보다 + // 드물고 한 탭 재생은 짧으니 진행 중 재생을 끊고 최신 idle 한 벌로 되돌린다 + const visualSignatureRef = useRef(visualSignature); + useEffect(() => { + if (visualSignatureRef.current === visualSignature) return; + visualSignatureRef.current = visualSignature; + if (!isOneShot) return; + const playback = playbackRef.current; + if (!playback.animation && playback.timer === null) return; + const el = imgRef.current; + stopPlayback(el); + if (el) restoreBaseImage(el); + }); + + useEffect(() => { + if (!isOneShot) return undefined; + // cleanup 복원 대상 노드 - 같은 분기 재구독 동안은 key가 같아 안정적이고, + // 모드 전환 시에는 어차피 key 재마운트가 잔상을 걷는다 + const mountedImg = imgRef.current; + + if (canonicalKey === '') return undefined; + const canonicals = canonicalKey.split('\n'); + + const handleEdge = () => { + const el = imgRef.current; + if (!el) return; + const { + position: pos, + failedImageSrcs: failed, + triggerIds: ids, + keyCanonicalMap: canonicalMap, + } = latestRef.current; + + // 눌린 집합은 시그널 peek로만 만든다 - 여기서 구독이 생기면 안 된다 + const pressed = new Set(); + for (const id of ids) { + const canonical = canonicalMap.get(id); + if (canonical && getKeySignal(canonical).value) pressed.add(id); + } + const resolved = resolveSpriteTarget(pos, pressed); + + let baseSrc = resolveImageSource(pos.baseImage); + if (baseSrc && failed.has(baseSrc)) baseSrc = null; + let src = resolveImageSource(resolved.imageSrc); + // 이미지가 기본으로 폴백하면 배치도 기본 이미지 것으로 + let playbackPlacement = placeSpriteVisual(pos, resolved.visual); + if (src && failed.has(src)) { + src = baseSrc; + playbackPlacement = placeSpriteVisual(pos, spriteIdleVisual(pos)); + } + + const playback = playbackRef.current; + // 재트리거 소유권 이전 - 이전 재생의 콜백을 떼고 취소한다 + stopPlayback(el); + const generation = playback.generation; + + if (src) { + el.src = src; + el.style.visibility = ''; + applySpritePlacementStyle(el, playbackPlacement); + } else { + // 재생할 이미지가 없으면 이전 세대의 잔상을 즉시 걷어낸다 + el.removeAttribute('src'); + el.style.visibility = baseSrc ? '' : 'hidden'; + if (baseSrc) el.src = baseSrc; + applySpritePlacementStyle( + el, + placeSpriteVisual(pos, spriteIdleVisual(pos)), + ); + } + const restore = () => { + if (playbackRef.current.generation !== generation) return; + // 재생 중 문서가 바뀌었을 수 있어 캡처값 대신 최신 baseImage로 복원 + restoreBaseImage(el); + }; + + const fromCss = spriteTransformToCss(resolved.transform); + const toCss = spriteTransformToCss(pos.idleTransform); + // 보간 없는 재생의 만료 복원 - 자세 이미지를 pressDurationMs 뒤 되돌린다 + const scheduleRestore = () => { + playback.timer = window.setTimeout(() => { + if (playbackRef.current.generation !== generation) return; + playbackRef.current.timer = null; + restoreIdleTransform(el, latestRef.current.position); + restore(); + }, pos.pressDurationMs); + }; + // 기본 모드에서 사용자가 --sprite-transform을 잡았으면 자세 transform은 그 + // 값으로 대체된 것이다. 애니메이션 원점은 사용자 CSS를 이기므로 여기서 걸러 + // whileHeld처럼 이미지만 바꾼다. 보간 자체는 컴포지터 경로를 지키려 native + // transform에 건다 - 커스텀 속성 보간은 매 프레임 메인 스레드 스타일 재계산이다 + if (pos.useInlineStyles !== true && hasSpriteTransformOverride(el)) { + scheduleRestore(); + return; + } + // WAAPI 부재·거부 공용 폴백 - 자세로 스냅했다가 시간 후 복귀 (보간 없음) + const snapBack = () => { + el.style.transform = fromCss; + scheduleRestore(); + }; + + const easing = resolveSpriteRenderEasing(pos.transitionEasing); + let animation: Animation | null = null; + if (typeof el.animate === 'function' && !rejectedEasings.has(easing)) { + try { + // 자세에서 시작해 idle로 돌아오는 한 방향 재생 - 스냅 타격 + 이즈 복귀. + // fill: none이라 종료 후에는 CSS 변수 채널(idle)이 그대로 복귀값이다 + animation = el.animate( + [{ transform: fromCss }, { transform: toCss }], + { + duration: pos.pressDurationMs, + easing, + fill: 'none', + }, + ); + } catch (error) { + // 문법 게이트는 엔진의 지원 여부까지는 알 수 없다. 여기서 던지면 + // 이미 자세 이미지로 바꾼 뒤라 복원 없이는 그대로 고착된다 + rejectedEasings.add(easing); + console.error('[sprite] 엔진이 거부한 easing:', easing, error); + } + } + + if (animation) { + const settle = () => { + if (playbackRef.current.generation !== generation) return; + playbackRef.current.animation = null; + restore(); + }; + animation.onfinish = settle; + // 외부 취소(사용자 스크립트 등)도 idle 이미지 복원 계약을 지킨다 + animation.oncancel = settle; + playback.animation = animation; + } else { + snapBack(); + } + }; + + const unsubscribes = canonicals.map((canonical) => + subscribeKeyPressEdge(canonical, handleEdge), + ); + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + // whileHeld 전환·트리거 교체 시 진행 중 재생을 함께 끊는다 + stopPlayback(mountedImg); + // 직접 쓴 src·visibility 잔상 제거 - React는 prop이 같으면 DOM을 다시 + // 쓰지 않으므로 여기서 현재 문서 기준으로 복원한다 (전환 시에는 key가 + // 노드를 갈아 끼우고, 같은 분기 안 재구독에는 이 복원이 잡는다) + if (mountedImg) restoreBaseImage(mountedImg); + }; + }, [isOneShot, canonicalKey]); + + if (position.hidden) return null; + + // 사용자 클래스는 위치 래퍼에, 표식은 안쪽 표면에 - 노브·카운터와 같은 배치라 + // 문서의 `.클래스 [data-sprite-element]` 선택자가 에디터와 오버레이에서 같이 먹는다. + // 히트 기준은 활동 영역 박스(래퍼) - 에디터 선택 박스와 같은 의미이고 포즈 전환 + // 중에도 rect가 안정적이다. 이미지 박스는 transform을 따라 움직여 실측 시점에 + // 따라 어긋나므로 히트 표식을 img로 옮기지 않는다 + return ( +
+
0 ? 'active' : 'inactive' + } + > + {isOneShot + ? mountOneShotImage && ( + { + // 재생이 DOM에 직접 쓴 src도 실제 실패 URL과 대조한다 - + // 늦게 도착한 이전 src의 오류가 멀쩡한 이미지를 낙인찍지 않게 + const el = event.currentTarget; + const src = el.getAttribute('src'); + if (!isErrorForCurrentSrc(el, src)) return; + markFailed(src); + // 직접 쓴 src는 React prop이 그대로라 리렌더가 걷어내지 못한다. + // 재생이 끝날 때까지 깨진 이미지를 두지 않게 여기서 되돌린다 + restoreBaseImage(el, src); + }} + /> + ) + : heldTarget && + heldImageSrc && ( + { + if (!isErrorForCurrentSrc(event.currentTarget, heldImageSrc)) + return; + markFailed(heldImageSrc); + }} + /> + )} +
+
+ ); +}); + +export default OverlaySpriteItem; diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx new file mode 100644 index 000000000..968414eea --- /dev/null +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -0,0 +1,907 @@ +/** + * 반응형 스프라이트 잎 렌더 + * - 트리거 눌림이 transform과 data-sprite-state를 바꾸고, 미눌림이면 inactive를 유지한다 + * - 매핑에 없는 트리거는 무시되고, 실패한 이미지 src는 baseImage로 폴백한다 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; +import type { SpritePose } from '@src/types/key/sprites'; +import { + applyEventKeyState, + resetAllKeySignals, + setKeyActive, +} from '@stores/signals/keySignals'; + +import OverlaySpriteItem from './OverlaySpriteItem'; +import { + makeCanonicalSpritePosition, + makeSpritePose, +} from '@utils/sprite/spriteFixtures'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const BASE_IMAGE = 'data:image/png;base64,base'; +const OVERRIDE_IMAGE = 'data:image/png;base64,override'; + +const makePose = ( + poseId: string, + triggers: string[], + overrides: Partial = {}, +): SpritePose => + makeSpritePose({ + poseId, + triggers, + transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, + ...overrides, + }); + +const makeSprite = ( + overrides: Partial = {}, +): CanonicalReactiveSpritePosition => + makeCanonicalSpritePosition({ + dx: 12, + dy: 24, + width: 300, + height: 200, + zIndex: 5, + layerName: null, + groupId: null, + baseImage: BASE_IMAGE, + pivot: { x: 0.5, y: 1 }, + poses: [makePose('p1', ['el-a'])], + ...overrides, + }); + +const keyCanonicalMap: ReadonlyMap = new Map([ + ['el-a', 'KeyA'], +]); + +describe('OverlaySpriteItem', () => { + let container: HTMLDivElement; + let root: Root; + + const render = ( + position: CanonicalReactiveSpritePosition, + map: ReadonlyMap = keyCanonicalMap, + ) => { + act(() => { + root.render( + , + ); + }); + }; + + // 위치·히트는 래퍼, 상태 표식은 안쪽 표면 (노브와 같은 배치) + const wrapperEl = () => + container.querySelector('[data-overlay-hit="true"]'); + const spriteEl = () => + container.querySelector('[data-sprite-element="true"]'); + const imgEl = () => spriteEl()?.querySelector('img') ?? null; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + resetAllKeySignals(); + }); + + it('idle 상태를 idleTransform과 baseImage로 그린다', () => { + render(makeSprite()); + + const el = spriteEl(); + expect(el).not.toBeNull(); + expect(el?.dataset.spriteState).toBe('inactive'); + + // 히트 마커로 네이티브 히트 패널에 참여 - 상호작용은 패널 몫이라 pointer-events는 none 유지 + const wrapper = wrapperEl(); + expect(wrapper).not.toBeNull(); + expect(wrapper).not.toBe(el); + expect(wrapper?.contains(el!)).toBe(true); + expect(wrapper?.style.transform).toBe('translate3d(12px, 24px, 0)'); + expect(wrapper?.style.width).toBe('300px'); + expect(wrapper?.style.height).toBe('200px'); + expect(wrapper?.style.zIndex).toBe('5'); + expect(wrapper?.style.pointerEvents).toBe('none'); + + const img = imgEl(); + expect(img?.getAttribute('src')).toBe(BASE_IMAGE); + // 기본 모드는 외관 채널을 변수로만 싣는다 - 전역 :where 규칙 소비 + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + expect(img?.style.transformOrigin).toBe('50% 100%'); + expect(img?.style.getPropertyValue('--dmn-sprite-transition-default')).toBe( + 'transform 90ms cubic-bezier(0.4, 0, 0.2, 1)', + ); + }); + + it('배치 회전은 히트 상자에만 적용하고 whileHeld 전환 각도는 보존한다', () => { + const position = makeSprite({ + rotation: 179, + idleTransform: { x: 0, y: 0, rotation: -179, scale: 1 }, + poses: [ + makePose('p1', ['el-a'], { + transform: { x: 10, y: -6, rotation: 179, scale: 1.2 }, + }), + ], + }); + render(position); + const idle = imgEl()!.style.getPropertyValue( + '--dmn-sprite-transform-default', + ); + act(() => setKeyActive('KeyA', true)); + const active = imgEl()!.style.getPropertyValue( + '--dmn-sprite-transform-default', + ); + render({ ...position, rotation: -179 }); + expect( + imgEl()!.style.getPropertyValue('--dmn-sprite-transform-default'), + ).toBe(active); + expect(wrapperEl()!.style.transform).toBe( + 'translate3d(12px, 24px, 0) rotate(-179deg)', + ); + expect(wrapperEl()!.style.transformOrigin).toBe('50% 50%'); + act(() => setKeyActive('KeyA', false)); + expect( + imgEl()!.style.getPropertyValue('--dmn-sprite-transform-default'), + ).toBe(idle); + expect(active).toContain('rotate(179deg)'); + expect(idle).toContain('rotate(-179deg)'); + }); + + it('트리거 눌림이 pose transform과 active 상태로 바꾸고, 뗌은 idle로 되돌린다', () => { + render(makeSprite()); + + act(() => setKeyActive('KeyA', true)); + expect(spriteEl()?.dataset.spriteState).toBe('active'); + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transform-default'), + ).toBe('translate(10px, -6px) rotate(15deg) scale(1.2)'); + + act(() => setKeyActive('KeyA', false)); + expect(spriteEl()?.dataset.spriteState).toBe('inactive'); + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transform-default'), + ).toBe('translate(0px, 0px) rotate(0deg) scale(1)'); + }); + + it('useInlineStyles=false는 외관 채널을 인라인 선언 없이 변수로만 싣는다', () => { + render(makeSprite({ useInlineStyles: false })); + + const img = imgEl(); + // 인라인 선언이 비어야 사용자 CSS가 !important 없이 이긴다 + expect(img?.style.transform).toBe(''); + expect(img?.style.transition).toBe(''); + expect(img?.style.objectFit).toBe(''); + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + expect(img?.style.getPropertyValue('--dmn-sprite-transition-default')).toBe( + 'transform 90ms cubic-bezier(0.4, 0, 0.2, 1)', + ); + // 배치·기준점은 모드와 무관하게 인라인 + expect(img?.style.width).toBe('300px'); + expect(img?.style.transformOrigin).toBe('50% 100%'); + }); + + it('발산 easing은 전환에서 폴백 곡선으로 강등된다', () => { + render(makeSprite({ transitionEasing: 'cubic-bezier(0.5, 10, 0.5, -9)' })); + + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transition-default'), + ).toBe('transform 90ms ease'); + }); + + // whileHeld의 보간자는 CSS transition이라 onPress의 WAAPI try/catch가 닿지 않는다. + // 엔진이 모르는 timing-function은 선언 전체가 버려져 0ms 스냅이 되므로, + // 문법 게이트를 통과한 값도 선언 전에 엔진 지원을 확인해야 한다 + it('엔진이 거부하는 easing은 whileHeld 전환에서도 폴백 곡선으로 강등된다', () => { + const unsupported = 'linear(0, 0.25 75%, 1)'; + const supports = vi + .spyOn(CSS, 'supports') + .mockImplementation((...args: unknown[]) => + args[0] === 'transition-timing-function' && args[1] === unsupported + ? false + : true, + ); + + render(makeSprite({ transitionEasing: unsupported })); + + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transition-default'), + ).toBe('transform 90ms ease'); + supports.mockRestore(); + }); + + // 강등 게이트가 지원되는 곡선까지 삼키면 whileHeld가 통째로 기본 곡선이 된다. + // 폴백만 검사하면 그 회귀를 못 잡는다 + it('엔진이 지원하는 easing은 원문 그대로 전환에 실린다', () => { + const supported = 'linear(0, 0.5 50%, 1)'; + const supports = vi.spyOn(CSS, 'supports').mockReturnValue(true); + + render(makeSprite({ transitionEasing: supported })); + + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transition-default'), + ).toBe(`transform 90ms ${supported}`); + supports.mockRestore(); + }); + + it('useInlineStyles=true는 외관 채널을 인라인 선언으로 승격한다', () => { + render(makeSprite({ useInlineStyles: true })); + + const img = imgEl(); + expect(img?.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + expect(img?.style.transition).toBe( + 'transform 90ms cubic-bezier(0.4, 0, 0.2, 1)', + ); + expect(img?.style.objectFit).toBe('fill'); + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + '', + ); + + // 눌림 전환도 인라인 transform으로 반영 + act(() => setKeyActive('KeyA', true)); + expect(imgEl()?.style.transform).toBe( + 'translate(10px, -6px) rotate(15deg) scale(1.2)', + ); + }); + + it('매핑에 없는 트리거는 눌림을 무시한다', () => { + render(makeSprite(), new Map()); + + act(() => setKeyActive('KeyA', true)); + expect(spriteEl()?.dataset.spriteState).toBe('inactive'); + }); + + it('imageOverride pose는 눌림에서 이미지를 교체한다', () => { + render( + makeSprite({ + poses: [makePose('p1', ['el-a'], { imageOverride: OVERRIDE_IMAGE })], + }), + ); + + expect(imgEl()?.getAttribute('src')).toBe(BASE_IMAGE); + act(() => setKeyActive('KeyA', true)); + expect(imgEl()?.getAttribute('src')).toBe(OVERRIDE_IMAGE); + }); + + it('실패한 override src는 baseImage로 폴백하고, base도 실패하면 img를 내리지 않는다', () => { + render( + makeSprite({ + poses: [makePose('p1', ['el-a'], { imageOverride: OVERRIDE_IMAGE })], + }), + ); + + act(() => setKeyActive('KeyA', true)); + const img = imgEl(); + expect(img?.getAttribute('src')).toBe(OVERRIDE_IMAGE); + + act(() => { + img?.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(imgEl()?.getAttribute('src')).toBe(BASE_IMAGE); + + act(() => { + imgEl()?.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(imgEl()).toBeNull(); + // 이미지가 없어도 요소 자체는 남는다 + expect(spriteEl()?.dataset.spriteState).toBe('active'); + }); + + // 문서가 안내하는 선택자 형태 - 에디터에서만 먹고 오버레이에서 빗나가면 + // 사용자가 편집창에서 확인한 CSS가 실제 화면에서 조용히 사라진다 + it('문서의 클래스 한정 선택자가 오버레이에서 매치된다', () => { + render(makeSprite({ className: 'left-hand' })); + expect( + container.querySelector('.left-hand [data-sprite-element] > img'), + ).not.toBeNull(); + }); + + it('hidden이면 아무것도 그리지 않는다', () => { + render(makeSprite({ hidden: true })); + expect(spriteEl()).toBeNull(); + // 래퍼가 남으면 보이지 않는 히트 영역이 생긴다 + expect(wrapperEl()).toBeNull(); + }); +}); + +// onPress 단발 재생 - edge 채널·WAAPI 소유권·이미지 수명주기 +describe('OverlaySpriteItem onPress', () => { + interface MockAnimation { + keyframes: Array>; + options: KeyframeAnimationOptions; + cancel: () => void; + cancelled: boolean; + onfinish: (() => void) | null; + oncancel: (() => void) | null; + } + + let container: HTMLDivElement; + let root: Root; + let animations: MockAnimation[]; + const proto = HTMLImageElement.prototype as unknown as { animate?: unknown }; + const originalAnimate = proto.animate; + + const oneShotSprite = ( + overrides: Partial = {}, + ) => + makeSprite({ + activation: 'onPress', + pressDurationMs: 300, + poses: [makePose('p1', ['el-a'], { imageOverride: OVERRIDE_IMAGE })], + ...overrides, + }); + + const render = ( + position: CanonicalReactiveSpritePosition, + map: ReadonlyMap = keyCanonicalMap, + ) => { + act(() => { + root.render( + , + ); + }); + }; + + const imgEl = () => container.querySelector('img'); + + beforeEach(() => { + animations = []; + proto.animate = function animate( + keyframes: Array>, + options: KeyframeAnimationOptions, + ) { + const animation: MockAnimation = { + keyframes, + options, + cancelled: false, + cancel: () => { + animation.cancelled = true; + animation.oncancel?.(); + }, + onfinish: null, + oncancel: null, + }; + animations.push(animation); + return animation; + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + resetAllKeySignals(); + if (originalAnimate === undefined) delete proto.animate; + else proto.animate = originalAnimate; + vi.restoreAllMocks(); + }); + + // 사용자 CSS가 공개 변수로 자세 transform을 대체한 상태 + const stubTransformOverride = (value: string) => { + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + getPropertyValue: (name: string) => + name === '--sprite-transform' ? value : '', + } as unknown as CSSStyleDeclaration); + }; + + it('실입력 DOWN edge가 자세→idle 재생을 시작하고 종료 시 기본 이미지로 복원한다', () => { + render(oneShotSprite()); + const img = imgEl()!; + expect(img.src).toContain(BASE_IMAGE); + + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + expect(animations[0].keyframes[0].transform).toBe( + 'translate(10px, -6px) rotate(15deg) scale(1.2)', + ); + expect(animations[0].keyframes[1].transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + expect(animations[0].options.duration).toBe(300); + expect(animations[0].options.fill).toBe('none'); + expect(img.src).toContain(OVERRIDE_IMAGE); + + act(() => animations[0].onfinish?.()); + expect(img.src).toContain(BASE_IMAGE); + }); + + it('배치 ±180도 전환은 진행 중 WAAPI를 끊거나 자세 각도 경로를 바꾸지 않는다', () => { + const position = oneShotSprite({ + rotation: 179, + idleTransform: { x: 0, y: 0, rotation: -179, scale: 1 }, + poses: [ + makePose('p1', ['el-a'], { + transform: { x: 10, y: -6, rotation: 179, scale: 1.2 }, + }), + ], + }); + render(position); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + const frames = structuredClone(animations[0].keyframes); + for (const rotation of [180, -180, -179]) { + render({ ...position, rotation }); + expect(animations).toHaveLength(1); + expect(animations[0].cancelled).toBe(false); + expect(animations[0].keyframes).toEqual(frames); + } + expect(frames[0].transform).toContain('rotate(179deg)'); + expect(frames[1].transform).toContain('rotate(-179deg)'); + }); + + it('하이드레이션 레벨 세팅은 재생을 트리거하지 않는다 - 유령 단발 방지', () => { + render(oneShotSprite()); + act(() => setKeyActive('KeyA', true)); + act(() => setKeyActive('KeyA', false)); + expect(animations).toHaveLength(0); + }); + + it('이미 눌린 키의 반복 DOWN은 edge가 아니다', () => { + render(oneShotSprite()); + act(() => applyEventKeyState('KeyA', true)); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + }); + + it('재트리거는 이전 재생을 취소하고, 늦은 복원 콜백이 새 이미지를 덮지 않는다', () => { + render(oneShotSprite()); + act(() => applyEventKeyState('KeyA', true)); + const first = animations[0]; + // 소유권 검증 대상 - 재트리거 전에 콜백을 캡처해 지연 호출을 재현한다 + const lateRestore = first.onfinish; + + act(() => applyEventKeyState('KeyA', false)); + act(() => applyEventKeyState('KeyA', true)); + expect(first.cancelled).toBe(true); + expect(first.onfinish).toBeNull(); + expect(animations).toHaveLength(2); + + lateRestore?.(); + expect(imgEl()!.src).toContain(OVERRIDE_IMAGE); + + act(() => animations[1].onfinish?.()); + expect(imgEl()!.src).toContain(BASE_IMAGE); + }); + + it('기본 이미지 없이 자세 이미지만 있어도 재생 순간에만 표시된다', () => { + render(oneShotSprite({ baseImage: null })); + const img = imgEl()!; + expect(img.style.visibility).toBe('hidden'); + expect(img.hasAttribute('src')).toBe(false); + + act(() => applyEventKeyState('KeyA', true)); + expect(img.style.visibility).toBe(''); + expect(img.src).toContain(OVERRIDE_IMAGE); + + act(() => animations[0].onfinish?.()); + expect(img.style.visibility).toBe('hidden'); + expect(img.hasAttribute('src')).toBe(false); + }); + + // 스키마가 빈 문자열을 막지 않아 플러그인·임포트로 들어온다. 렌더러가 이걸 + // 이미지 없음으로 보는 이상 마운트 판정도 같아야 한다 - 아니면 재생할 것이 + // 없는 노드에 대고 WAAPI와 타이머만 돌린다 + it('공백 override만 있고 기본 이미지가 없으면 재생 노드를 만들지 않는다', () => { + render( + oneShotSprite({ + baseImage: null, + poses: [makePose('p1', ['el-a'], { imageOverride: ' ' })], + }), + ); + + expect(imgEl()).toBeNull(); + + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(0); + }); + + it('리싱크가 레벨을 선점해도 실제 DOWN edge는 재생된다', () => { + render(oneShotSprite()); + // OBS 대조·하이드레이션이 이벤트보다 먼저 레벨을 true로 올린 상황 + act(() => setKeyActive('KeyA', true)); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + }); + + it('이미지 없는 자세로 재트리거하면 이전 자세 이미지 잔상을 걷어낸다', () => { + render( + oneShotSprite({ + baseImage: null, + poses: [ + makePose('p1', ['el-a'], { imageOverride: OVERRIDE_IMAGE }), + makePose('p2', ['el-b']), + ], + }), + new Map([ + ['el-a', 'KeyA'], + ['el-b', 'KeyB'], + ]), + ); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + act(() => applyEventKeyState('KeyA', false)); + + act(() => applyEventKeyState('KeyB', true)); + expect(animations).toHaveLength(2); + expect(img.hasAttribute('src')).toBe(false); + expect(img.style.visibility).toBe('hidden'); + }); + + it('whileHeld로 전환하면 진행 중 재생이 취소된다', () => { + const sprite = oneShotSprite(); + render(sprite); + act(() => applyEventKeyState('KeyA', true)); + expect(animations[0].cancelled).toBe(false); + + act(() => applyEventKeyState('KeyA', false)); + render({ ...sprite, activation: 'whileHeld' }); + expect(animations[0].cancelled).toBe(true); + // 재생 중 직접 쓴 override src가 새 모드로 새지 않는다 (분기 key 재마운트) + expect(imgEl()!.src).toContain(BASE_IMAGE); + }); + + it('외부 취소도 기본 이미지 복원 계약을 지킨다', () => { + render(oneShotSprite()); + const img = imgEl()!; + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + + // 우리 재트리거·정리가 아닌 외부 cancel (콜백이 붙은 채로 취소됨) + act(() => animations[0].cancel()); + expect(img.src).toContain(BASE_IMAGE); + }); + + // 문서 편집 커밋은 coordinator가 문서를 통째로 clone해 모든 스프라이트 객체와 + // 키 맵이 새 identity를 받는다. 내용이 같으면 재구독하지 않아야 재생이 살아남는다 + it('같은 내용의 새 키 맵으로 리렌더돼도 진행 중 재생을 끊지 않는다', () => { + const sprite = oneShotSprite(); + render(sprite); + const img = imgEl()!; + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + + render(sprite, new Map(keyCanonicalMap)); + expect(animations[0].cancelled).toBe(false); + expect(img.src).toContain(OVERRIDE_IMAGE); + }); + + it('문서 복제로 자세 배열 identity가 바뀌어도 진행 중 재생을 끊지 않는다', () => { + const sprite = oneShotSprite(); + render(sprite); + const img = imgEl()!; + act(() => applyEventKeyState('KeyA', true)); + + render(structuredClone(sprite)); + expect(animations[0].cancelled).toBe(false); + expect(img.src).toContain(OVERRIDE_IMAGE); + }); + + it('재생 중 배치 내용이 바뀌면 진행 중 재생을 끊고 기본 이미지·배치로 되돌린다', () => { + const sprite = oneShotSprite(); + render(sprite); + const img = imgEl()!; + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + + // 상자가 바뀌면 React는 idle 스타일 차이만 다시 쓴다 - 직접 쓴 자세 배치가 남지 않게 + // 재생을 취소하고 한 벌로 복원한다 + render({ ...sprite, width: sprite.width + 10 }); + expect(animations[0].cancelled).toBe(true); + expect(img.src).not.toContain(OVERRIDE_IMAGE); + expect(img.style.width).toBe(`${sprite.width + 10}px`); + }); + + it('재생 중 자세 기준점·이동값이 바뀌면 진행 중 재생을 끊고 기본 이미지로 되돌린다', () => { + const sprite = oneShotSprite(); + render(sprite); + const img = imgEl()!; + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + + // 이미지·상자는 그대로, 자세 기준점과 보정 이동값만 바뀐 문서 - 재생이 직접 쓴 + // 축·transform이 낡으므로 상자 변경과 같은 규칙으로 재생을 끊는다 + const pose = sprite.poses[0]; + render({ + ...sprite, + poses: [ + { + ...pose, + pivot: { x: 0.25, y: 0.75 }, + transform: { ...pose.transform, x: pose.transform.x + 12 }, + }, + ], + }); + expect(animations[0].cancelled).toBe(true); + expect(img.src).not.toContain(OVERRIDE_IMAGE); + }); + + it('트리거의 canonical이 실제로 바뀌면 새 키로 재구독한다', () => { + const sprite = oneShotSprite(); + render(sprite); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + act(() => applyEventKeyState('KeyA', false)); + + render(sprite, new Map([['el-a', 'KeyB']])); + expect(animations[0].cancelled).toBe(true); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + act(() => applyEventKeyState('KeyB', true)); + expect(animations).toHaveLength(2); + }); + + // 기본 모드의 외관 채널은 변수라 사용자 --sprite-transform이 자세 transform을 + // 대체한다. 애니메이션 원점은 사용자 CSS를 이기므로 재생 전에 걸러야 whileHeld와 + // 같은 결과(이미지만 교체)가 된다 + it('기본 모드에서 사용자 --sprite-transform이 잡혀 있으면 transform을 움직이지 않고 이미지만 재생한다', () => { + vi.useFakeTimers(); + stubTransformOverride('rotate(0deg)'); + render(oneShotSprite()); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(0); + expect(img.style.transform).toBe(''); + expect(img.src).toContain(OVERRIDE_IMAGE); + + act(() => vi.advanceTimersByTime(300)); + expect(img.src).toContain(BASE_IMAGE); + vi.useRealTimers(); + }); + + it('오버라이드 중 재트리거는 이전 복원을 걷고 다시 예약한다', () => { + vi.useFakeTimers(); + stubTransformOverride('rotate(0deg)'); + render(oneShotSprite()); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + act(() => vi.advanceTimersByTime(200)); + act(() => applyEventKeyState('KeyA', false)); + act(() => applyEventKeyState('KeyA', true)); + act(() => vi.advanceTimersByTime(200)); + // 첫 재생 기준 400ms가 지났지만 두 번째 재생이 소유권을 가져 아직 자세 이미지다 + expect(img.src).toContain(OVERRIDE_IMAGE); + act(() => vi.advanceTimersByTime(100)); + expect(img.src).toContain(BASE_IMAGE); + vi.useRealTimers(); + }); + + it('인라인 우선 모드는 사용자 변수와 무관하게 transform을 보간한다', () => { + stubTransformOverride('rotate(0deg)'); + render(oneShotSprite({ useInlineStyles: true })); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + expect(animations[0].keyframes[0]).toHaveProperty('transform'); + }); + + it('whileHeld 스프라이트는 edge 채널로 재생되지 않는다', () => { + render(makeSprite()); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(0); + }); + + // WAAPI는 무효 easing을 TypeError로 거부한다 - 강등 없이 넘기면 재생이 끊기고 + // 복원 콜백이 붙지 않아 자세 이미지가 그대로 고착된다 + it('무효 easing은 폴백 곡선으로 강등해 재생에 넘긴다', () => { + render(oneShotSprite({ transitionEasing: 'cubic-bezier(2, 0, 0.5, 1)' })); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + expect(animations[0].options.easing).toBe('ease'); + }); + + it('실패한 기본 이미지는 idle에서 숨기고 재생 복원도 숨김으로 끝낸다', () => { + render(oneShotSprite()); + const img = imgEl()!; + expect(img.src).toContain(BASE_IMAGE); + + act(() => { + img.dispatchEvent(new Event('error', { bubbles: false })); + }); + // 깨진 노드를 남기면 투명 오버레이 위에 대체 박스가 상주한다 + expect(imgEl()!.hasAttribute('src')).toBe(false); + expect(imgEl()!.style.visibility).toBe('hidden'); + + // 자세 이미지는 멀쩡하므로 재생은 그대로 되고, 복원만 숨김으로 간다 + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.src).toContain(OVERRIDE_IMAGE); + act(() => animations[0].onfinish?.()); + expect(imgEl()!.hasAttribute('src')).toBe(false); + expect(imgEl()!.style.visibility).toBe('hidden'); + }); + + it('재생 중 자세 이미지가 실패하면 그 자리에서 기본 이미지로 되돌린다', () => { + render(oneShotSprite()); + act(() => applyEventKeyState('KeyA', true)); + const img = imgEl()!; + expect(img.getAttribute('src')).toBe(OVERRIDE_IMAGE); + + act(() => { + img.dispatchEvent(new Event('error', { bubbles: false })); + }); + // 직접 쓴 src라 React prop이 그대로다 - 리렌더를 기다리면 재생이 끝날 때까지 + // (최대 pressDurationMs) 깨진 이미지가 남는다 + expect(imgEl()!.getAttribute('src')).toBe(BASE_IMAGE); + expect(imgEl()!.style.visibility).toBe(''); + }); + + it('기본 이미지도 실패한 상태면 자세 실패 즉시 노드를 숨긴다', () => { + render(oneShotSprite()); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.getAttribute('src')).toBe(OVERRIDE_IMAGE); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(imgEl()!.hasAttribute('src')).toBe(false); + expect(imgEl()!.style.visibility).toBe('hidden'); + }); + + it('자세 이미지도 실패하면 실패한 base로 폴백하지 않는다', () => { + render(oneShotSprite()); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.src).toContain(OVERRIDE_IMAGE); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + + act(() => applyEventKeyState('KeyA', false)); + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.hasAttribute('src')).toBe(false); + expect(imgEl()!.style.visibility).toBe('hidden'); + }); + + // jsdom은 실패 로드에 currentSrc를 채우지 않아 가드가 항상 통과한다. + // 실기 엔진의 불일치를 재현해야 검증이 성립한다 + it('늦게 도착한 이전 src의 오류는 재생 중 이미지를 낙인찍지 않는다', () => { + render(oneShotSprite()); + act(() => applyEventKeyState('KeyA', true)); + const img = imgEl()!; + expect(img.getAttribute('src')).toBe(OVERRIDE_IMAGE); + + // 직전 base 요청의 오류가 자세 이미지로 갈아탄 뒤에 도착한 상황 + Object.defineProperty(img, 'currentSrc', { + value: BASE_IMAGE, + configurable: true, + }); + act(() => { + img.dispatchEvent(new Event('error', { bubbles: false })); + }); + + act(() => applyEventKeyState('KeyA', false)); + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.getAttribute('src')).toBe(OVERRIDE_IMAGE); + }); + + // 문법 게이트는 엔진 지원까지 알 수 없다 - macOS 11 WebKit과 OBS의 CEF는 + // linear()를 거부한다. 던지는 자리가 src 교체 뒤라 복원이 없으면 고착된다. + // 거부 easing은 모듈 수준에 기억되므로 케이스마다 다른 값을 쓴다 + const rejectAnimate = () => { + proto.animate = function animate() { + throw new TypeError('unsupported easing'); + }; + }; + + it('엔진이 easing을 거부하면 스냅 폴백으로 재생하고 복원한다', () => { + vi.useFakeTimers(); + rejectAnimate(); + render(oneShotSprite({ transitionEasing: 'linear(0, 0.25 25%, 1)' })); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + expect(img.style.transform).toBe( + 'translate(10px, -6px) rotate(15deg) scale(1.2)', + ); + + act(() => vi.advanceTimersByTime(300)); + // 기본 모드는 transform을 비워야 CSS 변수 채널이 idle을 되찾는다 + expect(img.style.transform).toBe(''); + expect(img.src).toContain(BASE_IMAGE); + vi.useRealTimers(); + }); + + it('인라인 우선 모드의 스냅 폴백은 idle transform을 직접 되돌린다', () => { + vi.useFakeTimers(); + rejectAnimate(); + render( + oneShotSprite({ + useInlineStyles: true, + transitionEasing: 'linear(0, 0.75 75%, 1)', + }), + ); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + act(() => vi.advanceTimersByTime(300)); + // 비우면 React가 인라인으로 쓴 idle transform까지 사라진다 (prop 동일이라 복구 없음) + expect(img.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + vi.useRealTimers(); + }); + + it('폴백 재생 중 재구독되면 자세 transform도 함께 걷는다', () => { + vi.useFakeTimers(); + rejectAnimate(); + const sprite = oneShotSprite({ + useInlineStyles: true, + transitionEasing: 'linear(0, 0.4 40%, 1)', + }); + render(sprite); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + expect(img.style.transform).toBe( + 'translate(10px, -6px) rotate(15deg) scale(1.2)', + ); + + // 트리거 canonical 교체로 구독 effect가 다시 돈다 - 타이머만 지우면 각도가 남는다 + render(sprite, new Map([['el-a', 'KeyB']])); + expect(imgEl()!.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + vi.useRealTimers(); + }); + + it('폴백 만료는 캡처값이 아니라 최신 idle transform으로 되돌린다', () => { + vi.useFakeTimers(); + rejectAnimate(); + const sprite = oneShotSprite({ + useInlineStyles: true, + transitionEasing: 'linear(0, 0.6 60%, 1)', + }); + render(sprite); + + act(() => applyEventKeyState('KeyA', true)); + // 재생 중 idle이 바뀐다 - React가 최신 값을 쓴 뒤 타이머가 옛 값을 덮으면 안 된다 + render({ + ...sprite, + idleTransform: { x: 4, y: 8, rotation: 30, scale: 2 }, + }); + + act(() => vi.advanceTimersByTime(300)); + expect(imgEl()!.style.transform).toBe( + 'translate(4px, 8px) rotate(30deg) scale(2)', + ); + vi.useRealTimers(); + }); + + it('기본 이미지만 있고 그마저 실패하면 노드를 내린다', () => { + render(oneShotSprite({ poses: [makePose('p1', ['el-a'])] })); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(imgEl()).toBeNull(); + }); +}); diff --git a/src/renderer/components/shared/OverlayScene.tsx b/src/renderer/components/shared/OverlayScene.tsx index fd2b2770a..a1b9a1ff1 100644 --- a/src/renderer/components/shared/OverlayScene.tsx +++ b/src/renderer/components/shared/OverlayScene.tsx @@ -6,6 +6,7 @@ import StatItem from '@components/overlay/counters/StatItem'; import StatCounterLayer from '@components/overlay/counters/StatCounterLayer'; import OverlayGraphItem from '@components/overlay/counters/OverlayGraphItem'; import OverlayKnobItem from '@components/overlay/counters/OverlayKnobItem'; +import OverlaySpriteItem from '@components/overlay/counters/OverlaySpriteItem'; import { PluginElementsRenderer } from '@components/shared/plugin/PluginElementsRenderer'; import { getKeyInfoByGlobalKey } from '@utils/input/KeyMaps'; import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; @@ -26,6 +27,13 @@ const Tracks = lazy(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any type NoteSubscriber = (event: any) => void; +// 미지정 시 identity 안정 기본값 +const EMPTY_SPRITE_POSITIONS: CanonicalEditorDocumentV1['spritePositions'][string] = + Object.freeze( + [], + ) as unknown as CanonicalEditorDocumentV1['spritePositions'][string]; +const EMPTY_SPRITE_KEY_MAP: ReadonlyMap = new Map(); + interface OverlaySceneProps { // 키/위치 데이터 (currentKeys = canonical 문자열) currentKeys: string[]; @@ -36,6 +44,9 @@ interface OverlaySceneProps { displayStatPositions: CanonicalEditorDocumentV1['statPositions'][string]; displayGraphPositions: CanonicalEditorDocumentV1['graphPositions'][string]; displayKnobPositions: CanonicalEditorDocumentV1['knobPositions'][string]; + displaySpritePositions?: CanonicalEditorDocumentV1['spritePositions'][string]; + // 스프라이트 트리거(키 요소 id) -> canonical 키 문자열 + spriteKeyCanonicalMap?: ReadonlyMap; selectedKeyType: string; // 노트 이펙트 @@ -53,9 +64,10 @@ interface OverlaySceneProps { keyCounterEnabled: boolean; // 선택적 - // 배경 박스 크기 - 미지정 시 뷰포트 전체 - // 데스크톱 창은 콘텐츠 박스와 크기가 같아 동일하고, OBS 소스에서는 남는 영역이 투명으로 남는다 - contentSize?: { width: number; height: number }; + // 배경 박스 - 미지정 시 뷰포트 전체. + // x·y는 스프라이트 이미지 도달 여유로 창 원점이 밀린 만큼의 배경 위치 보정이고, + // 배경 밖 여유 영역과 OBS 소스의 남는 영역은 투명으로 남는다 + contentSize?: { x?: number; y?: number; width: number; height: number }; // 전환 중 콘텐츠 페이드 - 네이티브 창 알파 미지원 환경에서 리사이즈 아티팩트를 가린다 contentFade?: { opacity: number; durationMs: number } | null; // 초기 리빌 게이트 - false면 모든 요소가 자리 잡을 때까지 화면을 감춘다 @@ -73,6 +85,8 @@ const OverlayScene = ({ displayStatPositions, displayGraphPositions, displayKnobPositions, + displaySpritePositions = EMPTY_SPRITE_POSITIONS, + spriteKeyCanonicalMap = EMPTY_SPRITE_KEY_MAP, selectedKeyType, noteEffect, noteSettings, @@ -109,11 +123,14 @@ const OverlayScene = ({ }), }} > - {/* 배경은 콘텐츠 박스에만 - 데스크톱은 창==콘텐츠라 동일하고 OBS 소스에서는 남는 영역이 투명 */} + {/* 배경은 콘텐츠 박스에만 - 스프라이트 도달 여유와 OBS 소스의 남는 영역은 투명 */}
); })} + {displaySpritePositions.map((pos, index) => { + if (!pos || pos.hidden) return null; + // 전개 금지 - 원본 identity를 보존해야 잎의 React.memo가 유지된다 + const position = resolveZIndexFallback(pos, index); + return ( + + ); + })} {keyCounterEnabled ? ( ( { dx = 0, dy = 0, + rotation = 0, width = 200, height = 100, zIndex = 0, @@ -174,13 +178,14 @@ const GraphPanel = forwardRef( const shouldPromoteTransformLayer = !interactive || promoteTransformLayer === true; const resolvedGraphType = graphType === 'bar' ? 'bar' : 'line'; - const transform = withOffsetVars + const translate = withOffsetVars ? interactive ? `translate(calc(${dx}px + var(--key-offset-x, 0px)), calc(${dy}px + var(--key-offset-y, 0px)))` : `translate3d(calc(${dx}px + var(--key-offset-x, 0px)), calc(${dy}px + var(--key-offset-y, 0px)), 0)` : interactive ? `translate(${dx}px, ${dy}px)` : `translate3d(${dx}px, ${dy}px, 0)`; + const transform = `${translate}${elementRotationTransform(rotation)}`; const normalizedHistory = useMemo( () => normalizeGraphHistory(history), diff --git a/src/renderer/components/shared/key/Key.statePreview.test.tsx b/src/renderer/components/shared/key/Key.statePreview.test.tsx index fc7eb6ea9..f95073388 100644 --- a/src/renderer/components/shared/key/Key.statePreview.test.tsx +++ b/src/renderer/components/shared/key/Key.statePreview.test.tsx @@ -18,20 +18,17 @@ import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; vi.mock('@utils/core/platform', () => ({ isMac: () => false })); -vi.mock('@hooks/Grid', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useDraggable: () => ({ - ref: () => {}, - dx: 0, - dy: 0, - wasMoved: false, - isDragging: false, - recentPressMovedRef: { current: false }, - }), - }; -}); +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/drag/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: false, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); vi.mock('@hooks/Grid/drag/useSelectionDrag', () => ({ useSelectionDrag: () => ({ diff --git a/src/renderer/components/shared/key/Key.trailingClick.test.tsx b/src/renderer/components/shared/key/Key.trailingClick.test.tsx index 8f5957c87..b1680987f 100644 --- a/src/renderer/components/shared/key/Key.trailingClick.test.tsx +++ b/src/renderer/components/shared/key/Key.trailingClick.test.tsx @@ -21,20 +21,17 @@ const hookState = vi.hoisted(() => ({ wasMoved: false, pressMoved: false })); // 수식키 판별을 ctrlKey로 고정 vi.mock('@utils/core/platform', () => ({ isMac: () => false })); -vi.mock('@hooks/Grid', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useDraggable: () => ({ - ref: () => {}, - dx: 0, - dy: 0, - wasMoved: hookState.wasMoved, - isDragging: false, - recentPressMovedRef: { current: false }, - }), - }; -}); +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/drag/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: hookState.wasMoved, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); vi.mock('@hooks/Grid/drag/useSelectionDrag', () => ({ useSelectionDrag: () => ({ diff --git a/src/renderer/components/shared/key/Key.tsx b/src/renderer/components/shared/key/Key.tsx index 596589ede..239ff0abb 100644 --- a/src/renderer/components/shared/key/Key.tsx +++ b/src/renderer/components/shared/key/Key.tsx @@ -23,6 +23,7 @@ import { useGridElementInteraction, type GridElementInteractionProps, } from '@hooks/Grid/useGridElementInteraction'; +import { elementRotationTransform } from '@utils/element/rotation'; // DraggableKey에서 counter가 KeyCounterSettings 타입인 확장 position interface KeyPosition extends KeyElementPosition { @@ -118,6 +119,7 @@ const DraggableKey = React.memo( '.counter', 'counterFill', anchorOrigin, + position.rotation, ); useCounterAxisAnchor( previewSession, @@ -126,6 +128,7 @@ const DraggableKey = React.memo( '[data-key-label]', 'font', anchorOrigin, + position.rotation, ); const interaction = useGridElementInteraction({ @@ -213,7 +216,9 @@ const DraggableKey = React.memo( // 이동 키는 매 프레임 손상 영역만 재페인트하는 쪽이 선명하고 충분히 싸다 const keyStyle: React.CSSProperties = { ...computedKeyStyle, - transform: `translate(calc(${renderDx}px + var(--key-offset-x, 0px)), calc(${renderDy}px + var(--key-offset-y, 0px)))`, + transform: `translate(calc(${renderDx}px + var(--key-offset-x, 0px)), calc(${renderDy}px + var(--key-offset-y, 0px)))${elementRotationTransform( + position.rotation, + )}`, willChange: 'auto', backfaceVisibility: 'visible', transformStyle: 'flat', diff --git a/src/renderer/components/shared/plugin/PluginElement.contextMenuFreeze.test.tsx b/src/renderer/components/shared/plugin/PluginElement.contextMenuFreeze.test.tsx index 55c8b9da8..8058e3a55 100644 --- a/src/renderer/components/shared/plugin/PluginElement.contextMenuFreeze.test.tsx +++ b/src/renderer/components/shared/plugin/PluginElement.contextMenuFreeze.test.tsx @@ -89,7 +89,7 @@ const renderElement = (element: PluginDisplayElementInternal) => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/components/shared/plugin/PluginElement.cursorPolicy.test.tsx b/src/renderer/components/shared/plugin/PluginElement.cursorPolicy.test.tsx index a740e54e1..2f8873310 100644 --- a/src/renderer/components/shared/plugin/PluginElement.cursorPolicy.test.tsx +++ b/src/renderer/components/shared/plugin/PluginElement.cursorPolicy.test.tsx @@ -64,7 +64,7 @@ const renderElement = (windowType: 'main' | 'overlay') => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/components/shared/plugin/PluginElement.groupClick.test.tsx b/src/renderer/components/shared/plugin/PluginElement.groupClick.test.tsx index 66fe76066..58c2db7aa 100644 --- a/src/renderer/components/shared/plugin/PluginElement.groupClick.test.tsx +++ b/src/renderer/components/shared/plugin/PluginElement.groupClick.test.tsx @@ -79,7 +79,7 @@ const renderElement = () => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/components/shared/plugin/PluginElement.legacyHtmlStability.test.tsx b/src/renderer/components/shared/plugin/PluginElement.legacyHtmlStability.test.tsx index 27634c01f..34f0261d1 100644 --- a/src/renderer/components/shared/plugin/PluginElement.legacyHtmlStability.test.tsx +++ b/src/renderer/components/shared/plugin/PluginElement.legacyHtmlStability.test.tsx @@ -87,7 +87,7 @@ const renderElement = (element: PluginDisplayElementInternal, zoom: number) => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/components/shared/plugin/PluginElement.measurementRuntime.test.tsx b/src/renderer/components/shared/plugin/PluginElement.measurementRuntime.test.tsx index 732f570d8..1656614a3 100644 --- a/src/renderer/components/shared/plugin/PluginElement.measurementRuntime.test.tsx +++ b/src/renderer/components/shared/plugin/PluginElement.measurementRuntime.test.tsx @@ -106,7 +106,7 @@ describe('PluginElement 측정·설정 크기 수명주기', () => { }); root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/components/shared/plugin/PluginElement.mountErrorIsolation.test.tsx b/src/renderer/components/shared/plugin/PluginElement.mountErrorIsolation.test.tsx index 4937f493b..734bca452 100644 --- a/src/renderer/components/shared/plugin/PluginElement.mountErrorIsolation.test.tsx +++ b/src/renderer/components/shared/plugin/PluginElement.mountErrorIsolation.test.tsx @@ -93,7 +93,7 @@ describe('플러그인 onMount 오류 격리', () => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} >
healthy
diff --git a/src/renderer/components/shared/plugin/PluginElement.runtime.test.tsx b/src/renderer/components/shared/plugin/PluginElement.runtime.test.tsx index a7617917d..8680af9be 100644 --- a/src/renderer/components/shared/plugin/PluginElement.runtime.test.tsx +++ b/src/renderer/components/shared/plugin/PluginElement.runtime.test.tsx @@ -129,7 +129,7 @@ const renderElement = ( act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/contexts/I18nContext.save.test.tsx b/src/renderer/contexts/I18nContext.save.test.tsx new file mode 100644 index 000000000..1b770f638 --- /dev/null +++ b/src/renderer/contexts/I18nContext.save.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +import React, { act, useContext } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { I18nProvider } from './I18nContext'; +import { I18nContext } from './I18nContextDef'; + +const mocks = vi.hoisted(() => ({ + update: vi.fn(), + result: null as Promise | null, +})); +vi.mock('@api/modules/app/settingsApi', () => ({ + settingsApi: { update: mocks.update }, +})); + +describe('언어 저장과 표시 일치', () => { + let root: Root; + let host: HTMLDivElement; + const originalApi = window.api; + const Probe = () => { + const context = useContext(I18nContext)!; + return ( + + ); + }; + beforeEach(async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + localStorage.setItem('dmnote:locale', 'ko'); + localStorage.setItem('dmnote:locale_initialized', '1'); + mocks.update.mockReset(); + mocks.result = null; + vi.spyOn(console, 'error').mockImplementation(() => {}); + window.api = { + settings: { + get: async () => ({ language: 'ko' }), + onChanged: () => () => {}, + }, + } as never; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(async () => + root.render( + + + , + ), + ); + await vi.waitFor(async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(host.querySelector('button')).not.toBeNull(); + }); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + window.api = originalApi; + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('저장에 실패하면 호출자에게 알리고 화면과 다음 실행 언어를 유지한다', async () => { + const failure = new Error('language write failed'); + mocks.update.mockRejectedValue(failure); + await act(async () => + host.querySelector('button')!.click(), + ); + await expect(mocks.result).rejects.toBe(failure); + expect( + host.querySelector('[data-locale]')?.getAttribute('data-locale'), + ).toBe('ko'); + expect(localStorage.getItem('dmnote:locale')).toBe('ko'); + }); + + it('저장 응답을 받은 뒤 화면과 다음 실행 언어를 함께 바꾼다', async () => { + let complete!: () => void; + mocks.update.mockReturnValue( + new Promise((resolve) => { + complete = resolve; + }), + ); + act(() => host.querySelector('button')!.click()); + expect( + host.querySelector('[data-locale]')?.getAttribute('data-locale'), + ).toBe('ko'); + expect(localStorage.getItem('dmnote:locale')).toBe('ko'); + await act(async () => { + complete(); + await mocks.result; + }); + expect( + host.querySelector('[data-locale]')?.getAttribute('data-locale'), + ).toBe('en'); + expect(localStorage.getItem('dmnote:locale')).toBe('en'); + }); +}); diff --git a/src/renderer/contexts/I18nContext.tsx b/src/renderer/contexts/I18nContext.tsx index d1383a7fd..966429bff 100644 --- a/src/renderer/contexts/I18nContext.tsx +++ b/src/renderer/contexts/I18nContext.tsx @@ -213,12 +213,10 @@ export function I18nProvider({ children }: { children: ReactNode }) { }; }, []); - const changeLocale = (next: SupportedLocale) => { + const changeLocale = async (next: SupportedLocale): Promise => { + await settingsApi.update({ language: next }); setLocaleState(next); safeLocalStorageSet(STORAGE_KEY, next); - settingsApi.update({ language: next }).catch((error) => { - console.error('Failed to update language', error); - }); }; const t = function translate( diff --git a/src/renderer/contexts/I18nContextDef.ts b/src/renderer/contexts/I18nContextDef.ts index 5afff2c5e..769a9c13a 100644 --- a/src/renderer/contexts/I18nContextDef.ts +++ b/src/renderer/contexts/I18nContextDef.ts @@ -4,7 +4,7 @@ export type SupportedLocale = 'ko' | 'en' | 'zh-cn' | 'zh-Hant' | 'ru'; export interface I18nContextValue { locale: SupportedLocale; - setLocale: (locale: SupportedLocale) => void; + setLocale: (locale: SupportedLocale) => Promise; t: (key: string, params?: Record) => string; } diff --git a/src/renderer/editor/model/elementIdMap.ts b/src/renderer/editor/model/elementIdMap.ts index 54c3807ea..f988e7ae5 100644 --- a/src/renderer/editor/model/elementIdMap.ts +++ b/src/renderer/editor/model/elementIdMap.ts @@ -1,11 +1,12 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; -export type NativeElementType = 'key' | 'stat' | 'graph' | 'knob'; +export type NativeElementType = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; export interface ElementLocator { type: NativeElementType; @@ -17,7 +18,8 @@ type CanonicalPosition = | CanonicalEditorDocumentV1['keyPositions'][string][number] | CanonicalEditorDocumentV1['statPositions'][string][number] | CanonicalEditorDocumentV1['graphPositions'][string][number] - | CanonicalEditorDocumentV1['knobPositions'][string][number]; + | CanonicalEditorDocumentV1['knobPositions'][string][number] + | CanonicalEditorDocumentV1['spritePositions'][string][number]; type PositionsRecord = Record | undefined; // 권위 컬렉션만 읽는다. 키의 렌더 positions는 canonical + 프리뷰 합성이라 @@ -27,6 +29,7 @@ const authorityRecords = (): Record => ({ stat: useStatItemStore.getState().positions, graph: useGraphItemStore.getState().positions, knob: useKnobItemStore.getState().positions, + sprite: useSpriteStore.getState().positions, }); interface CacheEntry { diff --git a/src/renderer/editor/model/keys.ts b/src/renderer/editor/model/keys.ts index 83a3b9647..83ed063a1 100644 --- a/src/renderer/editor/model/keys.ts +++ b/src/renderer/editor/model/keys.ts @@ -18,6 +18,7 @@ import { } from '@src/types/key/keys'; import { newElementId } from './elementId'; import { cloneSlot } from '@utils/keySlot'; +import { DEFAULT_ELEMENT_ROTATION } from '@src/types/key/rotation'; // ---------------------------------------------------------------------------- // 기본 키 포지션 생성 @@ -33,6 +34,7 @@ export function createDefaultKeyPosition( dy, width: 60, height: 60, + rotation: DEFAULT_ELEMENT_ROTATION, hidden: false, activeImage: '', inactiveImage: '', diff --git a/src/renderer/editor/runtime/coordinator/editorCoordinator.test.ts b/src/renderer/editor/runtime/coordinator/editorCoordinator.test.ts index de1a9f8b7..b6ee1393a 100644 --- a/src/renderer/editor/runtime/coordinator/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/coordinator/editorCoordinator.test.ts @@ -27,6 +27,7 @@ import { enqueueEditorCompatibilityWrite, } from '../lifecycle/editorCompatibilityQueue'; import { registerStoredPluginGroupRefsProvider } from '../intent/pluginGroupMembers'; +import { projectSpriteResize } from '@utils/sprite/resizeProjection'; import type { EditorCommitError, @@ -76,6 +77,7 @@ const makeDocument = (key = 'A'): CanonicalEditorDocumentV1 => ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }); @@ -134,7 +136,7 @@ class FakeTransport implements EditorCoordinatorTransport { ? { opResults: request.ops.map( (op): EditorOpResultV1 => - op.kind === 'setBounds' + op.kind === 'setBounds' || op.kind === 'resizeSprite' ? { status: changedFields.length > 0 ? 'applied' : 'noChange', @@ -343,6 +345,7 @@ const applyOpsForTest = ( statPositions: next.statPositions, graphPositions: next.graphPositions, knobPositions: next.knobPositions, + spritePositions: next.spritePositions, layerGroups: next.layerGroups, pluginElements: [], }); @@ -365,6 +368,26 @@ const applyOpsForTest = ( if (groups) next.layerGroups = groups; return; } + if (op.kind === 'resizeSprite') { + const spriteRecord = next.spritePositions as Record< + string, + Array> + >; + Object.entries(spriteRecord).some(([mode, positions]) => { + const index = positions.findIndex((position) => position.id === op.id); + if (index < 0) return false; + spriteRecord[mode] = positions.map((position, positionIndex) => + positionIndex === index + ? (projectSpriteResize(position as never, op.bounds) as Record< + string, + unknown + >) + : position, + ); + return true; + }); + return; + } const record = next[fields[op.elementType]] as Record< string, Array> @@ -1554,7 +1577,7 @@ describe('EditorSaveCoordinator', () => { async (context) => { expect(context).toMatchObject({ // 프론트만 승격되는 사고를 잡는 anchor - 상수 참조로 바꾸지 말 것 - editorOpsVersion: 2, + editorOpsVersion: 4, editorOps: [expect.objectContaining({ id })], }); return { @@ -3325,6 +3348,126 @@ describe('commitSemanticOpsInternal', () => { patch: { property: 'hidden', value: hidden }, }); + const RESIZE_SPRITE_ID = '00000000-0000-4000-8000-0000000000c1'; + const withResizableSprite = (): CanonicalEditorDocumentV1 => { + const document = makeDocument(); + document.spritePositions = { + '4key': [ + { + id: RESIZE_SPRITE_ID, + dx: 10, + dy: 20, + width: 200, + height: 100, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, + poses: [ + { + poseId: '00000000-0000-4000-8000-0000000000c2', + triggers: [DEFAULT_KEY_ID], + transform: { x: -30, y: 44, rotation: -90, scale: 0.5 }, + imageOverride: null, + imageOverrideMetrics: null, + }, + ], + activation: 'whileHeld', + pressDurationMs: 300, + transitionMs: 90, + transitionEasing: 'linear', + referenceNaturalSize: null, + } as never, + ], + }; + return document; + }; + + const resizeSpriteOp = (): EditorOpV1 => ({ + kind: 'resizeSprite', + id: RESIZE_SPRITE_ID, + bounds: { dx: 5, dy: 8, width: 400, height: 50 }, + }); + + it('resizeSprite는 bounds 교체와 콘텐츠 스케일을 함께 적용하고 재전송은 noChange다', async () => { + const harness = createHarness(withResizableSprite()); + await harness.coordinator.start(); + + const applied = await harness.coordinator.commitSemanticOpsInternal([ + resizeSpriteOp(), + ]); + expect(applied.opResults).toEqual([ + { status: 'applied', bounds: { dx: 5, dy: 8, width: 400, height: 50 } }, + ]); + // sx=2, sy=0.5 + const sprite = applied.document.spritePositions['4key'][0]; + expect(sprite).toMatchObject({ dx: 5, dy: 8, width: 400, height: 50 }); + expect(sprite.idleTransform).toEqual({ + x: 24, + y: -3, + rotation: 15, + scale: 1.5, + }); + expect(sprite.poses[0].transform).toEqual({ + x: -60, + y: 22, + rotation: -90, + scale: 0.5, + }); + expect(sprite.pivot).toEqual({ x: 0.5, y: 0.5 }); + + const noChange = await harness.coordinator.commitSemanticOpsInternal([ + resizeSpriteOp(), + ]); + expect(noChange.opResults).toEqual([ + { status: 'noChange', bounds: { dx: 5, dy: 8, width: 400, height: 50 } }, + ]); + expect(noChange.document).toEqual(applied.document); + harness.coordinator.stop(); + }); + + it('resizeSprite conflict 재시도는 최신 base 콘텐츠에 배율을 재적용한다', async () => { + const harness = createHarness(withResizableSprite()); + await harness.coordinator.start(); + + harness.transport.commitMock.mockImplementationOnce(async () => { + // 외부 writer가 같은 스프라이트의 자세를 먼저 편집 (last-writer-wins 계약) + const external = structuredClone(harness.transport.canonical.document); + external.spritePositions['4key'][0].poses[0].transform = { + x: 100, + y: 44, + rotation: -90, + scale: 0.5, + }; + harness.transport.canonical.document = external; + harness.transport.canonical.revision += 1; + throw revisionConflict(); + }); + + const outcome = await harness.coordinator.commitSemanticOpsInternal([ + resizeSpriteOp(), + ]); + + // 재시도가 외부 편집이 반영된 base에서 배율을 다시 계산한다: 100*2=200 → clamp 없음 + const sprite = outcome.document.spritePositions['4key'][0]; + expect(sprite).toMatchObject({ dx: 5, dy: 8, width: 400, height: 50 }); + expect(sprite.poses[0].transform).toEqual({ + x: 200, + y: 22, + rotation: -90, + scale: 0.5, + }); + // canonical과 낙관 로컬이 같은 결과로 수렴 + expect( + harness.transport.canonical.document.spritePositions['4key'][0], + ).toEqual(sprite); + expect(harness.getLocal().spritePositions['4key'][0]).toEqual(sprite); + harness.coordinator.stop(); + }); + it('fixed invalid ops는 Promise로 거절하고 transport start 전에 중단한다', async () => { const harness = createHarness(makeDocument()); const invalid = [{ kind: 'setBounds' }] as unknown as EditorOpV1[]; diff --git a/src/renderer/editor/runtime/coordinator/editorCoordinator.ts b/src/renderer/editor/runtime/coordinator/editorCoordinator.ts index 4e14658b0..5869aaf67 100644 --- a/src/renderer/editor/runtime/coordinator/editorCoordinator.ts +++ b/src/renderer/editor/runtime/coordinator/editorCoordinator.ts @@ -57,6 +57,7 @@ import type { EditorField, CanonicalEditorGetResult, EditorGestureCommitContext, + EditorLegacyPatchV1, EditorOpResultV1, EditorOpV1, EditorPatchV1, @@ -556,7 +557,7 @@ class EditorSaveCoordinator { // waitForGestureCommits로 같은 큐를 기다리므로 multiKey provenance가 // false → true로 승격되는 병합 경로가 구조적으로 없다 commitIsolatedPluginPatch( - changes: EditorPatchV1, + changes: EditorPatchV1 | EditorLegacyPatchV1, options: { multiKey: boolean }, ): Promise { this.assertWritable(); @@ -566,7 +567,7 @@ class EditorSaveCoordinator { } private async commitIsolatedPluginPatchInner( - changes: EditorPatchV1, + changes: EditorPatchV1 | EditorLegacyPatchV1, options: { multiKey: boolean }, ): Promise { await this.start(); @@ -577,7 +578,7 @@ class EditorSaveCoordinator { } const canonicalChanges = canonicalizeEditorGradients(changes); - assertEditorPatch(canonicalChanges); + assertEditorPatch(canonicalChanges, 'editor patch', 'input'); const baseDocument = clone(this.requireLastAck()); const target = applyIsolatedPluginPatch(baseDocument, canonicalChanges); const requestFields = EDITOR_FIELDS.filter( @@ -592,7 +593,7 @@ class EditorSaveCoordinator { baseRevision, baseDocument, target: clone(baseDocument), - localFields: getChangedEditorFields(baseDocument, target), + localFields: getChangedEditorFields(baseDocument, target, 'input'), requestFields, gestureIds: [], isolated: true, diff --git a/src/renderer/editor/runtime/coordinator/editorRebaseModel.ts b/src/renderer/editor/runtime/coordinator/editorRebaseModel.ts index 5a8ecf2a6..8d79a6709 100644 --- a/src/renderer/editor/runtime/coordinator/editorRebaseModel.ts +++ b/src/renderer/editor/runtime/coordinator/editorRebaseModel.ts @@ -12,6 +12,7 @@ import type { CanonicalEditorDocumentV1, EditorDocumentV1, EditorElementTypeV1, + EditorEventPatchV1, EditorField, EditorLegacyPatchV1, EditorOpV1, @@ -90,6 +91,19 @@ const frozenOpCasUnit = ( height: entry.height, }; } + if (op.kind === 'resizeSprite') { + const entry = findPositionEntryById(document, 'sprite', op.id); + if (!entry) return null; + // resize가 소유하는 조각 전체 - bounds와 스케일 대상 콘텐츠 + return { + dx: entry.dx, + dy: entry.dy, + width: entry.width, + height: entry.height, + idleTransform: entry.idleTransform, + poses: entry.poses, + }; + } if (op.kind === 'patchElement') { return findPositionEntryById(document, op.elementType, op.id); } @@ -139,9 +153,11 @@ export const frozenPatchOwnedFields = ( export function getChangedEditorFields( base: EditorDocumentV1, next: EditorDocumentV1, + // 플러그인 격리 커밋의 next는 poseId 미발급 상태일 수 있어 방향을 받는다 + nextSpriteMode: 'canonical' | 'input' = 'canonical', ): EditorField[] { assertEditorDocument(base, 'base editor document'); - assertEditorDocument(next, 'next editor document'); + assertEditorDocument(next, 'next editor document', nextSpriteMode); return EDITOR_FIELDS.filter( (field) => stableStringify(base[field]) !== stableStringify(next[field]), @@ -149,15 +165,15 @@ export function getChangedEditorFields( } // 버전 인자 없이 patchForFields를 부르므로 항상 v1이다. 이벤트 patch 타입과 -// 맞도록 반환도 v1로 좁힌다 +// 맞도록 반환도 이벤트 patch로 좁힌다 export function createEditorPatch( base: EditorDocumentV1, next: EditorDocumentV1, -): EditorLegacyPatchV1 { +): EditorEventPatchV1 { return patchForFields( next, getChangedEditorFields(base, next), - ) as EditorLegacyPatchV1; + ) as EditorEventPatchV1; } export function applyEditorPatch( @@ -179,17 +195,18 @@ export function applyEditorPatch( export const applyIsolatedPluginPatch = ( base: CanonicalEditorDocumentV1, - patch: EditorPatchV1, + patch: EditorPatchV1 | EditorLegacyPatchV1, ): EditorDocumentV1 => { assertCanonicalEditorDocument(base, 'isolated plugin base document'); - assertEditorPatch(patch); + // 플러그인 patch는 input 방향 - poseId 생략(백엔드 발급)을 허용한다 + assertEditorPatch(patch, 'editor patch', 'input'); const next: EditorDocumentV1 = clone(base); EDITOR_FIELDS.forEach((field) => { if (patch[field] !== undefined) { Object.assign(next, { [field]: clone(patch[field]) }); } }); - assertEditorDocument(next, 'isolated plugin target document'); + assertEditorDocument(next, 'isolated plugin target document', 'input'); return next; }; diff --git a/src/renderer/editor/runtime/coordinator/editorStateCoordinator.test.ts b/src/renderer/editor/runtime/coordinator/editorStateCoordinator.test.ts index ab0e0a482..d287d0633 100644 --- a/src/renderer/editor/runtime/coordinator/editorStateCoordinator.test.ts +++ b/src/renderer/editor/runtime/coordinator/editorStateCoordinator.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { EditorCommittedV1, EditorDocumentV1 } from '@src/types/editor'; @@ -10,6 +10,7 @@ const runtime = vi.hoisted(() => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }; let listener: ((event: EditorCommittedV1) => void) | null = null; @@ -80,8 +81,14 @@ const committedEvent = ( }); describe('editor state coordinator committed preview cleanup', () => { + beforeEach(() => { + // 프리뷰 구독은 편집 UI 창(main·panel)에서만 열린다 + window.__dmn_window_type = 'main'; + }); + afterEach(() => { editorCoordinator.stop(); + delete window.__dmn_window_type; }); it('recovers lazy subscription and maps committed gesture cleanup', async () => { diff --git a/src/renderer/editor/runtime/coordinator/editorStateCoordinator.ts b/src/renderer/editor/runtime/coordinator/editorStateCoordinator.ts index b824b1cd8..aef7e7b53 100644 --- a/src/renderer/editor/runtime/coordinator/editorStateCoordinator.ts +++ b/src/renderer/editor/runtime/coordinator/editorStateCoordinator.ts @@ -8,6 +8,7 @@ import { useKeyStore, } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { invalidateSelectionForChangedIndexedElementArrays } from '@stores/grid/useGridSelectionStore'; @@ -26,7 +27,14 @@ const hasChanged = (current: unknown, next: unknown) => let previewSubscribed = false; let previewSubscriptionInFlight: Promise | null = null; +// 프리뷰 봉투는 편집 UI가 있는 창(main·panel)끼리의 실시간 피드백 전용. +// 오버레이·OBS는 방송 화면이라 스크럽 중 덜덜거림 없이 커밋 시점만 반영한다 +const PREVIEW_WINDOW_TYPES = new Set(['main', 'panel']); + const ensurePreviewSubscription = (): Promise => { + if (!PREVIEW_WINDOW_TYPES.has(window.__dmn_window_type ?? '')) { + return Promise.resolve(); + } if (previewSubscribed) return Promise.resolve(); if (previewSubscriptionInFlight) return previewSubscriptionInFlight; @@ -58,6 +66,7 @@ export const captureEditorDocument = (): CanonicalEditorDocumentV1 => { statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, }; }; @@ -75,6 +84,7 @@ export const applyEditorDocument = ( stat: useStatItemStore.getState().positions[mode] ?? [], graph: useGraphItemStore.getState().positions[mode] ?? [], knob: useKnobItemStore.getState().positions[mode] ?? [], + sprite: useSpriteStore.getState().positions[mode] ?? [], }, { keyMappings: document.keys[mode] ?? [], @@ -82,6 +92,7 @@ export const applyEditorDocument = ( stat: document.statPositions[mode] ?? [], graph: document.graphPositions[mode] ?? [], knob: document.knobPositions[mode] ?? [], + sprite: document.spritePositions[mode] ?? [], }, ); const keyChanges: Partial< @@ -117,6 +128,11 @@ export const applyEditorDocument = ( ) { useKnobItemStore.setState({ positions: document.knobPositions }); } + if ( + hasChanged(useSpriteStore.getState().positions, document.spritePositions) + ) { + useSpriteStore.setState({ positions: document.spritePositions }); + } if ( hasChanged( useLayerGroupStore.getState().layerGroups, diff --git a/src/renderer/editor/runtime/focusedEditorSettlement.deferred.test.tsx b/src/renderer/editor/runtime/focusedEditorSettlement.deferred.test.tsx new file mode 100644 index 000000000..aac3957ed --- /dev/null +++ b/src/renderer/editor/runtime/focusedEditorSettlement.deferred.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { act, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAfterPaintValueCommit } from '@hooks/useAfterPaintValueCommit'; +import { useOptimisticBooleanCommit } from '@hooks/useOptimisticBooleanCommit'; +import { useOptimisticValueCommit } from '@hooks/useOptimisticValueCommit'; +import { registerPendingOptimisticCommit } from '@hooks/pendingOptimisticCommits'; +import { trackEditorWrite } from './lifecycle/editorWriteBarrier'; +import { settleFocusedEditor } from './lifecycle/focusedEditorSettlement'; + +type CommitKind = 'after-paint' | 'boolean' | 'value'; + +interface DeferredCommitHarnessProps { + kind: CommitKind; + onCommit: () => void; +} + +const DeferredCommitHarness = ({ + kind, + onCommit, +}: DeferredCommitHarnessProps) => { + const ref = useRef(null); + const afterPaint = useAfterPaintValueCommit({ + onCommit, + frameHostRef: ref, + }); + const boolean = useOptimisticBooleanCommit({ + canonicalValue: false, + onCommit, + frameHostRef: ref, + }); + const value = useOptimisticValueCommit({ + canonicalValue: 'before', + onCommit, + frameHostRef: ref, + }); + return ( + + ), +})); +vi.mock('@components/main/Modal/content/dialogs/Alert', () => ({ + default: ({ + isOpen, + message, + onConfirm, + }: { + isOpen: boolean; + message: string; + onConfirm: () => void | Promise; + }) => + isOpen ? ( +
+ {message} + +
+ ) : null, +})); + +import App from './App'; + +describe('App 비동기 확인 동작', () => { + let root: Root; + let host: HTMLDivElement; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + mocks.resetCountersMode.mockReset(); + mocks.confirm = null; + vi.spyOn(console, 'error').mockImplementation(() => {}); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + act(() => root.render()); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + }); + const confirmReset = () => { + act(() => + host.querySelector('[data-reset-counters]')!.click(), + ); + act(() => host.querySelector('[data-confirm]')!.click()); + }; + + it('카운터 초기화 확인은 저장 응답을 받은 뒤 완료된다', async () => { + let finish!: () => void; + mocks.resetCountersMode.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + confirmReset(); + let completed = false; + void mocks.confirm!.then(() => { + completed = true; + }); + await act(async () => { + await Promise.resolve(); + }); + const completedBeforeWrite = completed; + await act(async () => { + finish(); + await mocks.confirm; + }); + expect(completedBeforeWrite).toBe(false); + expect(mocks.resetCountersMode).toHaveBeenCalledOnce(); + }); + + it('카운터 초기화 실패는 처리되지 않은 예외 대신 사용자에게 안내한다', async () => { + mocks.resetCountersMode.mockRejectedValue( + new Error('counter reset failed'), + ); + confirmReset(); + await act(async () => { + await mocks.confirm; + }); + expect(host.querySelector('[data-alert]')?.textContent).toContain( + 'common.actionFailed', + ); + expect(mocks.resetCountersMode).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/renderer/windows/main/App.dialogRuntime.test.tsx b/src/renderer/windows/main/App.dialogRuntime.test.tsx index 856358c91..46479e8ea 100644 --- a/src/renderer/windows/main/App.dialogRuntime.test.tsx +++ b/src/renderer/windows/main/App.dialogRuntime.test.tsx @@ -46,6 +46,9 @@ vi.mock('@contexts/useTranslation', () => ({ useTranslation: () => ({ t: mocks.t }), })); vi.mock('@components/main/TitleBar', () => ({ default: () => null })); +vi.mock('@components/main/EditorSaveNotice', () => ({ + default: () => null, +})); vi.mock('@hooks/app/useCustomCssInjection', () => ({ useCustomCssInjection: vi.fn(), })); diff --git a/src/renderer/windows/main/App.tsx b/src/renderer/windows/main/App.tsx index 8f22119d2..abeb9139a 100644 --- a/src/renderer/windows/main/App.tsx +++ b/src/renderer/windows/main/App.tsx @@ -5,6 +5,7 @@ import { } from './useMainDialogRuntime'; import { useTranslation } from '@contexts/useTranslation'; import TitleBar from '@components/main/TitleBar'; +import EditorSaveNotice from '@components/main/EditorSaveNotice'; import { useCustomCssInjection } from '@hooks/app/useCustomCssInjection'; import { USER_CSS_SCOPE_SELECTOR } from '@utils/css/scopeUserCss'; import { useCustomJsInjection } from '@hooks/app/useCustomJsInjection'; @@ -52,7 +53,7 @@ import { appApi } from '@api/modules/app/appApi'; import { useUIStore } from '@stores/useUIStore'; -type ToolbarAddItemType = 'key' | 'stat' | 'graph' | 'knob'; +type ToolbarAddItemType = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; function getErrorMessage(error: unknown): string { if (error instanceof Error && error.message) { @@ -412,6 +413,7 @@ export default function App() { return (
+
{isSettingsOpen ? (
@@ -507,12 +509,8 @@ export default function App() { settings={shownNoteSettings} onClose={() => setIsNoteSettingOpen(false)} onSave={async (normalized) => { - try { - await settingsApi.update({ noteSettings: normalized }); - setNoteSettings(normalized); - } catch (error) { - console.error('Failed to update note settings', error); - } + await settingsApi.update({ noteSettings: normalized }); + setNoteSettings(normalized); }} /> )} diff --git a/src/renderer/windows/main/useMainDialogRuntime.ts b/src/renderer/windows/main/useMainDialogRuntime.ts index e3e00feb8..1c165e5d1 100644 --- a/src/renderer/windows/main/useMainDialogRuntime.ts +++ b/src/renderer/windows/main/useMainDialogRuntime.ts @@ -60,7 +60,7 @@ export interface MainDialogRuntime { ) => void; showConfirm: ( message: string, - onConfirm: () => void, + onConfirm: () => void | Promise, options?: { onCancel?: () => void; confirmText?: string; @@ -68,7 +68,7 @@ export interface MainDialogRuntime { danger?: boolean; }, ) => void; - handleAlertConfirm: () => void; + handleAlertConfirm: () => Promise; handleAlertCancel: () => void; showCustomDialog: ( html: string, @@ -87,7 +87,7 @@ export interface MainDialogRuntime { closeColorPicker: () => void; handleGlobalColorChange: (newColor: string) => void; handleGlobalColorChangeComplete: (newColor: string) => void; - confirmCallbackRef: React.RefObject<(() => void) | null>; + confirmCallbackRef: React.RefObject<(() => void | Promise) | null>; cancelCallbackRef: React.RefObject<(() => void) | null>; customDialogCallbackRef: React.RefObject; } @@ -95,7 +95,7 @@ export interface MainDialogRuntime { export const useMainDialogRuntime = ({ t, }: UseMainDialogRuntimeOptions): MainDialogRuntime => { - const confirmCallbackRef = useRef<(() => void) | null>(null); + const confirmCallbackRef = useRef<(() => void | Promise) | null>(null); const cancelCallbackRef = useRef<(() => void) | null>(null); const [alertState, setAlertState] = useState(() => ({ isOpen: false, @@ -161,7 +161,7 @@ export const useMainDialogRuntime = ({ const showConfirm = ( message: string, - onConfirm: () => void, + onConfirm: () => void | Promise, options?: { onCancel?: () => void; confirmText?: string; @@ -197,11 +197,16 @@ export const useMainDialogRuntime = ({ cancelCallbackRef.current = null; }; - // 닫은 뒤 콜백 실행 — 콜백이 동기적으로 새 다이얼로그를 열어도 닫히지 않게 - const handleAlertConfirm = () => { + // 닫은 뒤 콜백 실행, 콜백이 연 새 다이얼로그는 유지 + const handleAlertConfirm = async () => { const callback = confirmCallbackRef.current; closeAlert(); - callback?.(); + try { + await callback?.(); + } catch (error) { + console.error('Failed to complete confirmed action', error); + showAlert(t('common.actionFailed')); + } }; const handleAlertCancel = () => { diff --git a/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx b/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx index 6b5a9b55f..dd0ea9553 100644 --- a/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx +++ b/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx @@ -138,12 +138,12 @@ describe('키 표시 지연 타이머', () => { }); }; - const emitKeyState = (state: 'DOWN' | 'UP') => { + const emitKeyState = (state: 'DOWN' | 'UP', mode = '4key') => { act(() => { mocks.keyEventListener?.({ key: 'KeyK', state, - mode: '4key', + mode, eventAgeMs: 0, }); }); @@ -237,6 +237,31 @@ describe('키 표시 지연 타이머', () => { unsubscribe(); }); + it('탭 전환 직후 도착한 이전 모드 이벤트는 새 탭 신호를 켜지 않는다', async () => { + // 백엔드 확인 전에 화면 모드만 먼저 바뀐 구간을 재현 + await act(async () => { + useKeyStore.setState({ + selectedKeyType: '8key', + keyMappings: { '4key': ['KeyK'], '8key': ['KeyK'] }, + positions: { + '4key': [createDefaultKeyPosition(0, 0)], + '8key': [createDefaultKeyPosition(0, 0)], + }, + }); + }); + await flushAsync(); + + emitKeyState('DOWN', '4key'); + expect(vi.getTimerCount()).toBe(0); + expect(getKeySignal('KeyK').value).toBe(false); + + // 현재 탭 이벤트는 그대로 반영된다 + emitKeyState('DOWN', '8key'); + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(30000); + expect(getKeySignal('KeyK').value).toBe(true); + }); + it('keys reset 시 모든 키 타이머를 취소한다', () => { const transitions: boolean[] = []; const unsubscribe = getKeySignal('KeyK').subscribe((value) => { diff --git a/src/renderer/windows/overlay/App.spritePreview.test.tsx b/src/renderer/windows/overlay/App.spritePreview.test.tsx new file mode 100644 index 000000000..5f65bf4d4 --- /dev/null +++ b/src/renderer/windows/overlay/App.spritePreview.test.tsx @@ -0,0 +1,263 @@ +// 오버레이 창의 스프라이트 반영 계약 +// 오버레이는 방송 화면이라 스크럽 프리뷰 봉투를 반영하지 않고 +// 커밋 정산된 canonical만 레이아웃·씬 입력으로 쓴다 +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { resetAllKeySignals } from '@stores/signals/keySignals'; +import { previewOverlay } from '@src/renderer/editor/runtime/gesture/previewOverlay'; + +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; +import type { PreviewEnvelope } from '@src/types/preview'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + bootstrap: vi.fn(), + layoutInputs: [] as Array<{ + currentSpritePositions: ReadonlyArray<{ id?: string | null; dx: number }>; + }>, + sceneSpritePositions: [] as Array< + ReadonlyArray<{ id?: string | null; dx: number }> + >, +})); + +vi.mock('@tauri-apps/api/window', () => ({ + currentMonitor: vi.fn(), + getCurrentWindow: () => ({ + startDragging: vi.fn(() => Promise.resolve()), + }), + Window: { getByLabel: vi.fn() }, +})); +vi.mock('@tauri-apps/api/dpi', () => ({ + LogicalPosition: class LogicalPosition {}, + PhysicalPosition: class PhysicalPosition {}, +})); +vi.mock('@tauri-apps/api/menu', () => ({ + Menu: { new: vi.fn() }, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@hooks/app/useCustomCssInjection', () => ({ + useCustomCssInjection: vi.fn(), +})); +vi.mock('@hooks/app/useCustomJsInjection', () => ({ + useCustomJsInjection: vi.fn(), +})); +vi.mock('@hooks/app/useBlockBrowserShortcuts', () => ({ + useBlockBrowserShortcuts: vi.fn(), +})); +vi.mock('@hooks/app/useAppBootstrap', () => ({ useAppBootstrap: vi.fn() })); +vi.mock('@hooks/overlay/useBuiltinStatsSubscription', () => ({ + useBuiltinStatsSubscription: vi.fn(), +})); +vi.mock('@hooks/overlay/useNoteSystem', () => ({ + useNoteSystem: () => ({ + notesRef: { current: {} }, + subscribe: () => () => {}, + handleKeyDown: vi.fn(), + handleKeyUp: vi.fn(), + finalizeAllActive: vi.fn(), + reconcileActiveNotes: vi.fn(), + noteBuffer: {}, + updateTrackLayouts: vi.fn(), + }), +})); +vi.mock('@stores/data/useStatItemStore', () => ({ + useStatItemStore: ( + selector: (state: { positions: Record }) => T, + ) => selector({ positions: {} }), +})); +vi.mock('@stores/data/useGraphItemStore', () => ({ + useGraphItemStore: ( + selector: (state: { positions: Record }) => T, + ) => selector({ positions: {} }), +})); +vi.mock('@stores/data/useKnobItemStore', () => ({ + useKnobItemStore: ( + selector: (state: { positions: Record }) => T, + ) => selector({ positions: {} }), +})); +vi.mock('@hooks/overlay/useOverlayHitRegions', () => ({ + useOverlayHitRegions: () => {}, + subscribeHitContextMenu: () => () => {}, +})); +vi.mock('@components/shared/OverlayScene', () => ({ + default: (props: { + displaySpritePositions: ReadonlyArray<{ id?: string | null; dx: number }>; + }) => { + mocks.sceneSpritePositions.push(props.displaySpritePositions); + return null; + }, +})); +vi.mock('@hooks/shared/useLayoutComputation', () => ({ + // 합성된 sprite 입력을 그대로 통과시켜 OverlayScene까지의 배선을 관찰 + computeLayout: (input: { + currentSpritePositions: ReadonlyArray<{ id?: string | null; dx: number }>; + }) => { + mocks.layoutInputs.push(input); + return { + bounds: null, + displayPositions: [], + displayStatPositions: [], + displayGraphPositions: [], + displayKnobPositions: [], + displaySpritePositions: input.currentSpritePositions, + positionOffset: { x: 0, y: 0 }, + topOffset: 0, + webglTracks: [], + }; + }, +})); +vi.mock('@utils/input/axisEventBus', () => ({ + axisEventBus: { initialize: vi.fn() }, +})); +vi.mock('@utils/input/keyEventBus', () => ({ + keyEventBus: { + subscribe: vi.fn(() => vi.fn()), + initialize: vi.fn(() => Promise.resolve()), + }, +})); +vi.mock('@api/modules/window/obsApi', () => ({ + obsApi: { + onResync: vi.fn(() => vi.fn()), + }, +})); + +import App from './App'; +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; + +const SPRITE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; +const SESSION_ID = '00000000-0000-4000-8000-0000000000aa'; + +const spritePosition = (dx: number): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, + rotation: 0, + id: SPRITE_ID, + dx, + dy: 25, + width: 200, + height: 120, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + transitionMs: 90, + transitionEasing: 'linear', + referenceNaturalSize: null, +}); + +const remoteEnvelope = ( + overrides: Partial = {}, +): PreviewEnvelope => ({ + schemaVersion: 1, + sessionId: SESSION_ID, + seq: 1, + kind: 'patch', + sourceLabel: 'main', + domain: 'spritePosition', + mode: '4key', + targets: [0], + patch: { dx: 99 }, + ...overrides, +}); + +const lastLayoutSprites = () => + mocks.layoutInputs[mocks.layoutInputs.length - 1].currentSpritePositions; + +const lastSceneSprites = () => + mocks.sceneSpritePositions[mocks.sceneSpritePositions.length - 1]; + +const flushAsync = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + +describe('overlay sprite preview composition', () => { + let container: HTMLDivElement; + let root: Root; + let originalApi: Window['api']; + + beforeEach(async () => { + originalApi = window.api; + mocks.bootstrap.mockReset(); + mocks.bootstrap.mockResolvedValue({ activeKeys: [] }); + mocks.layoutInputs.length = 0; + mocks.sceneSpritePositions.length = 0; + previewOverlay.clearAll(); + window.api = { + app: { bootstrap: mocks.bootstrap }, + keys: { onKeysReset: vi.fn(() => vi.fn()) }, + } as unknown as Window['api']; + usePluginDisplayElementStore.setState({ elements: [] }); + useKeyStore.setState({ + selectedKeyType: '4key', + customTabs: [], + keyMappings: { '4key': ['KeyK'] }, + positions: { '4key': [] }, + canonicalPositions: { '4key': [] }, + isBootstrapped: true, + isLocalUpdateInProgress: false, + }); + useSpriteStore.setState({ positions: { '4key': [spritePosition(15)] } }); + resetAllKeySignals(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render(); + }); + await flushAsync(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + previewOverlay.clearAll(); + resetAllKeySignals(); + window.api = originalApi; + vi.restoreAllMocks(); + }); + + it('원격 프리뷰 patch는 오버레이 레이아웃·씬 입력에 반영되지 않는다', () => { + expect(lastLayoutSprites()[0].dx).toBe(15); + + act(() => { + previewOverlay.applyRemoteEnvelope(remoteEnvelope()); + }); + + expect(lastLayoutSprites()[0].dx).toBe(15); + expect(lastSceneSprites()[0].dx).toBe(15); + // canonical 참조가 그대로 유지되어 layout memo 재계산도 없다 + expect(lastLayoutSprites()).toBe( + useSpriteStore.getState().positions['4key'], + ); + }); + + it('커밋 정산 후에는 갱신된 canonical을 프리뷰 없이 반영한다', async () => { + act(() => { + previewOverlay.applyRemoteEnvelope(remoteEnvelope()); + }); + + // 커밋 정산: canonical 확정 반영 + 세션 종료 + const committed = { '4key': [spritePosition(99)] }; + await act(async () => { + useSpriteStore.setState({ positions: committed }); + previewOverlay.endSession(SESSION_ID); + }); + + expect(lastLayoutSprites()[0].dx).toBe(99); + expect(lastLayoutSprites()).toBe(committed['4key']); + expect(lastSceneSprites()).toBe(committed['4key']); + }); +}); diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index b3d9a6c25..f5ef336c3 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -18,6 +18,7 @@ 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 { useSpriteStore } from '@stores/data/useSpriteStore'; import { useSettingsStore } from '@stores/useSettingsStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useStoreWithEqualityFn } from 'zustand/traditional'; @@ -27,6 +28,7 @@ import { } from '@utils/plugin/layout/pluginLayoutElements'; import OverlayScene from '@components/shared/OverlayScene'; import { computeLayout } from '@hooks/shared/useLayoutComputation'; +import { buildSpriteKeyCanonicalMap } from '@utils/sprite/spriteKeyBinding'; // 슬라이스 부재 시에도 identity 안정 - computeLayout 메모 deps로 사용됨 // 이종 배열 5곳이 같은 인스턴스를 별칭하므로 freeze로 교차 오염 차단 @@ -83,6 +85,7 @@ export default function App() { const statPositions = useStatItemStore((state) => state.positions); const graphPositions = useGraphItemStore((state) => state.positions); const knobPositions = useKnobItemStore((state) => state.positions); + const spritePositions = useSpriteStore((state) => state.positions); // 레이아웃 필드만 투영 구독 - 플러그인 state/html 변경이 App 리렌더로 승격되지 않게 const pluginElements = useStoreWithEqualityFn( usePluginDisplayElementStore, @@ -169,6 +172,16 @@ export default function App() { const currentStatPositions = statPositions[selectedKeyType] ?? EMPTY_SLICE; const currentGraphPositions = graphPositions[selectedKeyType] ?? EMPTY_SLICE; const currentKnobPositions = knobPositions[selectedKeyType] ?? EMPTY_SLICE; + // 오버레이는 방송 화면이라 스크럽 프리뷰를 구독하지 않는다 (커밋 시점만 반영). + // 편집 중 실시간 피드백은 편집 캔버스 몫 + const currentSpritePositions = + spritePositions[selectedKeyType] ?? EMPTY_SLICE; + + // 스프라이트 트리거(키 요소 id) -> canonical 키 문자열, 잎이 시그널을 찾을 때 쓴다 + const spriteKeyCanonicalMap = useMemo( + () => buildSpriteKeyCanonicalMap(currentSlots, currentPositions), + [currentSlots, currentPositions], + ); // 레이아웃 입력이 실제로 바뀔 때만 재계산 - webglTracks identity가 안정되어 // updateTrackLayouts effect·WebGL uniform effect가 무관한 리렌더에 재실행되지 않음 @@ -180,6 +193,7 @@ export default function App() { currentStatPositions, currentGraphPositions, currentKnobPositions, + currentSpritePositions, trackHeight, noteSettings, selectedKeyType, @@ -192,6 +206,7 @@ export default function App() { currentStatPositions, currentGraphPositions, currentKnobPositions, + currentSpritePositions, trackHeight, noteSettings, selectedKeyType, @@ -200,31 +215,26 @@ export default function App() { ], ); const { - bounds, + backgroundBox, + contentBounds, displayPositions, displayStatPositions, displayGraphPositions, displayKnobPositions, + displaySpritePositions, positionOffset, - topOffset, + topMostY, + leftMostX, webglTracks, } = layout; // 레이아웃이 DOM에 반영된 뒤 키 rect를 실측해 네이티브 히트 창과 동기화 useOverlayHitRegions(layout); - // 창 크기와 배경 박스가 같은 공식을 공유 (창 == 콘텐츠 박스 불변식) - // 높이는 computeLayout의 topOffset을 그대로 재사용 - 공식이 한 곳에만 있다 - const contentSize = useMemo( - () => - bounds - ? { - width: bounds.maxX - bounds.minX + overlayPadding * 2, - height: bounds.maxY - bounds.minY + overlayPadding + topOffset, - } - : undefined, - [bounds, overlayPadding, topOffset], - ); + // 창 크기는 computeLayout의 contentSize 하나로 - 창 바운즈(스프라이트 도달 + // 범위·회전 얼굴 포함)와 트랙 밴드·회전 트랙·패딩 공식이 한 곳에만 있다. + // 배경 박스는 backgroundBox(콘텐츠 바운즈 기준)를 쓰므로 오버행 여유는 투명하다 + const contentSize = layout.contentSize ?? undefined; // updateTrackLayouts는 useNoteSystem이 마운트 1회 고정 참조를 보장하므로 // deps 포함이 재실행을 유발하지 않으며, 훅이 updater를 교체하는 미래 변경에도 안전 @@ -241,22 +251,27 @@ export default function App() { height: number; anchor: string; contentTopOffset: number; + contentLeftOffset: number; minX: number; minY: number; } | null>(null); useEffect(() => { - if (!bounds || !contentSize) return; + if (!contentBounds || !contentSize) return; // OBS 오버레이에는 네이티브 창이 없다 - allowlist 밖이라 호출이 항상 거부되고, // 기준선을 지우는 실패 처리와 맞물려 레이아웃이 바뀔 때마다 헛호출이 반복된다 if (window.__dmn_runtime === 'obs') return; const totalWidth = contentSize.width; const totalHeight = contentSize.height; - // computeLayout이 계산한 값을 그대로 - 같은 공식을 여기서 또 쓰지 않는다 - const contentTopOffset = topOffset; - const currentMinX = bounds.minX; - const currentMinY = bounds.minY; + // computeLayout이 계산한 콘텐츠 원점의 창 좌표를 그대로 - 스프라이트 오버행으로 + // 창이 위·왼쪽으로 자라면 이 값의 delta만큼 네이티브가 창 원점을 되당긴다 + const contentTopOffset = topMostY; + const contentLeftOffset = leftMostX; + // fixed-position 델타는 콘텐츠 원점 기준 - 창 바운즈 기준이면 오버행 시 + // 콘텐츠 오프셋 보정과 겹쳐 이중 이동이 된다 + const currentMinX = contentBounds.minX; + const currentMinY = contentBounds.minY; // 이전 값과 비교하여 실제로 변경되었을 때만 resize 호출 const lastParams = lastResizeParams.current; @@ -275,6 +290,7 @@ export default function App() { Math.abs(lastParams.height - totalHeight) < 0.5 && lastParams.anchor === overlayAnchor && Math.abs(lastParams.contentTopOffset - contentTopOffset) < 0.5 && + Math.abs(lastParams.contentLeftOffset - contentLeftOffset) < 0.5 && (!fixedPositionAnchor || (Math.abs(lastParams.minX - currentMinX) < 0.5 && Math.abs(lastParams.minY - currentMinY) < 0.5)) @@ -287,6 +303,7 @@ export default function App() { height: totalHeight, anchor: overlayAnchor, contentTopOffset, + contentLeftOffset, minX: currentMinX, minY: currentMinY, }; @@ -298,6 +315,7 @@ export default function App() { height: totalHeight, anchor: overlayAnchor, contentTopOffset, + contentLeftOffset, fixedPositionDeltaX: fixedPositionAnchor ? fixedPositionDeltaX : undefined, @@ -313,7 +331,14 @@ export default function App() { .finally(() => { setResizeInFlight((count) => Math.max(0, count - 1)); }); - }, [bounds, contentSize, topOffset, overlayAnchor, overlayPadding]); + }, [ + contentBounds, + contentSize, + topMostY, + leftMostX, + overlayAnchor, + overlayPadding, + ]); // 모든 요소가 자리 잡은 뒤 한 번에 공개 - 플러그인 요소가 늦게 뜨며 생기던 덜컥거림 제거 const revealed = useOverlayReveal(isBootstrapped, resizeInFlight > 0); @@ -327,9 +352,11 @@ export default function App() { displayStatPositions={displayStatPositions} displayGraphPositions={displayGraphPositions} displayKnobPositions={displayKnobPositions} + displaySpritePositions={displaySpritePositions} + spriteKeyCanonicalMap={spriteKeyCanonicalMap} selectedKeyType={selectedKeyType} noteEffect={noteEffect} - contentSize={contentSize} + contentSize={backgroundBox ?? undefined} contentFade={contentFade} revealed={revealed} noteSettings={noteSettings} diff --git a/src/types/app.ts b/src/types/app.ts index 6dcb94d8a..32596bd71 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -8,6 +8,7 @@ import { import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; +import type { SpritePositions } from '@src/types/key/sprites'; import type { DefaultsPayload } from '@src/renderer/defaults'; import type { LayerGroups } from '@src/types/layerGroups'; import type { TabNoteOverrides } from '@src/types/settings/noteSettings'; @@ -22,6 +23,7 @@ export interface BootstrapPayload { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; customTabs: CustomTab[]; tabOrder: string[]; barCount: number; @@ -46,10 +48,15 @@ export interface BootstrapPayload { export interface CanonicalBootstrapPayload extends Omit< BootstrapPayload, - 'positions' | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'positions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'spritePositions' > { positions: CanonicalEditorDocumentV1['keyPositions']; statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; } diff --git a/src/types/editor.ts b/src/types/editor.ts index 2cb78848c..4dd93803c 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -9,6 +9,13 @@ import { type KeySlot, } from '@src/types/key/keys'; import type { KnobItemPosition, KnobItemPositions } from '@src/types/key/knobs'; +import { + reactiveSpritePositionInputSchema, + reactiveSpritePositionSchema, + type ReactiveSpritePosition, + type SpritePositions, + type SpritePositionsInput, +} from '@src/types/key/sprites'; import { STAT_ITEM_TYPES, type StatItemPosition, @@ -17,6 +24,7 @@ import { import type { LayerGroupDef, LayerGroups } from '@src/types/layerGroups'; import { isNativeElementId } from '@src/renderer/editor/model/elementId'; import type { ElementShadowValuePatch } from '@src/types/key/shadows'; +import { isElementRotationValue } from '@src/types/key/rotation'; import { isImageTransformLeafPatch, type ImageMode, @@ -46,7 +54,7 @@ export const EDITOR_SCHEMA_VERSION = 1 as const; // 유지하고 id를 additive로 싣는다. v2 커밋은 포함된 모든 위치 항목에 유효 ID가 // 필수라 백엔드가 형식·전역 유일성을 강제한다. 구형 플러그인 gateway만 v1로 남는다 export const EDITOR_COMMIT_SCHEMA_VERSION = 2 as const; -export const EDITOR_OPS_VERSION = 2 as const; +export const EDITOR_OPS_VERSION = 4 as const; export const EDITOR_FIELDS = [ 'keys', @@ -54,6 +62,7 @@ export const EDITOR_FIELDS = [ 'statPositions', 'graphPositions', 'knobPositions', + 'spritePositions', 'layerGroups', ] as const; @@ -66,6 +75,7 @@ export interface EditorDocumentV1 { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; layerGroups: LayerGroups; } @@ -73,16 +83,24 @@ export type CanonicalKeyPosition = KeyPosition & { id: string }; export type CanonicalStatItemPosition = StatItemPosition & { id: string }; export type CanonicalGraphItemPosition = GraphItemPosition & { id: string }; export type CanonicalKnobItemPosition = KnobItemPosition & { id: string }; +export type CanonicalReactiveSpritePosition = ReactiveSpritePosition & { + id: string; +}; export interface CanonicalEditorDocumentV1 extends Omit< EditorDocumentV1, - 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'keyPositions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'spritePositions' > { keyPositions: Record; statPositions: Record; graphPositions: Record; knobPositions: Record; + spritePositions: Record; } export type EditorPatchV1 = { @@ -91,11 +109,21 @@ export type EditorPatchV1 = { | typeof EDITOR_COMMIT_SCHEMA_VERSION; } & Partial>; +// committed 이벤트 전용 패치. 백엔드 patch_for_fields가 만들므로 값은 항상 +// canonical(발급 완료)이고 버전은 v1 고정 - 커밋 요청 전용 v2를 타입에서 배제 +export type EditorEventPatchV1 = { + schemaVersion: typeof EDITOR_SCHEMA_VERSION; +} & Partial>; + // 플러그인 공개 표면 전용 패치. commit wire v2(ID 필수 검증)는 자사 내부 -// 전용이라 플러그인 경계에는 v1만 노출한다 - durable plugin ID는 별도 릴리스 +// 전용이라 플러그인 경계에는 v1만 노출한다 - durable plugin ID는 별도 릴리스. +// 스프라이트는 input 타입을 쓴다 - poseId 생략(백엔드 발급)이 문서화된 계약이라 +// canonical 타입으로는 유효한 커밋을 표현할 수 없다 export type EditorLegacyPatchV1 = { schemaVersion: typeof EDITOR_SCHEMA_VERSION; -} & Partial>; +} & Partial>> & { + spritePositions?: SpritePositionsInput; + }; interface EditorCommitRequestBase { baseRevision: number; @@ -115,7 +143,7 @@ export interface EditorPatchCommitRequest extends EditorCommitRequestBase { ops?: never; } -export type EditorElementTypeV1 = 'key' | 'stat' | 'graph' | 'knob'; +export type EditorElementTypeV1 = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; export interface EditorBoundsV1 { dx: number; @@ -124,6 +152,13 @@ export interface EditorBoundsV1 { height: number; } +// 백엔드 validate_bounds_metrics 상한 (editor.rs MAX_DIMENSION·MAX_ABS_COORDINATE) +// - 저장 좌표 dx·dy의 절댓값과 치수만 검사하고 오른쪽·아래 가장자리는 보지 않는다 +export const EDITOR_BOUNDS_LIMITS = { + maxDimension: 32_768, + maxAbsCoordinate: 32_768, +} as const; + export interface EditorSetBoundsOpV1 { kind: 'setBounds'; elementType: EditorElementTypeV1; @@ -131,6 +166,16 @@ export interface EditorSetBoundsOpV1 { bounds: EditorBoundsV1; } +// 스프라이트 전용 - bounds 교체와 함께 idleTransform·poses의 +// px 좌표를 이전 bounds 대비 배율로 스케일한다. 배율은 적용 시점 문서의 +// bounds 기준(last-writer-wins) - 드래그 중 외부 patch가 끼어들어도 +// 최종 bounds는 요청 값, 콘텐츠는 최신 상태 기준 비례로 수렴한다 +export interface EditorResizeSpriteOpV1 { + kind: 'resizeSprite'; + id: string; + bounds: EditorBoundsV1; +} + export interface EditorDeleteElementOpV1 { kind: 'deleteElement'; elementType: EditorElementTypeV1; @@ -141,7 +186,8 @@ export type EditorFrozenElementV1 = | { elementType: 'key'; slot: KeySlot; position: CanonicalKeyPosition } | { elementType: 'stat'; position: CanonicalStatItemPosition } | { elementType: 'graph'; position: CanonicalGraphItemPosition } - | { elementType: 'knob'; position: CanonicalKnobItemPosition }; + | { elementType: 'knob'; position: CanonicalKnobItemPosition } + | { elementType: 'sprite'; position: CanonicalReactiveSpritePosition }; export interface EditorFrozenZUpdateV1 { elementType: EditorElementTypeV1; @@ -276,6 +322,7 @@ interface EditorElementPropertyValuesV1 { noteAlignment: 'left' | 'center' | 'right'; noteBorderSide: 'all' | 'vertical' | 'horizontal'; statType: 'kps' | 'kpsAvg' | 'kpsMax' | 'total'; + rotation: number; } export type EditorElementPropertyKeyV1 = keyof EditorElementPropertyValuesV1; @@ -363,6 +410,7 @@ export const EDITOR_ELEMENT_PROPERTY_KEYS = [ 'noteAutoYCorrection', 'noteAlignment', 'noteBorderSide', + 'rotation', ] as const satisfies readonly EditorElementPropertyKeyV1[]; type AssertNever = T; @@ -479,6 +527,7 @@ export type EditorPreviewStylePropertyPatchV1 = | 'noteWidth' | 'noteBorderWidth' | 'noteBorderRadius' + | 'rotation' >; // 프리뷰 전용 - 커밋 wire는 leaf 패치지만 프리뷰는 keyPosition에 그대로 얹을 전체 변환을 보낸다 @@ -545,6 +594,7 @@ export interface EditorSetKeySlotOpV1 { export type EditorOpV1 = | EditorSetBoundsOpV1 + | EditorResizeSpriteOpV1 | EditorDeleteElementOpV1 | EditorInsertFrozenElementsOpV1 | EditorReorderElementsOpV1 @@ -630,7 +680,7 @@ export interface EditorCommittedV1 { origin?: string; changedFields: EditorField[]; // 이벤트는 v1 고정 - 커밋 요청 전용 v2를 타입에서 배제한다 - patch: EditorLegacyPatchV1; + patch: EditorEventPatchV1; // 커밋 요청의 gestureId echo, 수신 창의 프리뷰 오버레이 정리 신호 gestureId?: string | null; // 합쳐진 커밋에 포함된 모든 프리뷰 세션 정리 신호 @@ -718,6 +768,8 @@ export const EDITOR_CAPACITY_VALIDATION_CODES: ReadonlySet = new Set([ // setElementGroups targets 상한. 빈 배열은 plugin-only 편집으로 허용되므로 // 이 코드는 순수 배치 초과 전용이다 'INVALID_ELEMENT_GROUP_TARGET_COUNT', + // 자세당 담당 키 상한 (MAX_SPRITE_POSE_TRIGGERS) + 'TOO_MANY_SPRITE_POSE_TRIGGERS', // validate_plugin_instances_request 말미의 compact size 검사. // TOO_MANY_PLUGIN_INSTANCES와 같은 gesture 승격 채널로 프론트에 도달한다 'PLUGIN_INSTANCES_REQUEST_TOO_LARGE', @@ -1107,6 +1159,30 @@ const assertKnobPosition: ( } }; +// 스프라이트 검증 방향. canonical(서빙)은 grandfather 컬렉션을 관대하게 받고, +// input(플러그인 patch)은 poseId 생략을 허용하는 대신 상한을 선차단한다 +type SpriteAssertMode = 'canonical' | 'input'; + +// 스프라이트는 KeyPosition 형태가 아니라 전용 스키마로 검증한다. +// nullable 필드는 스키마가 직접 허용하므로 별도 정규화가 필요 없다 +const assertSpritePosition: ( + value: unknown, + label: string, + mode: SpriteAssertMode, +) => asserts value is ReactiveSpritePosition = ( + value: unknown, + label: string, + mode: SpriteAssertMode, +) => { + const schema = + mode === 'input' + ? reactiveSpritePositionInputSchema + : reactiveSpritePositionSchema; + if (!schema.safeParse(value).success) { + throw new EditorProtocolError(`${label} is not a valid sprite position`); + } +}; + const assertLayerGroups = (value: unknown, label: string): void => { assertModeRecord(value, label, (item, itemLabel) => { if ( @@ -1124,6 +1200,7 @@ const assertEditorCollections = ( value: Record, label: string, partial: boolean, + spriteMode: SpriteAssertMode = 'canonical', ): void => { const validators: Record< EditorField, @@ -1138,6 +1215,10 @@ const assertEditorCollections = ( assertModeRecord(collection, collectionLabel, assertGraphPosition), knobPositions: (collection, collectionLabel) => assertModeRecord(collection, collectionLabel, assertKnobPosition), + spritePositions: (collection, collectionLabel) => + assertModeRecord(collection, collectionLabel, (item, itemLabel) => + assertSpritePosition(item, itemLabel, spriteMode), + ), layerGroups: assertLayerGroups, }; @@ -1204,6 +1285,7 @@ const assertLayerGroupReferences = ( 'statPositions', 'graphPositions', 'knobPositions', + 'spritePositions', ] as const ).forEach((field) => { Object.entries(value[field] as Record).forEach( @@ -1226,6 +1308,7 @@ const assertLayerGroupReferences = ( }); }; +// 그라데이션 형제 필드 정규화 대상. 스프라이트는 그라데이션 필드가 없어 제외 const POSITION_COLLECTION_FIELDS = [ 'keyPositions', 'statPositions', @@ -1259,7 +1342,7 @@ const canonicalizePositionCollection = ( * 변경이 없으면 동일 참조 반환 */ export function canonicalizeEditorGradients< - T extends EditorPatchV1 | EditorDocumentV1, + T extends EditorPatchV1 | EditorLegacyPatchV1 | EditorDocumentV1, >(value: T): T { if (!isRecord(value)) return value; let changed = false; @@ -1281,11 +1364,12 @@ export function canonicalizeEditorGradients< export function assertEditorDocument( value: unknown, label = 'editor document', + spriteMode: SpriteAssertMode = 'canonical', ): asserts value is EditorDocumentV1 { if (!isRecord(value) || value.schemaVersion !== EDITOR_SCHEMA_VERSION) { throw new EditorProtocolError(`${label} has an unsupported schema version`); } - assertEditorCollections(value, label, false); + assertEditorCollections(value, label, false, spriteMode); assertPairedCollections(value, label); assertLayerGroupReferences(value, label); } @@ -1302,6 +1386,7 @@ export function assertCanonicalEditorDocument( ['statPositions', document.statPositions], ['graphPositions', document.graphPositions], ['knobPositions', document.knobPositions], + ['spritePositions', document.spritePositions], ] as const; for (const [field, collection] of collections) { for (const [mode, positions] of Object.entries(collection)) { @@ -1320,11 +1405,41 @@ export function assertCanonicalEditorDocument( } } } + // pose id는 요소 id와 같은 전역 네임스페이스 - 백엔드 backfill·검증이 + // 요소를 먼저 등록한 뒤 pose를 도는 순서를 그대로 미러한다 + for (const [mode, positions] of Object.entries(document.spritePositions)) { + for (const [index, position] of positions.entries()) { + for (const [poseIndex, pose] of position.poses.entries()) { + const poseLabel = `${label}.spritePositions.${mode}[${index}].poses[${poseIndex}]`; + if (!isNativeElementId(pose.poseId)) { + throw new EditorProtocolError(`${poseLabel}.poseId is invalid`); + } + if (seen.has(pose.poseId)) { + throw new EditorProtocolError( + `${poseLabel}.poseId is not globally unique`, + ); + } + seen.add(pose.poseId); + } + } + } } +// input 모드는 poseId 생략을 허용하므로 canonical EditorPatchV1로 좁히면 +// 타입 술어가 거짓이 된다 - 오버로드로 방향별 결과 타입을 분리 +export function assertEditorPatch( + value: unknown, + label?: string, +): asserts value is EditorPatchV1; +export function assertEditorPatch( + value: unknown, + label: string, + spriteMode: SpriteAssertMode, +): asserts value is EditorPatchV1 | EditorLegacyPatchV1; export function assertEditorPatch( value: unknown, label = 'editor patch', + spriteMode: SpriteAssertMode = 'canonical', ): asserts value is EditorPatchV1 { if ( !isRecord(value) || @@ -1339,7 +1454,7 @@ export function assertEditorPatch( if (unknownKey) { throw new EditorProtocolError(`${label}.${unknownKey} is not supported`); } - assertEditorCollections(value, label, true); + assertEditorCollections(value, label, true, spriteMode); } export function assertEditorFields( @@ -1460,6 +1575,34 @@ function assertEditorFrozenElement( assertFrozenPositionZIndex(value.position, `${label}.position`); return; } + if (value.elementType === 'sprite') { + // frozen 요소는 canonical 문서에서 뜬 스냅샷이라 서빙 방향으로 검증 + assertSpritePosition(value.position, `${label}.position`, 'canonical'); + const position = value.position as Record; + if (!isNativeElementId(position.id)) { + throw new EditorProtocolError(`${label}.position.id is invalid`); + } + // poseId도 canonical 스냅샷 요건 - 형식은 여기서, 유일성은 op 수준 + // insertedIds(요소 id와 같은 전역 네임스페이스)에서 확인한다 + const poses = (value.position as ReactiveSpritePosition).poses; + for (const [poseIndex, pose] of poses.entries()) { + if (!isNativeElementId(pose.poseId)) { + throw new EditorProtocolError( + `${label}.position.poses[${poseIndex}].poseId is invalid`, + ); + } + } + // 스프라이트 zIndex는 null 허용 - 정수 범위 검사는 값이 있을 때만 + if ( + position.zIndex !== null && + (!Number.isSafeInteger(position.zIndex) || + (position.zIndex as number) < -2_147_483_648 || + (position.zIndex as number) > 2_147_483_647) + ) { + throw new EditorProtocolError(`${label}.position.zIndex is invalid`); + } + return; + } throw new EditorProtocolError(`${label}.elementType is invalid`); } @@ -1709,6 +1852,9 @@ const isEditorElementPropertyValueValid = ( value >= 0 && value <= 50 ); + case 'rotation': + // 네이티브 4종 공통. 스프라이트는 자세 회전이 따로 있다 + return elementType !== 'sprite' && isElementRotationValue(value); case 'notePaint': case 'noteGlowPaint': return elementType === 'key' && isNotePaintValuePatchV1(value); @@ -1818,7 +1964,9 @@ export function assertEditorOpsV1( if (op.kind === 'setBounds') { assertExactKeys(op, ['kind', 'elementType', 'id', 'bounds'], opLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes(op.elementType as string) || + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( + op.elementType as string, + ) || !isNativeElementId(op.id) ) { throw new EditorProtocolError(`${opLabel} target is invalid`); @@ -1827,10 +1975,21 @@ export function assertEditorOpsV1( assertEditorBounds(op.bounds, `${opLabel}.bounds`); return; } + if (op.kind === 'resizeSprite') { + assertExactKeys(op, ['kind', 'id', 'bounds'], opLabel); + if (!isNativeElementId(op.id)) { + throw new EditorProtocolError(`${opLabel} target is invalid`); + } + assertUniqueDirectTarget(op.id, opLabel); + assertEditorBounds(op.bounds, `${opLabel}.bounds`); + return; + } if (op.kind === 'deleteElement') { assertExactKeys(op, ['kind', 'elementType', 'id'], opLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes(op.elementType as string) || + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( + op.elementType as string, + ) || !isNativeElementId(op.id) ) { throw new EditorProtocolError(`${opLabel} target is invalid`); @@ -1841,7 +2000,9 @@ export function assertEditorOpsV1( if (op.kind === 'patchElement') { assertExactKeys(op, ['kind', 'elementType', 'id', 'patch'], opLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes(op.elementType as string) || + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( + op.elementType as string, + ) || !isNativeElementId(op.id) || !isRecord(op.patch) ) { @@ -1880,7 +2041,7 @@ export function assertEditorOpsV1( } assertExactKeys(target, ['elementType', 'id'], targetLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes( + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( target.elementType as string, ) || typeof target.id !== 'string' || @@ -1986,7 +2147,7 @@ export function assertEditorOpsV1( targetLabel: string, ) => { if ( - !['key', 'stat', 'graph', 'knob'].includes( + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( target.elementType as string, ) || !isNativeElementId(target.id) @@ -2068,6 +2229,17 @@ export function assertEditorOpsV1( ); } insertedIds.add(id); + if (element.elementType === 'sprite') { + // poseId는 요소 id와 같은 전역 네임스페이스 - 백엔드 seen 집합 미러 + for (const pose of (element.position as ReactiveSpritePosition).poses) { + if (insertedIds.has(pose.poseId)) { + throw new EditorProtocolError( + `${opLabel}.elements contains an invalid or duplicate ID`, + ); + } + insertedIds.add(pose.poseId); + } + } }); const groupIds = new Set(); op.groups.forEach((group, groupIndex) => { @@ -2094,7 +2266,7 @@ export function assertEditorOpsV1( } assertExactKeys(update, ['elementType', 'id', 'zIndex'], updateLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes( + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( update.elementType as string, ) || !isNativeElementId(update.id) || @@ -2175,6 +2347,7 @@ export function assertEditorOpCommitResult( stat: 'statPositions', graph: 'graphPositions', knob: 'knobPositions', + sprite: 'spritePositions', }; const requiredFields = new Set(); const allowedFields = new Set(); @@ -2196,6 +2369,22 @@ export function assertEditorOpCommitResult( } return; } + if (op.kind === 'resizeSprite') { + if ( + result.status !== 'targetMissing' && + ((result.status !== 'applied' && result.status !== 'noChange') || + result.bounds === undefined) + ) { + throw new EditorProtocolError( + `editor_commit opResults[${index}] is invalid for resizeSprite`, + ); + } + if (result.status === 'applied') { + requiredFields.add('spritePositions'); + allowedFields.add('spritePositions'); + } + return; + } if (op.kind === 'deleteElement') { if ( result.status === 'noChange' || @@ -2401,6 +2590,30 @@ export function assertEditorCommittedEvent(value: EditorCommittedV1): void { } } } + // 스프라이트는 POSITION_COLLECTION_FIELDS(그라데이션 대상) 밖이라 별도 순회. + // 백엔드 committed patch는 항상 발급 완료 상태라 id·poseId를 같은 + // 전역 네임스페이스로 강제한다 + if (value.patch.spritePositions !== undefined) { + for (const [mode, positions] of Object.entries( + value.patch.spritePositions, + )) { + for (const [index, position] of positions.entries()) { + const spriteLabel = `editor:committed patch.spritePositions.${mode}[${index}]`; + if (!isNativeElementId(position.id) || suppliedIds.has(position.id)) { + throw new EditorProtocolError(`${spriteLabel}.id is invalid`); + } + suppliedIds.add(position.id); + for (const [poseIndex, pose] of position.poses.entries()) { + if (!isNativeElementId(pose.poseId) || suppliedIds.has(pose.poseId)) { + throw new EditorProtocolError( + `${spriteLabel}.poses[${poseIndex}].poseId is invalid`, + ); + } + suppliedIds.add(pose.poseId); + } + } + } + } const patchFields = EDITOR_FIELDS.filter( (field) => value.patch[field] !== undefined, ); diff --git a/src/types/key/imageLayer.ts b/src/types/key/imageLayer.ts index d17d2b484..104db35c9 100644 --- a/src/types/key/imageLayer.ts +++ b/src/types/key/imageLayer.ts @@ -1,9 +1,10 @@ import { z } from 'zod'; +import { ELEMENT_ROTATION_RANGE } from './rotation'; // 키 이미지 레이어 - 모드와 상태별 변환. 저장 구조는 Rust ImageMode/ImageTransform과 동일 export const IMAGE_TRANSFORM_CONSTRAINTS = { offset: { min: -500, max: 500 }, - rotation: { min: -180, max: 180 }, + rotation: ELEMENT_ROTATION_RANGE, scale: { min: 0.1, max: 10 }, } as const; diff --git a/src/types/key/keys.ts b/src/types/key/keys.ts index 8fb0842fc..e5f2edc3c 100644 --- a/src/types/key/keys.ts +++ b/src/types/key/keys.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { imageModeSchema, imageTransformSchema } from './imageLayer'; +import { DEFAULT_ELEMENT_ROTATION, elementRotationSchema } from './rotation'; import { elementShadowSpecSchema } from './shadows'; import { NOTE_SETTINGS_CONSTRAINTS } from '../settings/noteSettingsConstraints'; import { getDefaultCounterSettings } from '@src/renderer/defaults'; @@ -277,6 +278,9 @@ export const keyPositionSchema = z.object({ dy: z.number(), width: z.number().positive(), height: z.number().positive(), + // 요소 회전(도). 논리 상자는 축 정렬 그대로 두고 얼굴·트랙·외부 카운터가 + // 상자 중심 기준으로 돈다. 백엔드가 항상 직렬화하고 구형 문서·플러그인 입력은 0 + rotation: elementRotationSchema.default(DEFAULT_ELEMENT_ROTATION), // 레이어 표시 여부 (false면 그리드/오버레이에서 렌더링하지 않음) hidden: z.boolean().optional().default(false), activeImage: z.string().optional().or(z.literal('')), diff --git a/src/types/key/rotation.ts b/src/types/key/rotation.ts new file mode 100644 index 000000000..9167880ee --- /dev/null +++ b/src/types/key/rotation.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +// 요소 회전 각도 계약. 요소 회전·스프라이트 자세·키 이미지 레이어가 같은 범위를 쓴다. +// Rust ELEMENT_ROTATION_MIN/MAX와 동일 (도 단위, 양수 시계방향) +export const ELEMENT_ROTATION_RANGE = Object.freeze({ min: -180, max: 180 }); + +export const elementRotationSchema = z + .number() + .finite() + .min(ELEMENT_ROTATION_RANGE.min) + .max(ELEMENT_ROTATION_RANGE.max); + +export const DEFAULT_ELEMENT_ROTATION = 0; + +export const isElementRotationValue = (value: unknown): value is number => + typeof value === 'number' && + Number.isFinite(value) && + value >= ELEMENT_ROTATION_RANGE.min && + value <= ELEMENT_ROTATION_RANGE.max; diff --git a/src/types/key/sprites.test.ts b/src/types/key/sprites.test.ts new file mode 100644 index 000000000..fefe7a169 --- /dev/null +++ b/src/types/key/sprites.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest'; + +import { + findDuplicateTriggerPose, + reactiveSpritePositionInputSchema, + reactiveSpritePositionSchema, + type SpritePose, +} from './sprites'; + +// 백엔드 직렬화 실물 형태. layerName·groupId는 None이면 키 자체가 생략된다 +// (다른 요소 위치와 같은 관례) - 부재를 거부하면 부트스트랩 검증이 통째로 실패한다 +const backendWireSprite = { + baseImage: null, + className: null, + dx: 625, + dy: 130, + height: 215, + hidden: false, + id: '566b0333-494c-4d47-a76d-506b71e5ac4c', + idleTransform: { rotation: 0, scale: 1, x: 0, y: 0 }, + pivot: { x: 0.5, y: 0.5 }, + poses: [], + activation: 'whileHeld', + pressDurationMs: 300, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + transitionMs: 90, + useInlineStyles: null, + width: 210, + zIndex: null, +}; + +describe('reactiveSpritePositionSchema', () => { + it('구형 데이터의 배치 회전을 0으로 채우고 자세 회전과 독립적으로 보존한다', () => { + expect(reactiveSpritePositionSchema.parse(backendWireSprite).rotation).toBe( + 0, + ); + for (const rotation of [-180, -45.5, 0, 45.5, 180]) { + for (const schema of [ + reactiveSpritePositionSchema, + reactiveSpritePositionInputSchema, + ]) { + const parsed = schema.parse({ ...backendWireSprite, rotation }); + expect(parsed.rotation).toBe(rotation); + expect(parsed.idleTransform).toEqual(backendWireSprite.idleTransform); + } + } + }); + + it.each([null, '45', true, NaN, Infinity, -Infinity, -180.01, 180.01])( + '잘못된 배치 회전 %s를 거부한다', + (rotation) => { + for (const schema of [ + reactiveSpritePositionSchema, + reactiveSpritePositionInputSchema, + ]) { + expect( + schema.safeParse({ ...backendWireSprite, rotation }).success, + ).toBe(false); + } + }, + ); + + it('layerName·groupId가 생략된 백엔드 직렬화 형태를 허용한다', () => { + expect( + reactiveSpritePositionSchema.safeParse(backendWireSprite).success, + ).toBe(true); + }); + + it('layerName·groupId가 null로 온 형태도 허용한다', () => { + expect( + reactiveSpritePositionSchema.safeParse({ + ...backendWireSprite, + layerName: null, + groupId: null, + }).success, + ).toBe(true); + }); + + it('layerName·groupId 문자열 값을 보존 허용한다', () => { + expect( + reactiveSpritePositionSchema.safeParse({ + ...backendWireSprite, + layerName: '왼손', + groupId: 'group-1', + }).success, + ).toBe(true); + }); +}); + +describe('reactiveSpritePositionSchema wire 계약', () => { + const withPose = (pose: Partial) => ({ + ...backendWireSprite, + poses: [ + { + poseId: 'pose-1', + triggers: ['566b0333-494c-4d47-a76d-506b71e5ac4c'], + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, + ...pose, + }, + ], + }); + + it('빈 트리거 자세도 인입은 허용한다 - 복구 store 서빙이 부트스트랩을 죽이면 안 된다', () => { + expect( + reactiveSpritePositionSchema.safeParse(withPose({ triggers: [] })) + .success, + ).toBe(true); + }); + + it('input 스키마는 트리거 개수·id 길이 상한을 넘으면 거부한다', () => { + const tooMany = Array.from({ length: 513 }, (_, i) => `id-${i}`); + expect( + reactiveSpritePositionInputSchema.safeParse( + withPose({ triggers: tooMany }), + ).success, + ).toBe(false); + expect( + reactiveSpritePositionInputSchema.safeParse( + withPose({ triggers: ['a'.repeat(65)] }), + ).success, + ).toBe(false); + }); + + it('canonical 스키마는 상한 밖 컬렉션 서빙도 허용한다 - grandfather 데이터가 부트스트랩을 죽이면 안 된다', () => { + const tooMany = Array.from({ length: 513 }, (_, i) => `id-${i}`); + expect( + reactiveSpritePositionSchema.safeParse(withPose({ triggers: tooMany })) + .success, + ).toBe(true); + expect( + reactiveSpritePositionSchema.safeParse(withPose({ triggers: [''] })) + .success, + ).toBe(true); + }); + + it('input 스키마는 poseId 누락을 허용한다 - 백엔드가 새 id를 발급하는 공개 계약', () => { + expect( + reactiveSpritePositionInputSchema.safeParse( + withPose({ poseId: undefined }), + ).success, + ).toBe(true); + expect( + reactiveSpritePositionSchema.safeParse(withPose({ poseId: undefined })) + .success, + ).toBe(false); + }); + + it('id는 생략만 허용하고 명시 null은 거부한다 - Rust String decode와 동일', () => { + const { id: _id, ...withoutId } = backendWireSprite; + expect(reactiveSpritePositionSchema.safeParse(withoutId).success).toBe( + true, + ); + expect( + reactiveSpritePositionSchema.safeParse({ + ...backendWireSprite, + id: null, + }).success, + ).toBe(false); + expect( + reactiveSpritePositionInputSchema.safeParse({ + ...backendWireSprite, + id: null, + }).success, + ).toBe(false); + }); + + it('zIndex·transitionMs 소수는 Rust 정수 decode와 같이 거부한다', () => { + expect( + reactiveSpritePositionSchema.safeParse({ + ...backendWireSprite, + zIndex: 1.5, + }).success, + ).toBe(false); + expect( + reactiveSpritePositionSchema.safeParse({ + ...backendWireSprite, + transitionMs: 90.5, + }).success, + ).toBe(false); + }); + + it('상태 기준점은 생략·null·0..1 값을 허용하고 범위 밖은 거부한다', () => { + expect(reactiveSpritePositionSchema.safeParse(withPose({})).success).toBe( + true, + ); + expect( + reactiveSpritePositionSchema.safeParse(withPose({ pivot: null })).success, + ).toBe(true); + expect( + reactiveSpritePositionSchema.safeParse( + withPose({ pivot: { x: 0, y: 1 } }), + ).success, + ).toBe(true); + expect( + reactiveSpritePositionInputSchema.safeParse( + withPose({ pivot: { x: -0.001, y: 0.5 } }), + ).success, + ).toBe(false); + }); +}); + +describe('findDuplicateTriggerPose', () => { + const pose = (poseId: string, triggers: string[]): SpritePose => ({ + imageOverrideMetrics: null, + poseId, + triggers, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }); + + it('빈 트리거 자세는 미완성 draft라 중복으로 판정하지 않는다', () => { + expect(findDuplicateTriggerPose([pose('a', []), pose('b', [])])).toBeNull(); + }); + + it('순서·중복 트리거가 달라도 같은 집합이면 뒤 자세를 반환한다', () => { + const duplicate = pose('b', ['k2', 'k1', 'k1']); + expect(findDuplicateTriggerPose([pose('a', ['k1', 'k2']), duplicate])).toBe( + duplicate, + ); + }); + + it('빈 자세가 섞여 있어도 실제 중복은 검출한다', () => { + const duplicate = pose('c', ['k1']); + expect( + findDuplicateTriggerPose([pose('a', ['k1']), pose('b', []), duplicate]), + ).toBe(duplicate); + }); + + it('서로 다른 집합은 중복이 아니다', () => { + expect( + findDuplicateTriggerPose([pose('a', ['k1']), pose('b', ['k1', 'k2'])]), + ).toBeNull(); + }); +}); diff --git a/src/types/key/sprites.ts b/src/types/key/sprites.ts new file mode 100644 index 000000000..ac2a56fdb --- /dev/null +++ b/src/types/key/sprites.ts @@ -0,0 +1,274 @@ +import { z } from 'zod'; +import { + DEFAULT_ELEMENT_ROTATION, + ELEMENT_ROTATION_RANGE, + elementRotationSchema, +} from './rotation'; + +// 반응형 스프라이트: 키 입력에 따라 자세가 바뀌는 이미지 레이어. +// 키 요소와 달리 키 매핑에 인덱스 결합하지 않고, 담당 키를 자세마다 직접 참조한다. +// 저장 구조는 Rust ReactiveSpritePosition과 동일. +// 스키마는 두 벌이다 - canonical(서빙)은 복구·grandfather를 거친 store가 +// 상한 밖 컬렉션을 서빙해도 부트스트랩이 죽지 않게 개수 상한을 강제하지 않고, +// input(플러그인 patch)은 백엔드 커밋 검증과 같은 상한을 선차단한다 + +export const SPRITE_CONSTRAINTS = { + offset: { min: -2000, max: 2000 }, + rotation: ELEMENT_ROTATION_RANGE, + scale: { min: 0.1, max: 10 }, + anchor: { min: 0, max: 1 }, + transitionMs: { min: 0, max: 1000 }, + // 카운터 애니메이션과 같은 범위 - 백엔드 검증과 동일 + pressDurationMs: { min: 1, max: 5000 }, + // 이미지 원본 픽셀 크기(정수). WebView 디코드 상한과 같은 32768 + imageMetrics: { dimensionMin: 1, dimensionMax: 32768 }, + maxPoses: 64, + // 백엔드 MAX_SPRITE_POSE_TRIGGERS와 동일 + maxTriggersPerPose: 512, + // 요소 id는 UUID 계열(urn 표기 45자까지) - 형식 검증은 백엔드 전담, 길이만 상한 + triggerIdMaxLength: 64, +} as const; + +// 생성 기본 크기. Rust ReactiveSpritePosition Default와 동일 +export const DEFAULT_SPRITE_SIZE = 200; + +export const spriteTransformSchema = z.object({ + x: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.offset.min) + .max(SPRITE_CONSTRAINTS.offset.max), + y: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.offset.min) + .max(SPRITE_CONSTRAINTS.offset.max), + rotation: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.rotation.min) + .max(SPRITE_CONSTRAINTS.rotation.max), + scale: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.scale.min) + .max(SPRITE_CONSTRAINTS.scale.max), +}); +export type SpriteTransform = z.infer; + +export const IDENTITY_SPRITE_TRANSFORM: SpriteTransform = Object.freeze({ + x: 0, + y: 0, + rotation: 0, + scale: 1, +}); + +// 요소 로컬 px 사각형 - 배치 계산 결과에만 쓰고 저장하지 않는다. +// 이미지 상자는 항상 요소 상자(0, 0, width, height)다 +export interface SpriteRect { + x: number; + y: number; + width: number; + height: number; +} + +// 이미지 기준 정규화 좌표. CSS transform-origin으로 쓴다 +export const spriteAnchorSchema = z.object({ + x: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.anchor.min) + .max(SPRITE_CONSTRAINTS.anchor.max), + y: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.anchor.min) + .max(SPRITE_CONSTRAINTS.anchor.max), +}); +export type SpriteAnchor = z.infer; + +export const CENTER_SPRITE_ANCHOR: SpriteAnchor = Object.freeze({ + x: 0.5, + y: 0.5, +}); + +// 원본 픽셀 크기 - WebView 디코드가 보고한 naturalWidth/Height. Rust u32라 정수 +const spriteNaturalDimensionSchema = z + .number() + .int() + .min(SPRITE_CONSTRAINTS.imageMetrics.dimensionMin) + .max(SPRITE_CONSTRAINTS.imageMetrics.dimensionMax); + +// 이미지 경로에 결합된 원본 크기. source가 현재 경로와 같을 때만 유효하고, +// 경로만 바뀐 stale 값은 커밋에서 거절된다. 없으면 이미지를 요소 상자에 그대로 그린다 +export const spriteImageMetricsSchema = z.object({ + source: z.string().min(1), + width: spriteNaturalDimensionSchema, + height: spriteNaturalDimensionSchema, +}); +export type SpriteImageMetrics = z.infer; + +// 픽셀 배율의 기준 크기. base가 있으면 source=base 경로, base를 +// 지우면 크기는 두고 source만 null(자세 이미지만 남아도 배율이 흔들리지 않게) +export const spriteReferenceNaturalSizeSchema = z.object({ + source: z.string().min(1).nullable(), + width: spriteNaturalDimensionSchema, + height: spriteNaturalDimensionSchema, +}); +export type SpriteReferenceNaturalSize = z.infer< + typeof spriteReferenceNaturalSizeSchema +>; + +// 키 반응 방식: 누르는 동안 유지 vs 누른 순간 한 번 재생. +// 백엔드 SpriteActivation과 동일 camelCase 문자열 +const spriteActivationSchema = z.union([ + z.literal('whileHeld'), + z.literal('onPress'), +]); +export type SpriteActivation = z.infer; + +export const DEFAULT_SPRITE_ACTIVATION: SpriteActivation = 'whileHeld'; +export const DEFAULT_SPRITE_PRESS_DURATION_MS = 300; + +// Rust u32 - 소수는 decode 거부라 정수로 고정 +const spritePressDurationSchema = z + .number() + .int() + .min(SPRITE_CONSTRAINTS.pressDurationMs.min) + .max(SPRITE_CONSTRAINTS.pressDurationMs.max); + +// 백엔드 normalize_sprite_triggers와 동일한 canonical 정규형(정렬+중복 제거). +// ASCII UUID만 통과하므로 JS 사전순과 Rust 바이트순이 갈리지 않는다 +export const normalizeSpriteTriggers = ( + triggers: readonly string[], +): string[] => [...new Set(triggers)].sort(); + +// 트리거 집합 동일성 키. 중복 검출과 자세 해석이 같은 규칙을 공유한다 +export const spriteTriggerSetKey = (triggers: readonly string[]): string => + normalizeSpriteTriggers(triggers).join('\n'); + +const spritePoseBaseShape = { + // 사용자 지정 이름. 없으면 UI가 '상태 N'으로 표시하고, 백엔드는 None이면 키를 생략한다 + name: z.string().nullish(), + transform: spriteTransformSchema, + imageOverride: z.string().nullable(), +}; + +// canonical(서빙): 키 요소 id 목록. 물리 키가 아니라 레인에 결합해서 키 매핑을 +// 바꿔도 자리를 유지한다. 빈 배열·빈 문자열·상한 초과는 인입 허용 - 복구·grandfather를 +// 거친 store가 서빙해도 부트스트랩이 죽지 않아야 한다 (신규 커밋은 백엔드 검증이 차단) +const spritePoseSchema = z.object({ + poseId: z.string().min(1), + triggers: z.array(z.string()), + ...spritePoseBaseShape, + // null 또는 키 생략이면 기본 이미지 기준점을 따라가고, 값이 있으면 상태 이미지의 독립 기준점 + // 백엔드는 연결 상태를 키 생략으로 직렬화하므로 canonical도 같은 wire shape을 받는다 + pivot: spriteAnchorSchema.nullish(), + // imageOverride 경로에 결합된 원본 크기. 없으면 상자 수식 폴백. + // 구형 문서는 키가 없으므로 부재를 null로 받는다 + imageOverrideMetrics: spriteImageMetricsSchema.nullable().default(null), +}); +export type SpritePose = z.infer; + +// input(플러그인 patch): poseId 누락은 백엔드가 새 id를 발급하는 공개 계약이라 허용, +// 컬렉션 상한은 백엔드 커밋 검증과 같은 값으로 선차단 +const spritePoseInputSchema = z.object({ + poseId: z.string().min(1).optional(), + triggers: z + .array(z.string().min(1).max(SPRITE_CONSTRAINTS.triggerIdMaxLength)) + .max(SPRITE_CONSTRAINTS.maxTriggersPerPose), + ...spritePoseBaseShape, + // 기존 poseId에서 생략하면 canonical 값을 유지하고 null은 기본 기준점 연결 + pivot: spriteAnchorSchema.nullish(), + // 기존 poseId의 생략은 백엔드가 canonical 값을 보존한다(v1 presence 병합). + // 같은 patch에서 imageOverride가 바뀌면 생략은 null로 정규화된다 + imageOverrideMetrics: spriteImageMetricsSchema.nullish(), +}); + +const reactiveSpritePositionBaseShape = { + // 백엔드 발급 요소 id. 발급 전에는 키 생략만 허용 - 명시 null은 Rust String + // decode가 거부한다. 서빙 문서는 canonical 검증이 필수화한다 + id: z.string().min(1).optional(), + // 요소 상자 = 기본 이미지 상자. 창 크기 계산에 그대로 들어간다 + dx: z.number().finite(), + dy: z.number().finite(), + width: z.number().finite().positive(), + height: z.number().finite().positive(), + rotation: elementRotationSchema.default(DEFAULT_ELEMENT_ROTATION), + hidden: z.boolean(), + // Rust i32 - 소수·범위 초과는 decode에서 INVALID_REQUEST_PAYLOAD로 죽으므로 여기서 거른다 + zIndex: z.number().int().min(-2_147_483_648).max(2_147_483_647).nullable(), + // 백엔드는 다른 요소 위치와 같이 None이면 두 필드를 직렬화에서 생략한다 + layerName: z.string().nullish(), + groupId: z.string().nullish(), + className: z.string().nullable(), + useInlineStyles: z.boolean().nullable(), + + baseImage: z.string().nullable(), + // 회전·배율 축이자 크기가 다른 자세 이미지를 맞추는 고정점 + pivot: spriteAnchorSchema, + + idleTransform: spriteTransformSchema, + + // Rust u32 - 소수는 decode 거부라 정수로 고정 + transitionMs: z + .number() + .int() + .min(SPRITE_CONSTRAINTS.transitionMs.min) + .max(SPRITE_CONSTRAINTS.transitionMs.max), + transitionEasing: z.string(), +}; + +export const reactiveSpritePositionSchema = z.object({ + ...reactiveSpritePositionBaseShape, + // 서빙은 BE serde default가 항상 채우므로 필수 + activation: spriteActivationSchema, + pressDurationMs: spritePressDurationSchema, + poses: z.array(spritePoseSchema), + // 기준 크기 - 구형 문서에는 키가 없으므로 부재를 null로 받는다 + referenceNaturalSize: spriteReferenceNaturalSizeSchema + .nullable() + .default(null), +}); +export type ReactiveSpritePosition = z.infer< + typeof reactiveSpritePositionSchema +>; + +export const reactiveSpritePositionInputSchema = z.object({ + ...reactiveSpritePositionBaseShape, + // 구 플러그인 patch 호환 - 생략 허용, BE serde default가 채움 + activation: spriteActivationSchema.optional(), + pressDurationMs: spritePressDurationSchema.optional(), + poses: z.array(spritePoseInputSchema).max(SPRITE_CONSTRAINTS.maxPoses), + // 기존 id의 생략은 백엔드가 canonical 값을 보존한다(v1 presence 병합). + // 같은 patch에서 baseImage가 바뀌면 생략은 null로 정규화된다 + referenceNaturalSize: spriteReferenceNaturalSizeSchema.nullish(), +}); +export type ReactiveSpritePositionInput = z.infer< + typeof reactiveSpritePositionInputSchema +>; + +export type SpritePositions = Record; +export type SpritePositionsInput = Record< + string, + ReactiveSpritePositionInput[] +>; + +export const DEFAULT_SPRITE_TRANSITION_MS = 0; +export const DEFAULT_SPRITE_TRANSITION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)'; + +// 같은 담당 키 집합을 가진 자세가 둘이면 해석이 모호해진다. 저장 전에 걸러낸다. +// 빈 트리거는 중복이 아니라 미완성 draft다 - 백엔드 검증(EMPTY vs DUPLICATE)과 같은 구분 +export const findDuplicateTriggerPose = ( + poses: readonly SpritePose[], +): SpritePose | null => { + const seen = new Set(); + for (const pose of poses) { + if (pose.triggers.length === 0) continue; + // 집합 비교라 pose 안의 중복 트리거도 무시한다 + const key = spriteTriggerSetKey(pose.triggers); + if (seen.has(key)) return pose; + seen.add(key); + } + return null; +}; diff --git a/src/types/plugin/api.ts b/src/types/plugin/api.ts index 3021988d2..aaca672c2 100644 --- a/src/types/plugin/api.ts +++ b/src/types/plugin/api.ts @@ -11,6 +11,7 @@ import { import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; +import type { SpritePositions } from '@src/types/key/sprites'; import type { LayerGroups } from '@src/types/layerGroups'; import type { EditorPluginCommitResult, @@ -370,6 +371,7 @@ export type PresetSnapshot = { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; customTabs: CustomTab[]; tabOrder: string[]; barCount: number; diff --git a/src/types/preview.ts b/src/types/preview.ts index 5bf6d308c..1d487a2cc 100644 --- a/src/types/preview.ts +++ b/src/types/preview.ts @@ -6,7 +6,8 @@ export type PreviewDomain = | 'keyPosition' | 'statPosition' | 'graphPosition' - | 'knobPosition'; + | 'knobPosition' + | 'spritePosition'; export type PreviewEnvelopeKind = 'patch' | 'cancel'; diff --git a/tests/editor-ops-parity.test.ts b/tests/editor-ops-parity.test.ts index 142d0e47f..77e94278c 100644 --- a/tests/editor-ops-parity.test.ts +++ b/tests/editor-ops-parity.test.ts @@ -45,6 +45,7 @@ const fixture = (): OpsParityFixture => // 잡으므로 신규 op 추가 시 이 목록과 fixture 케이스를 함께 늘려야 한다 const ALL_OP_KINDS = { setBounds: true, + resizeSprite: true, deleteElement: true, insertFrozenElements: true, reorderElements: true, @@ -77,7 +78,7 @@ const createParityHarness = (initial: CanonicalEditorDocumentV1) => { changedFields, opResults: (request.ops ?? []).map( (op): EditorOpResultV1 => - op.kind === 'setBounds' + op.kind === 'setBounds' || op.kind === 'resizeSprite' ? { status, bounds: op.bounds } : { status }, ), diff --git a/tests/editor-property-wire-parity.test.ts b/tests/editor-property-wire-parity.test.ts index f05772d8b..070207329 100644 --- a/tests/editor-property-wire-parity.test.ts +++ b/tests/editor-property-wire-parity.test.ts @@ -23,14 +23,14 @@ const fixture = (): PropertyTagFixture => describe('editor property wire parity', () => { it('canonical property 태그 배열은 공유 fixture와 순서까지 일치한다', () => { const { properties } = fixture(); - expect(properties).toHaveLength(75); + expect(properties).toHaveLength(76); expect([...EDITOR_ELEMENT_PROPERTY_KEYS]).toEqual(properties); }); - it('ops 버전은 fixture version과 일치하는 2다', () => { + it('ops 버전은 fixture version과 일치하는 4이다', () => { // 양방향 anchor: 한쪽 상수만 승격되면 fixture 대조에서 걸린다 // (Rust는 models::editor 테스트가 같은 fixture와 대조) - expect(EDITOR_OPS_VERSION).toBe(2); + expect(EDITOR_OPS_VERSION).toBe(4); expect(fixture().version).toBe(EDITOR_OPS_VERSION); }); diff --git a/tests/element-rotation-parity.test.ts b/tests/element-rotation-parity.test.ts new file mode 100644 index 000000000..77e6214a9 --- /dev/null +++ b/tests/element-rotation-parity.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_ELEMENT_ROTATION, + ELEMENT_ROTATION_RANGE, + elementRotationSchema, +} from '../src/types/key/rotation'; +import { IMAGE_TRANSFORM_CONSTRAINTS } from '../src/types/key/imageLayer'; +import { SPRITE_CONSTRAINTS } from '../src/types/key/sprites'; +import { keyPositionSchema } from '../src/types/key/keys'; + +// Rust 테스트(src-tauri models)와 같은 fixture를 공유해 요소 회전 범위·기본값이 +// 프론트 zod 경계와 백엔드 검증 상수에서 기계적으로 일치함을 고정한다 +const FIXTURE_PATH = join( + __dirname, + 'fixtures', + 'element-rotation-parity.json', +); + +interface ElementRotationFixture { + rotation: { min: number; max: number }; + default: number; +} + +const fixture = (): ElementRotationFixture => + JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as ElementRotationFixture; + +describe('element rotation parity', () => { + it('회전 범위와 기본값이 fixture와 일치한다', () => { + const { rotation, default: fallback } = fixture(); + expect(ELEMENT_ROTATION_RANGE).toEqual(rotation); + expect(DEFAULT_ELEMENT_ROTATION).toBe(fallback); + }); + + it('스프라이트 자세·키 이미지 레이어 회전도 같은 범위를 쓴다', () => { + expect(SPRITE_CONSTRAINTS.rotation).toEqual(fixture().rotation); + expect(IMAGE_TRANSFORM_CONSTRAINTS.rotation).toEqual(fixture().rotation); + }); + + it('zod 경계는 fixture 경계값을 포함하고 바깥·비유한을 거부한다', () => { + const { min, max } = fixture().rotation; + expect(elementRotationSchema.safeParse(min).success).toBe(true); + expect(elementRotationSchema.safeParse(max).success).toBe(true); + expect(elementRotationSchema.safeParse(min - 0.001).success).toBe(false); + expect(elementRotationSchema.safeParse(max + 0.001).success).toBe(false); + expect(elementRotationSchema.safeParse(Number.NaN).success).toBe(false); + expect(elementRotationSchema.safeParse(Infinity).success).toBe(false); + expect(elementRotationSchema.safeParse(null).success).toBe(false); + }); + + it('keyPosition은 rotation 생략을 기본값으로 채우고 명시 null은 거부한다', () => { + const base = { + dx: 0, + dy: 0, + width: 60, + height: 60, + count: 0, + noteColor: '#FFFFFF', + noteOpacity: 90, + }; + const parsed = keyPositionSchema.parse(base); + expect(parsed.rotation).toBe(fixture().default); + expect( + keyPositionSchema.safeParse({ ...base, rotation: 45.5 }).success, + ).toBe(true); + expect(keyPositionSchema.safeParse({ ...base, rotation: null }).success).toBe( + false, + ); + expect(keyPositionSchema.safeParse({ ...base, rotation: 181 }).success).toBe( + false, + ); + }); +}); diff --git a/tests/fixtures/editor-capacity-codes.json b/tests/fixtures/editor-capacity-codes.json index 5358768ce..64078626b 100644 --- a/tests/fixtures/editor-capacity-codes.json +++ b/tests/fixtures/editor-capacity-codes.json @@ -11,6 +11,7 @@ "TOO_MANY_LAYER_GROUPS", "TOO_MANY_MODES", "TOO_MANY_PLUGIN_INSTANCES", + "TOO_MANY_SPRITE_POSE_TRIGGERS", "TOO_MANY_RENDER_ITEMS", "TOO_MANY_SLOTS_PER_MEMBER" ] diff --git a/tests/fixtures/editor-ops-parity.json b/tests/fixtures/editor-ops-parity.json index f345209ea..3f7f00aaa 100644 --- a/tests/fixtures/editor-ops-parity.json +++ b/tests/fixtures/editor-ops-parity.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 4, "comment": "semantic op 결과 동등성 파리티 fixture. 소비자: src-tauri/src/state/editor_ops_parity.rs (canonical 적용기), tests/editor-ops-parity.test.ts (프론트 낙관 적용기). 각 케이스는 같은 initialDocument에 같은 ops를 적용했을 때 양측이 만드는 문서가 expectedDocument와 일치함을 고정한다. 플러그인 그룹 멤버 원천(양측 근사 지점)은 양쪽 모두 비어 있는 상태를 전제한다.", "cases": [ { @@ -25,7 +25,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -55,7 +56,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -81,7 +83,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -111,7 +114,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -177,7 +181,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -259,7 +264,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -343,7 +349,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -457,7 +464,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -494,7 +502,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -541,7 +550,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -666,7 +676,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -807,7 +818,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -858,7 +870,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -922,7 +935,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -983,7 +997,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1088,7 +1103,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1184,7 +1200,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1334,7 +1351,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1387,7 +1405,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1490,7 +1509,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1538,7 +1558,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1625,7 +1646,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1667,17 +1689,15 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { "kind": "patchElement", "elementType": "key", "id": "11111111-1111-4111-8111-111111111111", - "patch": { - "property": "noteGlowSyncPaint", - "value": true - } + "patch": { "property": "noteGlowSyncPaint", "value": true } } ], "expectedDocument": { @@ -1729,7 +1749,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1758,7 +1779,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1834,7 +1856,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1920,7 +1943,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -2022,7 +2046,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -2070,7 +2095,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -2129,7 +2155,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -2180,7 +2207,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -2214,7 +2242,8 @@ "statPositions": { "4key": [] }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [] } + "layerGroups": { "4key": [] }, + "spritePositions": {} } }, { @@ -2240,7 +2269,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -2337,7 +2367,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-new", "name": "New Group" }] } + "layerGroups": { "4key": [{ "id": "group-new", "name": "New Group" }] }, + "spritePositions": {} } }, { @@ -2387,7 +2418,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -2455,7 +2487,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -2512,7 +2545,8 @@ { "id": "group-a", "name": "Group A" }, { "id": "group-b", "name": "Group B" } ] - } + }, + "spritePositions": {} }, "ops": [ { @@ -2604,7 +2638,8 @@ { "id": "group-a", "name": "Group A" }, { "id": "group-b", "name": "Group B" } ] - } + }, + "spritePositions": {} } }, { @@ -2641,7 +2676,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -2701,7 +2737,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [] } + "layerGroups": { "4key": [] }, + "spritePositions": {} } }, { @@ -2742,7 +2779,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -2801,7 +2839,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-new", "name": "Fresh" }] } + "layerGroups": { "4key": [{ "id": "group-new", "name": "Fresh" }] }, + "spritePositions": {} } }, { @@ -2858,7 +2897,8 @@ { "id": "group-a", "name": "Group A" }, { "id": "group-b", "name": "Group B" } ] - } + }, + "spritePositions": {} }, "ops": [ { @@ -2925,7 +2965,8 @@ { "id": "group-a", "name": "Group A" }, { "id": "group-b", "name": "Group B" } ] - } + }, + "spritePositions": {} } }, { @@ -2952,7 +2993,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -2987,7 +3029,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [] } + "layerGroups": { "4key": [] }, + "spritePositions": {} } }, { @@ -3014,7 +3057,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -3045,7 +3089,378 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Renamed" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Renamed" }] }, + "spritePositions": {} + } + }, + { + "name": "resizesprite-scale-with-setbounds-batch", + "comment": "resizeSprite는 bounds 교체 + 이전 bounds 대비 배율(sx=2, sy=0.5)로 idleTransform·poses 오프셋을 스케일한다. rotation·scale·pivot 불변. 같은 배치의 다른 요소 setBounds와 원자 커밋", + "initialDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 0, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": {}, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": { + "4key": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "dx": 10, + "dy": 20, + "width": 200, + "height": 100, + "hidden": false, + "zIndex": null, + "className": null, + "useInlineStyles": null, + "baseImage": null, + "pivot": { "x": 0.5, "y": 0.5 }, + "idleTransform": { + "x": 12, + "y": -6, + "rotation": 15, + "scale": 1.5 + }, + "poses": [ + { + "poseId": "44444444-4444-4444-8444-444444444444", + "triggers": ["11111111-1111-4111-8111-111111111111"], + "transform": { + "x": -30, + "y": 44, + "rotation": -90, + "scale": 0.5 + }, + "imageOverride": null + } + ], + "activation": "whileHeld", + "pressDurationMs": 300, + "transitionMs": 90, + "transitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" + } + ] + } + }, + "ops": [ + { + "kind": "resizeSprite", + "id": "22222222-2222-4222-8222-222222222222", + "bounds": { "dx": 5, "dy": 8, "width": 400, "height": 50 } + }, + { + "kind": "setBounds", + "elementType": "key", + "id": "11111111-1111-4111-8111-111111111111", + "bounds": { "dx": 100, "dy": 0, "width": 60, "height": 60 } + } + ], + "expectedDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 100, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": {}, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": { + "4key": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "dx": 5, + "dy": 8, + "width": 400, + "height": 50, + "hidden": false, + "zIndex": null, + "className": null, + "useInlineStyles": null, + "baseImage": null, + "pivot": { "x": 0.5, "y": 0.5 }, + "idleTransform": { + "x": 24, + "y": -3, + "rotation": 15, + "scale": 1.5 + }, + "poses": [ + { + "poseId": "44444444-4444-4444-8444-444444444444", + "triggers": ["11111111-1111-4111-8111-111111111111"], + "transform": { + "x": -60, + "y": 22, + "rotation": -90, + "scale": 0.5 + }, + "imageOverride": null + } + ], + "activation": "whileHeld", + "pressDurationMs": 300, + "transitionMs": 90, + "transitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" + } + ] + } + } + }, + { + "name": "resizesprite-pure-move-keeps-content", + "comment": "치수 불변 이동은 배율 1이라 콘텐츠를 건드리지 않는다", + "initialDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 0, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": {}, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": { + "4key": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "dx": 10, + "dy": 20, + "width": 200, + "height": 100, + "hidden": false, + "zIndex": null, + "className": null, + "useInlineStyles": null, + "baseImage": null, + "pivot": { "x": 0.5, "y": 0.5 }, + "idleTransform": { + "x": 12, + "y": -6, + "rotation": 15, + "scale": 1.5 + }, + "poses": [ + { + "poseId": "44444444-4444-4444-8444-444444444444", + "triggers": ["11111111-1111-4111-8111-111111111111"], + "transform": { + "x": -30, + "y": 44, + "rotation": -90, + "scale": 0.5 + }, + "imageOverride": null + } + ], + "activation": "whileHeld", + "pressDurationMs": 300, + "transitionMs": 90, + "transitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" + } + ] + } + }, + "ops": [ + { + "kind": "resizeSprite", + "id": "22222222-2222-4222-8222-222222222222", + "bounds": { "dx": 300, "dy": -40, "width": 200, "height": 100 } + } + ], + "expectedDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 0, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": {}, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": { + "4key": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "dx": 300, + "dy": -40, + "width": 200, + "height": 100, + "hidden": false, + "zIndex": null, + "className": null, + "useInlineStyles": null, + "baseImage": null, + "pivot": { "x": 0.5, "y": 0.5 }, + "idleTransform": { + "x": 12, + "y": -6, + "rotation": 15, + "scale": 1.5 + }, + "poses": [ + { + "poseId": "44444444-4444-4444-8444-444444444444", + "triggers": ["11111111-1111-4111-8111-111111111111"], + "transform": { + "x": -30, + "y": 44, + "rotation": -90, + "scale": 0.5 + }, + "imageOverride": null + } + ], + "activation": "whileHeld", + "pressDurationMs": 300, + "transitionMs": 90, + "transitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" + } + ] + } + } + }, + { + "name": "patch-rotation-scalar", + "comment": "요소 회전 스칼라 patch. 네 종류 공통 필드라 flatten 상속 대상(stat)까지 같은 값으로 적용된다", + "initialDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 0, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": { + "4key": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "statType": "kps", + "dx": 120, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": {} + }, + "ops": [ + { + "kind": "patchElement", + "elementType": "key", + "id": "11111111-1111-4111-8111-111111111111", + "patch": { "property": "rotation", "value": -45.5 } + }, + { + "kind": "patchElement", + "elementType": "stat", + "id": "44444444-4444-4444-8444-444444444444", + "patch": { "property": "rotation", "value": 90 } + } + ], + "expectedDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 0, + "dy": 0, + "width": 60, + "height": 60, + "rotation": -45.5, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": { + "4key": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "statType": "kps", + "dx": 120, + "dy": 0, + "width": 60, + "height": 60, + "rotation": 90, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": {} } } ] diff --git a/tests/fixtures/editor-property-tags.json b/tests/fixtures/editor-property-tags.json index 3ab5834fa..376cb49c7 100644 --- a/tests/fixtures/editor-property-tags.json +++ b/tests/fixtures/editor-property-tags.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 4, "properties": [ "hidden", "layerName", @@ -75,6 +75,7 @@ "noteBorderRadius", "noteAutoYCorrection", "noteAlignment", - "noteBorderSide" + "noteBorderSide", + "rotation" ] } diff --git a/tests/fixtures/element-rotation-parity.json b/tests/fixtures/element-rotation-parity.json new file mode 100644 index 000000000..887af6ba4 --- /dev/null +++ b/tests/fixtures/element-rotation-parity.json @@ -0,0 +1,7 @@ +{ + "rotation": { + "min": -180, + "max": 180 + }, + "default": 0 +} diff --git a/tests/fixtures/sprite-constraint-parity.json b/tests/fixtures/sprite-constraint-parity.json new file mode 100644 index 000000000..d280a6499 --- /dev/null +++ b/tests/fixtures/sprite-constraint-parity.json @@ -0,0 +1,36 @@ +{ + "offset": { + "min": -2000, + "max": 2000 + }, + "rotation": { + "min": -180, + "max": 180 + }, + "scale": { + "min": 0.1, + "max": 10 + }, + "anchor": { + "min": 0, + "max": 1 + }, + "transitionMs": { + "min": 0, + "max": 1000 + }, + "maxPoses": 64, + "maxTriggersPerPose": 512, + "pressDurationMs": { + "min": 1, + "max": 5000 + }, + "defaultTransitionMs": 0, + "defaultTransitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)", + "defaultActivation": "whileHeld", + "defaultPressDurationMs": 300, + "imageMetrics": { + "dimensionMin": 1, + "dimensionMax": 32768 + } +} diff --git a/tests/fixtures/sprite-resize-scale-parity.json b/tests/fixtures/sprite-resize-scale-parity.json new file mode 100644 index 000000000..036346acd --- /dev/null +++ b/tests/fixtures/sprite-resize-scale-parity.json @@ -0,0 +1,108 @@ +{ + "version": 1, + "comment": "resizeSprite 스케일 수학 파리티 fixture. ratio = next/prev (무효·비유한은 1, 언더플로 0 허용), 값 적용 = clamp(value * ratio, min, max), clamp는 max(min)→min(max) 순서. 스케일 대상은 idleTransform·자세 transform의 x/y(offset ±2000)뿐이다. 소비자: tests/sprite-resize-scale-parity.test.ts, src-tauri Rust 테스트. expectedHex는 IEEE754 f64 big-endian 비트 - 양측이 to_bits로 대조한다", + "ranges": { + "offset": [ + -2000, + 2000 + ] + }, + "cases": [ + { + "name": "offset-uniform-double", + "field": "offset", + "prev": 100, + "next": 200, + "value": 250, + "expected": 500, + "expectedHex": "0x407f400000000000" + }, + { + "name": "offset-nonuniform-shrink", + "field": "offset", + "prev": 320, + "next": 200, + "value": -40.5, + "expected": -25.3125, + "expectedHex": "0xc039500000000000" + }, + { + "name": "offset-clamp-max", + "field": "offset", + "prev": 100, + "next": 1000, + "value": 250, + "expected": 2000, + "expectedHex": "0x409f400000000000" + }, + { + "name": "offset-clamp-min", + "field": "offset", + "prev": 100, + "next": 1000, + "value": -250, + "expected": -2000, + "expectedHex": "0xc09f400000000000" + }, + { + "name": "offset-negative-zero", + "field": "offset", + "prev": 100, + "next": 200, + "value": -0, + "expected": -0, + "expectedHex": "0x8000000000000000" + }, + { + "name": "offset-third-bits", + "field": "offset", + "prev": 3, + "next": 1, + "value": 99, + "expected": 33, + "expectedHex": "0x4040800000000000" + }, + { + "name": "offset-ratio-underflow-zero", + "field": "offset", + "prev": 4, + "next": 5e-324, + "value": 123, + "expected": 0, + "expectedHex": "0x0000000000000000" + }, + { + "name": "offset-ratio-one-passthrough-beyond-max", + "field": "offset", + "prev": 100, + "next": 100, + "value": 5000, + "expected": 5000, + "expectedHex": "0x40b3880000000000" + }, + { + "name": "ratio-subnormal-overflow-guard", + "field": "ratio", + "prev": 5e-324, + "next": 32768, + "expected": 1, + "expectedHex": "0x3ff0000000000000" + }, + { + "name": "ratio-underflow-zero", + "field": "ratio", + "prev": 4, + "next": 5e-324, + "expected": 0, + "expectedHex": "0x0000000000000000" + }, + { + "name": "ratio-identity-same", + "field": "ratio", + "prev": 240, + "next": 240, + "expected": 1, + "expectedHex": "0x3ff0000000000000" + } + ] +} diff --git a/tests/sprite-constraint-parity.test.ts b/tests/sprite-constraint-parity.test.ts new file mode 100644 index 000000000..f7876e5c0 --- /dev/null +++ b/tests/sprite-constraint-parity.test.ts @@ -0,0 +1,279 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_SPRITE_ACTIVATION, + DEFAULT_SPRITE_PRESS_DURATION_MS, + DEFAULT_SPRITE_TRANSITION_EASING, + DEFAULT_SPRITE_TRANSITION_MS, + SPRITE_CONSTRAINTS, + reactiveSpritePositionInputSchema, + reactiveSpritePositionSchema, + spriteAnchorSchema, + spriteImageMetricsSchema, + spriteTransformSchema, +} from '../src/types/key/sprites'; + +// Rust 테스트(src-tauri models)와 같은 fixture를 공유해, 프론트 zod 경계와 +// 백엔드 검증 상수가 기계적으로 일치함을 고정한다. 한쪽만 바뀌면 프론트가 +// 통과시킨 커밋을 백엔드가 거부하는(또는 역방향) 침묵 드리프트가 된다 +const FIXTURE_PATH = join( + __dirname, + 'fixtures', + 'sprite-constraint-parity.json', +); + +interface SpriteConstraintFixture { + offset: { min: number; max: number }; + rotation: { min: number; max: number }; + scale: { min: number; max: number }; + anchor: { min: number; max: number }; + transitionMs: { min: number; max: number }; + maxPoses: number; + maxTriggersPerPose: number; + pressDurationMs: { min: number; max: number }; + defaultTransitionMs: number; + defaultTransitionEasing: string; + defaultActivation: string; + defaultPressDurationMs: number; + imageMetrics: { dimensionMin: number; dimensionMax: number }; +} + +const fixture = JSON.parse( + readFileSync(FIXTURE_PATH, 'utf8'), +) as SpriteConstraintFixture; + +const baseSprite = () => ({ + id: '00000000-0000-4000-8000-0000000000a0', + dx: 0, + dy: 0, + width: 200, + height: 200, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [] as unknown[], + activation: 'whileHeld', + pressDurationMs: 300, + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + referenceNaturalSize: null, +}); + +const pose = (poseId: string, triggers: string[]) => ({ + poseId, + triggers, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, +}); + +describe('sprite constraint parity (fixture)', () => { + it('fixture는 SPRITE_CONSTRAINTS 항목을 전수 커버한다', () => { + // triggerIdMaxLength는 FE 전용 방어선(백엔드는 UUID 형식 검증 전담)이라 + // 파리티 대상에서 의도적으로 제외 - 새 constraint가 생기면 여기서 실패한다 + const constraintKeys = Object.keys(SPRITE_CONSTRAINTS) + .filter((key) => key !== 'triggerIdMaxLength') + .sort(); + const fixtureKeys = Object.keys(fixture) + .filter((key) => !key.startsWith('default')) + .sort(); + expect(fixtureKeys).toEqual(constraintKeys); + }); + + it('SPRITE_CONSTRAINTS 수치는 fixture와 일치한다', () => { + expect(SPRITE_CONSTRAINTS.offset).toEqual(fixture.offset); + expect(SPRITE_CONSTRAINTS.rotation).toEqual(fixture.rotation); + expect(SPRITE_CONSTRAINTS.scale).toEqual(fixture.scale); + expect(SPRITE_CONSTRAINTS.anchor).toEqual(fixture.anchor); + expect(SPRITE_CONSTRAINTS.transitionMs).toEqual(fixture.transitionMs); + expect(SPRITE_CONSTRAINTS.imageMetrics).toEqual(fixture.imageMetrics); + expect(SPRITE_CONSTRAINTS.maxPoses).toBe(fixture.maxPoses); + expect(SPRITE_CONSTRAINTS.maxTriggersPerPose).toBe( + fixture.maxTriggersPerPose, + ); + expect(SPRITE_CONSTRAINTS.pressDurationMs).toEqual(fixture.pressDurationMs); + }); + + it('전환 기본값은 fixture와 일치한다 - easing은 바이트 단위 동일해야 프리셋 매칭이 산다', () => { + expect(DEFAULT_SPRITE_TRANSITION_MS).toBe(fixture.defaultTransitionMs); + expect(DEFAULT_SPRITE_TRANSITION_EASING).toBe( + fixture.defaultTransitionEasing, + ); + expect(DEFAULT_SPRITE_ACTIVATION).toBe(fixture.defaultActivation); + expect(DEFAULT_SPRITE_PRESS_DURATION_MS).toBe( + fixture.defaultPressDurationMs, + ); + }); + + it('transform 스칼라는 fixture 경계에서 통과하고 근소 초과에서 거부된다', () => { + const cases: Array<[string, number, number]> = [ + ['x', fixture.offset.min, fixture.offset.max], + ['y', fixture.offset.min, fixture.offset.max], + ['rotation', fixture.rotation.min, fixture.rotation.max], + ['scale', fixture.scale.min, fixture.scale.max], + ]; + const identity = { x: 0, y: 0, rotation: 0, scale: 1 }; + for (const [field, min, max] of cases) { + expect( + spriteTransformSchema.safeParse({ ...identity, [field]: min }).success, + ).toBe(true); + expect( + spriteTransformSchema.safeParse({ ...identity, [field]: max }).success, + ).toBe(true); + expect( + spriteTransformSchema.safeParse({ ...identity, [field]: min - 0.001 }) + .success, + ).toBe(false); + expect( + spriteTransformSchema.safeParse({ ...identity, [field]: max + 0.001 }) + .success, + ).toBe(false); + } + }); + + it('pivot·원본 크기·transitionMs도 fixture 경계를 그대로 강제한다', () => { + expect( + spriteAnchorSchema.safeParse({ x: fixture.anchor.max, y: 0 }).success, + ).toBe(true); + expect( + spriteAnchorSchema.safeParse({ x: fixture.anchor.max + 0.001, y: 0 }) + .success, + ).toBe(false); + + const metrics = { source: 'a.png', width: 100, height: 100 }; + expect( + spriteImageMetricsSchema.safeParse({ + ...metrics, + width: fixture.imageMetrics.dimensionMax, + }).success, + ).toBe(true); + expect( + spriteImageMetricsSchema.safeParse({ + ...metrics, + width: fixture.imageMetrics.dimensionMax + 1, + }).success, + ).toBe(false); + expect( + spriteImageMetricsSchema.safeParse({ + ...metrics, + height: fixture.imageMetrics.dimensionMin - 1, + }).success, + ).toBe(false); + // Rust u32 계약 - 소수 픽셀은 거부 + expect( + spriteImageMetricsSchema.safeParse({ ...metrics, width: 100.5 }).success, + ).toBe(false); + + expect( + reactiveSpritePositionSchema.safeParse({ + ...baseSprite(), + transitionMs: fixture.transitionMs.max, + }).success, + ).toBe(true); + expect( + reactiveSpritePositionSchema.safeParse({ + ...baseSprite(), + transitionMs: fixture.transitionMs.max + 1, + }).success, + ).toBe(false); + + for (const value of [ + fixture.pressDurationMs.min, + fixture.pressDurationMs.max, + ]) { + expect( + reactiveSpritePositionSchema.safeParse({ + ...baseSprite(), + pressDurationMs: value, + }).success, + ).toBe(true); + } + expect( + reactiveSpritePositionSchema.safeParse({ + ...baseSprite(), + pressDurationMs: fixture.pressDurationMs.min - 1, + }).success, + ).toBe(false); + expect( + reactiveSpritePositionSchema.safeParse({ + ...baseSprite(), + pressDurationMs: fixture.pressDurationMs.max + 1, + }).success, + ).toBe(false); + }); + + it('canonical은 새 필드를 요구하고 input은 생략을 허용한다 - 구 플러그인 patch 호환', () => { + const { + activation: _activation, + pressDurationMs: _pressDurationMs, + ...legacySprite + } = baseSprite(); + const legacy = { ...legacySprite, poses: [pose('p', ['t'])] }; + expect(reactiveSpritePositionSchema.safeParse(legacy).success).toBe(false); + expect(reactiveSpritePositionInputSchema.safeParse(legacy).success).toBe( + true, + ); + }); + + it('제거된 필드(imageRect·imageFit·imagePlacement·contactPoint·imagePivot)는 무시된다', () => { + const legacy = { + ...baseSprite(), + imageFit: 'contain', + imageRect: { x: 10, y: 10, width: 100, height: 100 }, + imagePlacement: 'box', + poses: [ + { + ...pose('p', ['t']), + contactPoint: { x: 0.5, y: 1 }, + imagePivot: null, + }, + ], + }; + const parsed = reactiveSpritePositionSchema.safeParse(legacy); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect('imageRect' in parsed.data).toBe(false); + expect('contactPoint' in parsed.data.poses[0]).toBe(false); + }); + + it('input 스키마 컬렉션 상한은 fixture 값에서 통과하고 +1에서 거부된다', () => { + const triggersAtCap = Array.from( + { length: fixture.maxTriggersPerPose }, + (_, i) => `trigger-${i}`, + ); + expect( + reactiveSpritePositionInputSchema.safeParse({ + ...baseSprite(), + poses: [pose('p', triggersAtCap)], + }).success, + ).toBe(true); + expect( + reactiveSpritePositionInputSchema.safeParse({ + ...baseSprite(), + poses: [pose('p', [...triggersAtCap, 'trigger-over'])], + }).success, + ).toBe(false); + + const posesAtCap = Array.from({ length: fixture.maxPoses }, (_, i) => + pose(`p-${i}`, [`t-${i}`]), + ); + expect( + reactiveSpritePositionInputSchema.safeParse({ + ...baseSprite(), + poses: posesAtCap, + }).success, + ).toBe(true); + expect( + reactiveSpritePositionInputSchema.safeParse({ + ...baseSprite(), + poses: [...posesAtCap, pose('p-over', ['t-over'])], + }).success, + ).toBe(false); + }); +}); diff --git a/tests/sprite-resize-scale-parity.test.ts b/tests/sprite-resize-scale-parity.test.ts new file mode 100644 index 000000000..ceb167a6e --- /dev/null +++ b/tests/sprite-resize-scale-parity.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { SPRITE_CONSTRAINTS } from '../src/types/key/sprites'; +import { + scaleSpriteResizeOffset, + spriteResizeRatio, +} from '../src/renderer/utils/sprite/resizeProjection'; + +// Rust 적용기와 같은 fixture를 공유해 resizeSprite 스케일 수학의 f64 결과를 +// 비트 단위로 고정한다. 비교는 expectedHex(IEEE754 big-endian)로만 한다 - +// decimal expected는 사람 읽기용이고, -0·subnormal은 decimal 비교가 놓친다 +const FIXTURE_PATH = join( + __dirname, + 'fixtures', + 'sprite-resize-scale-parity.json', +); + +interface ResizeScaleCase { + name: string; + field: 'offset' | 'ratio'; + prev: number; + next: number; + value?: number; + expected: number; + expectedHex: string; +} + +interface ResizeScaleFixture { + version: number; + ranges: { offset: [number, number] }; + cases: ResizeScaleCase[]; +} + +const fixture = JSON.parse( + readFileSync(FIXTURE_PATH, 'utf8'), +) as ResizeScaleFixture; + +const bitsHex = (value: number): string => { + const buffer = new ArrayBuffer(8); + new DataView(buffer).setFloat64(0, value, false); + return ( + '0x' + + [...new Uint8Array(buffer)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + ); +}; + +describe('sprite resize scale parity (fixture)', () => { + it('fixture 범위는 SPRITE_CONSTRAINTS와 일치한다', () => { + expect(fixture.ranges.offset).toEqual([ + SPRITE_CONSTRAINTS.offset.min, + SPRITE_CONSTRAINTS.offset.max, + ]); + }); + + it('케이스는 비어 있지 않고 필드 종류를 전수 커버한다', () => { + const fields = new Set(fixture.cases.map((entry) => entry.field)); + expect([...fields].sort()).toEqual(['offset', 'ratio']); + }); + + it.each(fixture.cases.map((entry) => [entry.name, entry] as const))( + '%s', + (_name, entry) => { + const ratio = spriteResizeRatio(entry.prev, entry.next); + const actual = + entry.field === 'ratio' + ? ratio + : scaleSpriteResizeOffset(entry.value ?? 0, ratio); + expect(bitsHex(actual)).toBe(entry.expectedHex); + }, + ); +});