diff --git a/src/renderer/components/main/Grid/PropertiesPanel/controls/RotationInputRow.tsx b/src/renderer/components/main/Grid/PropertiesPanel/controls/RotationInputRow.tsx index 16064bf3..d73a2408 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/controls/RotationInputRow.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/controls/RotationInputRow.tsx @@ -28,7 +28,7 @@ const RotationInputRow = ({ prefix={} ariaLabel={label} suffix="°" - width="80px" + width="68px" min={ELEMENT_ROTATION_RANGE.min} max={ELEMENT_ROTATION_RANGE.max} allowDecimal diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx index 6d2fb12a..38d6fbaa 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx @@ -240,10 +240,10 @@ describe('GroupResizeHandles 세션', () => { 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.left).toBe('-11px'); + expect(outline.style.top).toBe('1px'); + expect(outline.style.width).toBe('122px'); + expect(outline.style.height).toBe('98px'); expect(outline.style.transform).toBe('rotate(45deg)'); }); diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx index 32438df6..66e168d3 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx @@ -7,7 +7,6 @@ import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { isElementResizable, - getElementBounds, calculateGroupBounds, type Bounds, type SelectedElement, @@ -17,6 +16,11 @@ import { useGroupResizeSession } from './useGroupResizeSession'; import { rotatePointAround } from '@utils/element/rotation'; import { resizeCursorForHandle } from './rotatedResize'; import type { GroupRotationFrame } from './rotatedGroupResize'; +import { + GROUP_SELECTION_BORDER_WIDTH, + GROUP_SELECTION_BORDER_COLOR, + SELECTION_BORDER_WIDTH, +} from './selectionOutline'; /** * 다중 선택 시 그룹 전체를 감싸는 리사이즈 핸들을 표시하는 컴포넌트 @@ -60,7 +64,6 @@ const CORNER_HANDLE_SIZE = 10; // 꼭짓점 핸들의 시각적 크기 (픽셀) const EDGE_HANDLE_WIDTH = 8; // 상하좌우 핸들의 두께 (픽셀) const EDGE_HANDLE_LENGTH = 18; // 상하좌우 핸들의 길이 (픽셀) const HANDLE_HIT_SIZE = 18; // 핸들의 클릭 가능 영역 크기 (픽셀) -const GROUP_BORDER_WIDTH = 3; // 그룹 테두리 두께 (픽셀) // ================================ const HANDLE_HIT_HALF = HANDLE_HIT_SIZE / 2; @@ -227,9 +230,6 @@ const GroupResizeHandles = ({ ), })); - const nonResizableElements = resizabilityInfo.filter( - (info) => !info.isResizable, - ); const resizableElements = resizabilityInfo.filter((info) => info.isResizable); const handleMouseDown = useGroupResizeSession({ @@ -263,20 +263,19 @@ const GroupResizeHandles = ({ const rotation = rotationFrame?.rotation ?? 0; // 그룹 테두리 좌표 계산 - 내부 요소 테두리와 동일한 위치에 겹치게 - const selectionLeft = displayBounds.x * zoom + panX - 2; - const selectionTop = displayBounds.y * zoom + panY - 2; - const selectionWidth = displayBounds.width * zoom + 4; - const selectionHeight = displayBounds.height * zoom + 4; + const selectionLeft = displayBounds.x * zoom + panX - SELECTION_BORDER_WIDTH; + const selectionTop = displayBounds.y * zoom + panY - SELECTION_BORDER_WIDTH; + const selectionWidth = + displayBounds.width * zoom + SELECTION_BORDER_WIDTH * 2; + const selectionHeight = + displayBounds.height * zoom + SELECTION_BORDER_WIDTH * 2; // 핸들 위치 계산용 - 테두리 중앙에 배치하기 위해 테두리 두께의 절반만큼 오프셋 - const borderHalf = GROUP_BORDER_WIDTH / 2; + const borderHalf = GROUP_SELECTION_BORDER_WIDTH / 2; const handleAreaLeft = selectionLeft + borderHalf; const handleAreaTop = selectionTop + borderHalf; - const handleAreaWidth = selectionWidth - GROUP_BORDER_WIDTH; - const handleAreaHeight = selectionHeight - GROUP_BORDER_WIDTH; - - // 리사이즈 불가능한 요소가 있으면 핸들 비활성화 - const _hasNonResizable = nonResizableElements.length > 0; + const handleAreaWidth = selectionWidth - GROUP_SELECTION_BORDER_WIDTH; + const handleAreaHeight = selectionHeight - GROUP_SELECTION_BORDER_WIDTH; return ( <> @@ -290,46 +289,14 @@ const GroupResizeHandles = ({ width: selectionWidth, height: selectionHeight, ...(rotation !== 0 ? { transform: `rotate(${rotation}deg)` } : {}), - border: `${GROUP_BORDER_WIDTH}px solid var(--ui-selection-border-strong)`, + boxSizing: 'border-box', + border: `${GROUP_SELECTION_BORDER_WIDTH}px solid ${GROUP_SELECTION_BORDER_COLOR}`, borderRadius: '6px', pointerEvents: 'none' as const, zIndex: 'var(--z-canvas-group-outline)', }} /> - {/* 리사이즈 불가능한 요소들에 대한 표시 (주황색 점선만, 아이콘 없음) */} - {nonResizableElements.map(({ element }) => { - const bounds = getElementBounds( - element, - positions, - statPositions, - graphPositions, - knobPositions, - selectedKeyType, - pluginElements, - spritePositions, - ); - if (!bounds) return null; - - return ( -
- ); - })} - {/* 리사이즈 핸들들 - 리사이즈 가능한 요소가 있을 때만 표시 */} {resizableElements.length > 0 && HANDLES.map((handle) => { diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx index 57cd27cf..cded5580 100644 --- a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx @@ -230,14 +230,14 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { 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); + // pivot x = 0.8 → 축 160 + 프레임 보정 0.3 → 히트 좌상단 147.3 + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(147.3, 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); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.4, 3); + expect(parseFloat(pivotHandle().style.top)).toBeCloseTo(77.1, 3); pointer('pointerup', window, 180, 90); await settle(); @@ -245,11 +245,11 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { 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); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.4, 3); runtime.resolveCommit({ revision: 1, changedFields: ['spritePositions'] }); await settle(); - expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.8, 3); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.4, 3); expect( harness.useSpriteStore.getState().positions['4key'][0].pivot, ).toEqual({ x: 0.9, y: 0.6 }); @@ -264,7 +264,7 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { render(); // 1차: 모서리 → 중앙으로 드래그해 스냅 - pointer('pointerdown', pivotHandle(), 201, -1); + pointer('pointerdown', pivotHandle(), 200.5, -0.5); pointer('pointermove', window, 102, 77); await settle(); pointer('pointerup', window, 102, 77); @@ -281,11 +281,11 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { pointer('pointerdown', pivotHandle(), 100, 75); pointer('pointermove', window, 140, 75); await settle(); - expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(127.4, 3); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(127.2, 3); pointer('pointermove', window, 160, 75); await settle(); - expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(147.6, 3); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(147.3, 3); pointer('pointerup', window, 160, 75); await settle(); @@ -317,8 +317,8 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { // 스냅 해제 상태로 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); + // 105 + 프레임 보정 (2·0.75−1)·0.5 = 105.25 + expect(handleCenterX()).toBeCloseTo(105.25, 6); pointer('pointerup', window, 105, 75); await settle(); @@ -328,7 +328,7 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { 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); + expect(handleCenterX()).toBeCloseTo(105.25, 6); }); it('히트 영역 가장자리를 잡아도 첫 move에서 표식이 튀지 않는다', async () => { @@ -343,8 +343,8 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { 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); + // 축 104 + 프레임 보정 (2·0.52−1)·0.5 = 104.02 + expect(handleCenterX()).toBeCloseTo(104.02, 6); pointer('pointerup', window, 112, 75); await settle(); expect(runtime.commit).toHaveBeenCalledOnce(); @@ -380,7 +380,7 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { ), ); await settle(); - expect(handleCenterX()).toBeCloseTo(150.5, 6); + expect(handleCenterX()).toBeCloseTo(150.25, 6); pointer('pointerdown', pivotHandle(), 150, 75); pointer('pointermove', window, 120, 75, { ctrlKey: true }); @@ -388,7 +388,7 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { pointer('pointerup', window, 120, 75); await settle(); expect(runtime.commit).not.toHaveBeenCalled(); - expect(handleCenterX()).toBeCloseTo(150.5, 6); + expect(handleCenterX()).toBeCloseTo(150.25, 6); act(() => editGestureController.cancel()); }); @@ -414,7 +414,7 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { ); }); await settle(); - expect(handleCenterX()).toBeCloseTo(150.5, 6); + expect(handleCenterX()).toBeCloseTo(150.25, 6); pointer('pointerdown', pivotHandle(), 150, 75); pointer('pointermove', window, 120, 75, { ctrlKey: true }); @@ -427,7 +427,7 @@ describe('SpriteCanvasHandles 기준점 드래그 통합', () => { expect( harness.useSpriteStore.getState().positions['4key'][0].pivot.x, ).toBeCloseTo(0.6, 9); - expect(handleCenterX()).toBeCloseTo(120.2, 6); + expect(handleCenterX()).toBeCloseTo(120.1, 6); }); it('다른 프리뷰가 상자 크기만 바꿔 보여 줘도 드래그를 시작하지 않는다', async () => { 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 index 17ec1590..59888f23 100644 --- a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx @@ -471,8 +471,8 @@ describe('자세 편집 세션 통합 (패널 + 캔버스 핸들)', () => { 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); + // 기본 선택 표식은 0.5px 바깥의 선택 테두리 중심에 놓여 x가 0.2px 바깥이다 + expect(baseAxisAfter.x).toBeCloseTo(140.2, 6); expect(baseAxisAfter.y).toBeCloseTo(75, 6); const linkedTransform = canonicalSprite().poses[0].transform; // 연결 상태는 이동값을 유지해 기준점 화면 좌표가 기본 축을 따라간다 diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx index bd51eebc..38b1eb2a 100644 --- a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx @@ -253,9 +253,9 @@ describe('SpriteCanvasHandles', () => { 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'); + // 로컬 (200, 0) → 화면 (210, 20), 테두리 선 중심은 모서리 밖 0.5px → (210.5, 19.5), 히트 26 중심 + expect(pivotHandle()!.style.left).toBe('197.5px'); + expect(pivotHandle()!.style.top).toBe('6.5px'); act(() => useSpritePoseHandleStore @@ -276,7 +276,7 @@ describe('SpriteCanvasHandles', () => { pointer('pointermove', window, { clientX: 14, clientY: 117 }); await flushFrame(); - expect(parseFloat(handle.style.left) + 13).toBeCloseTo(9, 6); + expect(parseFloat(handle.style.left) + 13).toBeCloseTo(9.5, 6); expect(mocks.patchPosition).not.toHaveBeenCalled(); pointer('pointerup', window, { clientX: 14, clientY: 117 }); diff --git a/src/renderer/components/main/Grid/handles/selectionOutline.test.ts b/src/renderer/components/main/Grid/handles/selectionOutline.test.ts new file mode 100644 index 00000000..067f8fd8 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/selectionOutline.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { rotatePointAround } from '@utils/element/rotation'; +import { getSelectionOutlineEdges } from './selectionOutline'; + +const group = { x: 10, y: 20, width: 180, height: 100 }; +const bounds = { x: 10, y: 20, width: 30, height: 40 }; + +describe('getSelectionOutlineEdges', () => { + it('바깥 박스와 겹치는 변만 생략하고 내부 요소는 네 변을 유지한다', () => { + const frame = { bounds: group, rotation: 0 }; + expect(getSelectionOutlineEdges(bounds, 0, frame)).toEqual({ + top: false, + right: true, + bottom: true, + left: false, + }); + expect( + getSelectionOutlineEdges({ ...bounds, x: 50, y: 50 }, 0, frame), + ).toEqual({ + top: true, + right: true, + bottom: true, + left: true, + }); + }); + + it.each([30, 90, -135, 180])( + '그룹과 함께 %s° 회전한 뒤에도 같은 변을 생략한다', + (rotation) => { + const center = { + x: group.x + group.width / 2, + y: group.y + group.height / 2, + }; + const elementCenter = rotatePointAround( + { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }, + center, + rotation, + ); + const rotatedBounds = { + ...bounds, + x: elementCenter.x - bounds.width / 2, + y: elementCenter.y - bounds.height / 2, + }; + expect( + getSelectionOutlineEdges(rotatedBounds, rotation, { + bounds: group, + rotation, + }), + ).toEqual({ + top: false, + right: true, + bottom: true, + left: false, + }); + }, + ); + + it('꼭짓점만 바깥 박스에 닿는 회전 요소는 변을 숨기지 않는다', () => { + const size = Math.SQRT2 * 20; + expect( + getSelectionOutlineEdges({ x: -10, y: -10, width: 20, height: 20 }, 45, { + bounds: { x: -size / 2, y: -size / 2, width: size, height: size }, + rotation: 0, + }), + ).toEqual({ top: true, right: true, bottom: true, left: true }); + }); + + it('바깥 박스가 없거나 변이 박스 밖으로 이어지면 윤곽을 유지한다', () => { + expect(getSelectionOutlineEdges(bounds, 0, null)).toEqual({ + top: true, + right: true, + bottom: true, + left: true, + }); + expect( + getSelectionOutlineEdges({ ...bounds, y: 0, height: 150 }, 0, { + bounds: group, + rotation: 0, + }), + ).toEqual({ top: true, right: true, bottom: true, left: true }); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/selectionOutline.ts b/src/renderer/components/main/Grid/handles/selectionOutline.ts index ddb2a4b1..b4ee4934 100644 --- a/src/renderer/components/main/Grid/handles/selectionOutline.ts +++ b/src/renderer/components/main/Grid/handles/selectionOutline.ts @@ -1,4 +1,60 @@ -// 선택 테두리 규격 - 상자 바깥쪽으로 2px를 두르고 선 중심은 가장자리에서 1px 바깥이다. +import { + rotatedRectCorners, + rotatePointAround, + type Point, +} from '@utils/element/rotation'; +import type { Bounds } from './groupResizeUtils'; +import type { GroupRotationFrame } from './rotatedGroupResize'; + +// 선택 테두리 규격 - 상자 바깥쪽에 선을 두르고 선 중심은 두께의 절반만큼 바깥에 배치 // 테두리·리사이즈 핸들·스프라이트 기준점 표식이 같은 프레임에 놓이도록 한 곳에서 정한다 -export const SELECTION_BORDER_WIDTH = 2; +export const SELECTION_BORDER_WIDTH = 1; export const SELECTION_BORDER_CENTER = SELECTION_BORDER_WIDTH / 2; +export const GROUP_SELECTION_BORDER_WIDTH = SELECTION_BORDER_WIDTH; +export const GROUP_SELECTION_BORDER_COLOR = 'var(--ui-selection-border-strong)'; + +// 개별 변의 양 끝을 그룹 로컬 좌표로 옮겨 실제로 겹치는 변만 생략 +export const getSelectionOutlineEdges = ( + bounds: Bounds, + rotation: number, + groupFrame: GroupRotationFrame | null, +) => { + const edges = { top: true, right: true, bottom: true, left: true }; + if (!groupFrame) return edges; + + const group = groupFrame.bounds; + const center = { + x: group.x + group.width / 2, + y: group.y + group.height / 2, + }; + const corners = rotatedRectCorners( + bounds.x, + bounds.y, + bounds.width, + bounds.height, + rotation, + ).map((point) => rotatePointAround(point, center, -groupFrame.rotation)); + const epsilon = 1e-5; + const onGroupEdge = (a: Point, b: Point) => { + const horizontal = [group.y, group.y + group.height].some( + (y) => + Math.abs(a.y - y) <= epsilon && + Math.abs(b.y - y) <= epsilon && + Math.min(a.x, b.x) >= group.x - epsilon && + Math.max(a.x, b.x) <= group.x + group.width + epsilon, + ); + const vertical = [group.x, group.x + group.width].some( + (x) => + Math.abs(a.x - x) <= epsilon && + Math.abs(b.x - x) <= epsilon && + Math.min(a.y, b.y) >= group.y - epsilon && + Math.max(a.y, b.y) <= group.y + group.height + epsilon, + ); + return horizontal || vertical; + }; + edges.top = !onGroupEdge(corners[0], corners[1]); + edges.right = !onGroupEdge(corners[1], corners[2]); + edges.bottom = !onGroupEdge(corners[2], corners[3]); + edges.left = !onGroupEdge(corners[3], corners[0]); + return edges; +}; diff --git a/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.test.tsx b/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.test.tsx index 21a8f3d5..c205054d 100644 --- a/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.test.tsx +++ b/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.test.tsx @@ -9,6 +9,8 @@ import type { import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; import GridSelectionOverlays from './GridSelectionOverlays'; +import type { Bounds, ElementBounds } from '../handles/groupResizeUtils'; +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; @@ -92,10 +94,16 @@ describe('GridSelectionOverlays', () => { pluginElements = [] as PluginDisplayElementInternal[], hasGradientEditSession = false, hasSpritePoseSession = false, - previewElementBounds = null as readonly unknown[] | null, + previewElementBounds = null as readonly ElementBounds[] | null, + previewGroupBounds = null as Bounds | null, + statPositions = {} as CanonicalEditorDocumentV1['statPositions'], + graphPositions = {} as CanonicalEditorDocumentV1['graphPositions'], + knobPositions = {} as CanonicalEditorDocumentV1['knobPositions'], + zoom = 2, firstKeyRotation = 0, + keyPositions = null as CanonicalEditorDocumentV1['keyPositions'] | null, } = {}) => { - const positions = { + const positions = keyPositions ?? { '4key': [ keyPosition(FIRST_ID, 10, 20, firstKeyRotation), keyPosition(SECOND_ID, 50, 60), @@ -108,23 +116,26 @@ describe('GridSelectionOverlays', () => { }); useGridSelectionStore.setState({ selectedElements }); useSpriteStore.setState({ positions: spritePositions }); + useStatItemStore.setState({ positions: statPositions }); + useGraphItemStore.setState({ positions: graphPositions }); + useKnobItemStore.setState({ positions: knobPositions }); root.render( { }; beforeEach(() => { + usePluginDisplayElementStore.setState({ definitions: new Map() }); useStatItemStore.setState({ positions: {} }); useGraphItemStore.setState({ positions: {} }); useKnobItemStore.setState({ positions: {} }); @@ -162,10 +174,10 @@ describe('GridSelectionOverlays', () => { const outline = host.querySelector( '[data-grid-selection-outline]', ) as HTMLElement; - expect(outline.style.left).toBe('31px'); - expect(outline.style.top).toBe('52px'); - expect(outline.style.width).toBe('74px'); - expect(outline.style.height).toBe('94px'); + expect(outline.style.left).toBe('32px'); + expect(outline.style.top).toBe('53px'); + expect(outline.style.width).toBe('72px'); + expect(outline.style.height).toBe('92px'); expect(host.querySelector('[data-resize-handles]')).not.toBeNull(); expect(host.querySelector('[data-group-resize-handles]')).toBeNull(); expect(host.querySelector('[data-gradient-axis]')).not.toBeNull(); @@ -194,23 +206,42 @@ describe('GridSelectionOverlays', () => { expect(host.querySelector('[data-grid-selection-outline]')).not.toBeNull(); }); - it('다중 선택은 그룹 핸들을 사용하고 그룹 프리뷰 중 개별 윤곽을 숨긴다', () => { + it('그룹 리사이즈 중에도 각 요소의 프리뷰 윤곽을 표시한다', () => { renderOverlays({ selectedElements: [ { type: 'key', id: FIRST_ID, index: 0 }, { type: 'key', id: SECOND_ID, index: 1 }, ], - previewElementBounds: [{ id: FIRST_ID }], + previewGroupBounds: { x: 20, y: 30, width: 140, height: 160 }, + previewElementBounds: [ + { + element: { type: 'key', id: FIRST_ID }, + bounds: { x: 20, y: 30, width: 60, height: 80 }, + }, + { + element: { type: 'key', id: SECOND_ID }, + bounds: { x: 100, y: 110, width: 60, height: 80 }, + }, + ], }); expect(host.querySelectorAll('[data-grid-selection-outline]')).toHaveLength( - 0, + 2, ); expect( host .querySelector('[data-group-resize-handles]') ?.getAttribute('data-group-resize-handles'), ).toBe('2'); + const first = host.querySelector( + '[data-grid-selection-outline]', + )!; + expect(first.style.left).toBe('42px'); + expect(first.style.top).toBe('63px'); + expect(first.style.width).toBe('122px'); + expect(first.style.borderTopColor).toBe('transparent'); + expect(first.style.borderLeftColor).toBe('transparent'); + expect(first.style.borderRightColor).not.toBe('transparent'); expect(host.querySelector('[data-resize-handles]')).toBeNull(); expect( host.querySelector('[data-rotation-handles="selection"]'), @@ -218,7 +249,7 @@ describe('GridSelectionOverlays', () => { }); it.each([0, 30])( - '기존 각도 %s°의 다중 선택은 공통 틀만 표시한다', + '기존 각도 %s°의 다중 선택은 공통 틀과 개별 윤곽을 함께 표시한다', (rotation) => { renderOverlays({ selectedElements: [ @@ -229,7 +260,7 @@ describe('GridSelectionOverlays', () => { }); expect( host.querySelectorAll('[data-grid-selection-outline]'), - ).toHaveLength(0); + ).toHaveLength(2); expect(host.querySelectorAll('[data-group-resize-handles]')).toHaveLength( 1, ); @@ -268,7 +299,9 @@ describe('GridSelectionOverlays', () => { pluginElements: [PLUGIN_ELEMENT], }); expect(host.querySelector('[data-group-resize-handles]')).not.toBeNull(); - expect(host.querySelector('[data-grid-selection-outline]')).toBeNull(); + expect(host.querySelectorAll('[data-grid-selection-outline]')).toHaveLength( + 2, + ); }); it.each([0, 30])( @@ -293,7 +326,7 @@ describe('GridSelectionOverlays', () => { const outlines = host.querySelectorAll( '[data-grid-selection-outline]', ); - expect(outlines).toHaveLength(rotation === 0 ? 0 : 2); + expect(outlines).toHaveLength(2); if (rotation === 0) { expect( host.querySelector('[data-group-resize-handles]'), @@ -308,6 +341,137 @@ describe('GridSelectionOverlays', () => { }, ); + it('3×3 배치에서 중앙을 제외하면 나머지 여덟 항목에만 윤곽을 표시한다', () => { + const keys = Array.from({ length: 9 }, (_, index) => + keyPosition(`key-${index}`, (index % 3) * 50, Math.floor(index / 3) * 60), + ); + renderOverlays({ + keyPositions: { '4key': keys }, + selectedElements: keys + .filter((_, index) => index !== 4) + .map(({ id }) => ({ type: 'key', id })), + }); + expect(host.querySelectorAll('[data-grid-selection-outline]')).toHaveLength( + 8, + ); + expect( + host.querySelector('[data-grid-selection-element-id="key-4"]'), + ).toBeNull(); + // 중앙 빈 선택을 마주 보는 변은 모두 남아 있어야 한다 + for (const [id, side] of [ + ['key-1', 'bottom'], + ['key-3', 'right'], + ['key-5', 'left'], + ['key-7', 'top'], + ]) { + const outline = host.querySelector( + `[data-grid-selection-element-id="${id}"]`, + )!; + expect(outline.style.getPropertyValue(`border-${side}-color`)).toBe( + 'var(--ui-selection-border-strong)', + ); + } + }); + + it('모든 요소 종류에서 선택한 항목만 표시하고 바깥 박스와 겹친 변을 생략한다', () => { + const selectedElements: SelectedElement[] = [ + { type: 'key', id: FIRST_ID }, + { type: 'stat', id: 'stat-1' }, + { type: 'graph', id: 'graph-1' }, + { type: 'knob', id: 'knob-1' }, + { type: 'sprite', id: 'sprite-1' }, + { type: 'plugin', id: PLUGIN_ELEMENT.fullId }, + ]; + renderOverlays({ + selectedElements, + statPositions: { + '4key': [{ ...keyPosition('stat-1', 60, 20), statType: 'kps' }], + }, + graphPositions: { + '4key': [ + { + ...keyPosition('graph-1', 110, 20), + statType: 'kps', + graphType: 'line', + graphSpeed: 1, + graphColor: '#fff', + }, + ], + }, + knobPositions: { + '4key': [ + { + ...keyPosition('knob-1', 160, 20), + axisId: 'x', + sensitivity: 1, + reverse: false, + }, + ], + }, + spritePositions: { + '4key': [ + makeCanonicalSpritePosition({ + id: 'sprite-1', + dx: 210, + dy: 20, + width: 30, + height: 40, + }), + ], + }, + pluginElements: [PLUGIN_ELEMENT], + }); + const outlines = Array.from( + host.querySelectorAll('[data-grid-selection-outline]'), + ); + expect( + outlines.map((outline) => outline.dataset.gridSelectionElementType), + ).toEqual(['key', 'stat', 'graph', 'knob', 'sprite', 'plugin']); + expect( + host.querySelector(`[data-grid-selection-element-id="${SECOND_ID}"]`), + ).toBeNull(); + expect(outlines[0].style.borderTopColor).toBe('transparent'); + expect(outlines[0].style.borderBottomColor).toBe('transparent'); + expect(outlines[0].style.borderLeftColor).toBe('transparent'); + expect(outlines[0].style.borderRightColor).not.toBe('transparent'); + expect(outlines[4].style.borderRightColor).toBe('transparent'); + expect(outlines[4].style.borderLeftColor).not.toBe('transparent'); + // 내부 플러그인은 네 변을 유지하고 크기 조절 불가 표시도 보존 + expect(outlines[5].style.borderStyle).toBe('dashed'); + for (const side of ['Top', 'Right', 'Bottom', 'Left']) { + expect( + outlines[5].style.getPropertyValue( + `border-${side.toLowerCase()}-color`, + ), + ).not.toBe('transparent'); + } + expect(host.querySelector('[data-resize-handles]')).toBeNull(); + }); + + it.each([0.25, 1, 4])( + '배율 %s에서도 개별 윤곽 두께는 바깥 박스와 같은 화면 기준 1px이다', + (zoom) => { + renderOverlays({ + selectedElements: [ + { type: 'key', id: FIRST_ID }, + { type: 'key', id: SECOND_ID }, + ], + zoom, + }); + const outlines = host.querySelectorAll( + '[data-grid-selection-outline]', + ); + expect(outlines).toHaveLength(2); + expect(outlines[0].style.borderWidth).toBe('1px'); + expect(outlines[0].style.borderRightColor).toBe( + 'var(--ui-selection-border-strong)', + ); + expect(outlines[0].style.width).toBe(`${30 * zoom + 2}px`); + expect(outlines[0].style.borderTopColor).toBe('transparent'); + expect(outlines[1].style.borderBottomColor).toBe('transparent'); + }, + ); + it('그라데이션 편집은 회전 혼합 선택의 개별 윤곽도 숨긴다', () => { renderOverlays({ selectedElements: [ diff --git a/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.tsx b/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.tsx index bb3b31d4..4e3dbe03 100644 --- a/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.tsx +++ b/src/renderer/components/main/Grid/overlays/GridSelectionOverlays.tsx @@ -8,13 +8,20 @@ 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 { + getSelectionOutlineEdges, + GROUP_SELECTION_BORDER_WIDTH, + GROUP_SELECTION_BORDER_COLOR, + SELECTION_BORDER_WIDTH, +} from '../handles/selectionOutline'; +import { + calculateGroupBounds, getElementBounds, getElementRotation, isAspectLockedElement, isElementResizable, type Bounds, + type ElementBounds, } from '../handles/groupResizeUtils'; import { matchSpriteAnchorPreset } from '@utils/sprite/spriteGeometry'; import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; @@ -41,7 +48,7 @@ interface GridSelectionOverlaysProps { hasSpritePoseSession: boolean; previewBounds: Bounds | null; previewGroupBounds: Bounds | null; - previewElementBounds: readonly unknown[] | null; + previewElementBounds: readonly ElementBounds[] | null; onResizeStart: NonNullable; onResize: NonNullable; onResizeEnd: NonNullable; @@ -91,11 +98,48 @@ const GridSelectionOverlays = ({ spritePositions, ) !== 0, ); + const isMultiSelection = selectedElements.length > 1; + const rotationFrame = + selectionFrame && + (selectionFrame.rotation !== 0 || + selectionFrame.snapshot.hasRotatedContent || + selectionFrame.snapshot.entries.some( + (entry) => + entry.type === 'sprite' && + (entry.idleTransform.x !== 0 || + entry.idleTransform.y !== 0 || + entry.idleTransform.scale !== 1), + )) + ? { bounds: selectionFrame.bounds, rotation: selectionFrame.rotation } + : undefined; + const groupData = + isMultiSelection && !rotatedWithoutFrame + ? calculateGroupBounds( + selectedElements, + positions, + statPositions, + graphPositions, + knobPositions, + mode, + pluginElements, + spritePositions, + ) + : null; + // 그룹 핸들이 실제로 그리는 프레임과 같은 좌표·회전·프리뷰 사용 + const groupOutlineFrame = groupData + ? { + bounds: previewGroupBounds ?? rotationFrame?.bounds ?? groupData, + rotation: rotationFrame?.rotation ?? 0, + } + : null; + const previewByElement = new Map( + previewElementBounds?.map(({ element, bounds }) => [ + `${element.type}:${element.id}`, + bounds, + ]), + ); const selectionOutlines: ReactNode[] = []; - if ( - !hasGradientEditSession && - (selectedElements.length === 1 || rotatedWithoutFrame) - ) { + if (!hasGradientEditSession) { selectedElements.forEach((element) => { const bounds = getElementBounds( element, @@ -108,16 +152,12 @@ const GridSelectionOverlays = ({ 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 displayBounds = isMultiSelection + ? previewByElement.get(`${element.type}:${element.id}`) ?? bounds + : previewBounds ?? bounds; + const borderWidth = isMultiSelection + ? GROUP_SELECTION_BORDER_WIDTH + : SELECTION_BORDER_WIDTH; const rotation = getElementRotation( element, positions, @@ -127,19 +167,48 @@ const GridSelectionOverlays = ({ mode, spritePositions, ); + const edges = getSelectionOutlineEdges( + displayBounds, + rotation, + groupOutlineFrame, + ); + const nonResizable = + isMultiSelection && + !isElementResizable( + element, + positions, + statPositions, + graphPositions, + knobPositions, + mode, + pluginElements, + ); + const color = nonResizable + ? 'rgba(251, 146, 60, 0.9)' + : isMultiSelection + ? GROUP_SELECTION_BORDER_COLOR + : 'var(--ui-selection-border)'; selectionOutlines.push(
- entry.type === 'sprite' && - (entry.idleTransform.x !== 0 || - entry.idleTransform.y !== 0 || - entry.idleTransform.scale !== 1), - )) - ? { - bounds: selectionFrame.bounds, - rotation: selectionFrame.rotation, - } - : undefined - } + rotationFrame={rotationFrame} onGroupResizeStart={onResizeStart} onGroupResize={onGroupResize} onGroupResizeEnd={onGroupResizeEnd}