Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const RotationInputRow = ({
prefix={<AngleGlyph />}
ariaLabel={label}
suffix="°"
width="80px"
width="68px"
min={ELEMENT_ROTATION_RANGE.min}
max={ELEMENT_ROTATION_RANGE.max}
allowDecimal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,10 @@ describe('GroupResizeHandles 세션', () => {
const outline = host.querySelector<HTMLElement>(
'[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)');
});

Expand Down
65 changes: 16 additions & 49 deletions src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

/**
* 다중 선택 시 그룹 전체를 감싸는 리사이즈 핸들을 표시하는 컴포넌트
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -227,9 +230,6 @@ const GroupResizeHandles = ({
),
}));

const nonResizableElements = resizabilityInfo.filter(
(info) => !info.isResizable,
);
const resizableElements = resizabilityInfo.filter((info) => info.isResizable);

const handleMouseDown = useGroupResizeSession({
Expand Down Expand Up @@ -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 (
<>
Expand All @@ -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 (
<div
key={`non-resizable-${element.id}`}
style={{
position: 'absolute',
left: bounds.x * zoom + panX - 2,
top: bounds.y * zoom + panY - 2,
width: bounds.width * zoom + 4,
height: bounds.height * zoom + 4,
border: '2px dashed rgba(251, 146, 60, 0.9)',
borderRadius: '4px',
pointerEvents: 'none' as const,
zIndex: 'var(--z-canvas-selection-handle)',
}}
title="크기 조절 불가능한 요소"
/>
);
})}

{/* 리사이즈 핸들들 - 리사이즈 가능한 요소가 있을 때만 표시 */}
{resizableElements.length > 0 &&
HANDLES.map((handle) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,26 +230,26 @@ 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();
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);
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 });
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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 () => {
Expand All @@ -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();
Expand Down Expand Up @@ -380,15 +380,15 @@ 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 });
await settle();
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());
});

Expand All @@ -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 });
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
// 연결 상태는 이동값을 유지해 기준점 화면 좌표가 기본 축을 따라간다
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 });
Expand Down
82 changes: 82 additions & 0 deletions src/renderer/components/main/Grid/handles/selectionOutline.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading