onMovedCheck(movedDuringPressRef.current)}
+ />
+ );
+};
+
+// index가 없는 플러그인 2개 선택 시나리오 — 그룹 bounds 오인 회귀 검증용
+const PluginPairHarness = () => {
+ const { handlePointerDown } = useSelectionDrag({
+ enabled: true,
+ zoom: 1,
+ startX: 0,
+ startY: 0,
+ elementId: 'plugin-a',
+ elementWidth: 100,
+ elementHeight: 100,
+ elementType: 'plugin',
+ selectedElements: [
+ { id: 'plugin-a', type: 'plugin' },
+ { id: 'plugin-b', type: 'plugin' },
+ ],
+ getOtherElements: () => [
+ {
+ id: 'plugin-b',
+ left: 200,
+ top: 0,
+ right: 300,
+ bottom: 100,
+ centerX: 250,
+ centerY: 50,
+ width: 100,
+ height: 100,
+ },
+ ],
+ });
+
+ return
;
+};
+
+interface MovingPluginPairHarnessProps {
+ getOtherElements: () => ElementBounds[];
+ onMultiDrag: (dx: number, dy: number) => void;
+}
+
+const MovingPluginPairHarness = ({
+ getOtherElements,
+ onMultiDrag,
+}: MovingPluginPairHarnessProps) => {
+ const { handlePointerDown } = useSelectionDrag({
+ enabled: true,
+ zoom: 1,
+ startX: 0,
+ startY: 0,
+ elementId: 'plugin-a',
+ elementWidth: 100,
+ elementHeight: 100,
+ elementType: 'plugin',
+ selectedElements: [
+ { id: 'plugin-a', type: 'plugin' },
+ { id: 'plugin-b', type: 'plugin' },
+ ],
+ getOtherElements,
+ onMultiDrag,
+ });
+
+ return (
+
+ );
+};
+
+describe('useSelectionDrag', () => {
+ let host: HTMLDivElement;
+ let root: Root;
+ let rafCallbacks: Map
;
+ let nextRafId: number;
+ let onClick: Mock<() => void>;
+ let onMovedCheck: Mock<(moved: boolean) => void>;
+ let onMultiDragStart: Mock<() => void>;
+ let onMultiDrag: Mock<(dx: number, dy: number) => void>;
+ let onMultiDragEnd: Mock<() => void>;
+
+ const renderHarness = async () => {
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ return host.querySelector('[data-testid="selection-drag"]')!;
+ };
+
+ const pointerEvent = (type: string, init: PointerEventInit = {}) =>
+ new PointerEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ pointerId: 1,
+ pointerType: 'mouse',
+ isPrimary: true,
+ ...init,
+ });
+
+ const flushRaf = () => {
+ const callbacks = [...rafCallbacks.values()];
+ rafCallbacks.clear();
+ callbacks.forEach((callback) => callback(performance.now()));
+ };
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+ host = document.createElement('div');
+ document.body.appendChild(host);
+ root = createRoot(host);
+ rafCallbacks = new Map();
+ nextRafId = 1;
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+ const id = nextRafId++;
+ rafCallbacks.set(id, callback);
+ return id;
+ });
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => {
+ rafCallbacks.delete(id);
+ });
+ onClick = vi.fn();
+ onMovedCheck = vi.fn();
+ onMultiDragStart = vi.fn();
+ onMultiDrag = vi.fn();
+ onMultiDragEnd = vi.fn();
+ clearGuides.mockClear();
+ setDraggingOrResizing.mockClear();
+ releaseDragSession();
+ });
+
+ afterEach(async () => {
+ await act(async () => root.unmount());
+ host.remove();
+ document.body.innerHTML = '';
+ vi.restoreAllMocks();
+ });
+
+ it('moves on the first valid snapped delta without a 5px threshold', async () => {
+ const element = await renderHarness();
+
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointerdown'));
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 3 }));
+ flushRaf();
+ element.dispatchEvent(pointerEvent('pointerup', { clientX: 3 }));
+ });
+ element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
+
+ expect(onMultiDragStart).toHaveBeenCalledTimes(1);
+ expect(onMultiDrag).toHaveBeenCalledTimes(1);
+ expect(onMultiDrag).toHaveBeenCalledWith(5, 0);
+ expect(onMultiDragEnd).toHaveBeenCalledTimes(1);
+ expect(onMovedCheck).toHaveBeenCalledWith(true);
+ });
+
+ it('keeps pointerdown uncanceled so compatibility click remains available', async () => {
+ const element = await renderHarness();
+ const down = pointerEvent('pointerdown');
+
+ await act(async () => {
+ element.dispatchEvent(down);
+ element.dispatchEvent(pointerEvent('pointerup'));
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ });
+
+ expect(down.defaultPrevented).toBe(false);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('ignores an additional press during the owned pointer session', async () => {
+ const element = await renderHarness();
+ const setPointerCapture = vi.spyOn(element, 'setPointerCapture');
+
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }));
+ element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 2 }));
+ element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }));
+ });
+
+ expect(setPointerCapture).toHaveBeenCalledTimes(1);
+ expect(onMultiDragStart).toHaveBeenCalledTimes(1);
+ expect(onMultiDragEnd).toHaveBeenCalledTimes(1);
+ });
+
+ it('completes once across duplicate terminal signals', async () => {
+ const element = await renderHarness();
+
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointerdown'));
+ element.dispatchEvent(pointerEvent('pointerup'));
+ element.dispatchEvent(pointerEvent('lostpointercapture'));
+ window.dispatchEvent(new Event('blur'));
+ });
+
+ expect(onMultiDragEnd).toHaveBeenCalledTimes(1);
+ expect(
+ setDraggingOrResizing.mock.calls.filter(([value]) => value === false),
+ ).toHaveLength(1);
+ });
+
+ it('keeps the double-click guard across the second stationary press', async () => {
+ const element = await renderHarness();
+
+ // 첫 press 드래그 → 두 번째 정지 press → dblclick: 가드 유지돼야 함
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointerdown'));
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 7 }));
+ flushRaf();
+ element.dispatchEvent(pointerEvent('pointerup', { clientX: 7 }));
+ element.dispatchEvent(pointerEvent('pointerdown', { clientX: 7 }));
+ element.dispatchEvent(pointerEvent('pointerup', { clientX: 7 }));
+ });
+ element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
+
+ expect(onMovedCheck).toHaveBeenLastCalledWith(true);
+ });
+
+ it('keeps other plugins distinct in group bounds without index collision', async () => {
+ await act(async () => {
+ root.render();
+ });
+ const element = host.querySelector(
+ '[data-testid="plugin-drag"]',
+ )!;
+
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointerdown'));
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 7 }));
+ flushRaf();
+ element.dispatchEvent(pointerEvent('pointerup', { clientX: 7 }));
+ });
+
+ const groupCall = vi
+ .mocked(calculateGroupBounds)
+ .mock.calls.at(-1)?.[0] as Array<{ id: string }>;
+ expect(groupCall).toHaveLength(2);
+ expect(groupCall.map((bounds) => bounds.id).sort()).toEqual([
+ 'plugin-a',
+ 'plugin-b',
+ ]);
+ });
+
+ it('keeps selected group bounds based on drag-start positions across frames', async () => {
+ let pluginBLeft = 200;
+ const getOtherElements = vi.fn(() => [
+ {
+ id: 'plugin-b',
+ left: pluginBLeft,
+ top: 0,
+ right: pluginBLeft + 100,
+ bottom: 100,
+ centerX: pluginBLeft + 50,
+ centerY: 50,
+ width: 100,
+ height: 100,
+ },
+ ]);
+ const updateStorePosition = vi.fn((dx: number) => {
+ pluginBLeft += dx;
+ });
+
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ const element = host.querySelector(
+ '[data-testid="moving-plugin-drag"]',
+ )!;
+
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointerdown'));
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 5 }));
+ flushRaf();
+ });
+ const firstFrameBounds = vi
+ .mocked(calculateGroupBounds)
+ .mock.calls.at(-1)?.[0];
+ expect(
+ firstFrameBounds?.find((bounds) => bounds.id === 'plugin-b')?.left,
+ ).toBe(205);
+
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointermove', { clientX: 10 }));
+ flushRaf();
+ element.dispatchEvent(pointerEvent('pointerup', { clientX: 10 }));
+ });
+ const secondFrameBounds = vi
+ .mocked(calculateGroupBounds)
+ .mock.calls.at(-1)?.[0];
+ expect(
+ secondFrameBounds?.find((bounds) => bounds.id === 'plugin-b')?.left,
+ ).toBe(210);
+ expect(updateStorePosition).toHaveBeenNthCalledWith(1, 5, 0);
+ expect(updateStorePosition).toHaveBeenNthCalledWith(2, 5, 0);
+ });
+
+ it('ignores non-primary pointers', async () => {
+ const element = await renderHarness();
+
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointerdown', { isPrimary: false }));
+ });
+
+ expect(onMultiDragStart).not.toHaveBeenCalled();
+ });
+
+ it('rejects a concurrent session from another hook instance', async () => {
+ const element = await renderHarness();
+
+ // 두 번째 인스턴스 — 별도 루트에 렌더해 교차 인스턴스 소유권 검증
+ const host2 = document.createElement('div');
+ document.body.appendChild(host2);
+ const root2 = createRoot(host2);
+ const onMultiDragStart2 = vi.fn();
+ await act(async () => {
+ root2.render(
+ ,
+ );
+ });
+ const element2 = host2.querySelector(
+ '[data-testid="selection-drag"]',
+ )!;
+
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }));
+ element2.dispatchEvent(pointerEvent('pointerdown', { pointerId: 2 }));
+ });
+ expect(onMultiDragStart).toHaveBeenCalledTimes(1);
+ expect(onMultiDragStart2).not.toHaveBeenCalled();
+
+ // 첫 세션이 끝나면 소유권이 풀려 다른 인스턴스가 시작 가능
+ await act(async () => {
+ element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }));
+ element2.dispatchEvent(pointerEvent('pointerdown', { pointerId: 3 }));
+ });
+ expect(onMultiDragStart2).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ element2.dispatchEvent(pointerEvent('pointerup', { pointerId: 3 }));
+ root2.unmount();
+ });
+ host2.remove();
+ });
+});
diff --git a/src/renderer/hooks/Grid/useSelectionDrag.ts b/src/renderer/hooks/Grid/useSelectionDrag.ts
new file mode 100644
index 00000000..0abd9030
--- /dev/null
+++ b/src/renderer/hooks/Grid/useSelectionDrag.ts
@@ -0,0 +1,306 @@
+import { useEffect, useRef, type RefObject } from 'react';
+import type React from 'react';
+import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore';
+import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore';
+import { useSettingsStore } from '@stores/useSettingsStore';
+import {
+ calculateBounds,
+ calculateGroupBounds,
+ calculateSnapPoints,
+ type ElementBounds,
+} from '@utils/grid/smartGuides';
+import { tryAcquireDragSession, releaseDragSession } from './dragSession';
+
+interface SelectedElementLike {
+ id: string;
+ type?: string;
+ index?: number;
+}
+
+interface UseSelectionDragOptions {
+ enabled: boolean;
+ zoom: number;
+ startX: number;
+ startY: number;
+ elementId: string;
+ elementWidth: number;
+ elementHeight: number;
+ elementType: string;
+ elementIndex?: number;
+ selectedElements: SelectedElementLike[];
+ getOtherElements: (excludeId: string) => ElementBounds[];
+ getSelectedElementIds?: (element: SelectedElementLike) => string[];
+ onMultiDragStart?: () => void;
+ onMultiDrag?: (dx: number, dy: number) => void;
+ onMultiDragEnd?: () => void;
+}
+
+interface UseSelectionDragReturn {
+ handlePointerDown: (event: React.PointerEvent) => void;
+ movedDuringPressRef: RefObject;
+}
+
+// movedDuringPressRef는 "이번 또는 직전 press에서 실이동 발생"을 뜻한다 —
+// dblclick은 두 press의 합성이므로 직전 press까지 봐야
+// 드래그(복귀 포함) 직후의 빠른 재클릭이 편집 진입으로 새지 않는다
+export const useSelectionDrag = ({
+ enabled,
+ zoom,
+ startX,
+ startY,
+ elementId,
+ elementWidth,
+ elementHeight,
+ elementType,
+ elementIndex,
+ selectedElements,
+ getOtherElements,
+ getSelectedElementIds = (element) => [element.id],
+ onMultiDragStart,
+ onMultiDrag,
+ onMultiDragEnd,
+}: UseSelectionDragOptions): UseSelectionDragReturn => {
+ const movedDuringPressRef = useRef(false);
+ const lastPressMovedRef = useRef(false);
+ const activePointerIdRef = useRef(null);
+ const activeCleanupRef = useRef<(() => void) | null>(null);
+
+ const beginPointerDrag = (
+ event: React.PointerEvent | PointerEvent,
+ dragTarget: HTMLElement,
+ ) => {
+ if (!enabled || event.button !== 0 || activePointerIdRef.current !== null) {
+ return;
+ }
+ // primary 포인터만 + 전역 소유권 — 다른 요소 인스턴스와의 동시 세션 차단
+ if (!event.isPrimary) return;
+ if (!tryAcquireDragSession()) return;
+
+ event.stopPropagation();
+
+ const pointerId = event.pointerId;
+ const startClientX = event.clientX;
+ const startClientY = event.clientY;
+ const previousUserSelect = dragTarget.style.userSelect;
+ const selectedIds = new Set(
+ selectedElements.flatMap((element) => getSelectedElementIds(element)),
+ );
+ // 선택 요소는 시작 좌표를 고정 — 프레임별 최신 store 좌표에 누적 delta를
+ // 다시 더하면 다중 드래그 그룹 bounds가 매 프레임 벌어짐
+ const selectedStartBounds = new Map();
+ if (selectedElements.length > 1) {
+ getOtherElements(elementId).forEach((bounds) => {
+ if (selectedIds.has(bounds.id)) {
+ selectedStartBounds.set(bounds.id, bounds);
+ }
+ });
+ }
+ let lastSnappedDeltaX = 0;
+ let lastSnappedDeltaY = 0;
+ let rafId: number | null = null;
+ let dragEnded = false;
+
+ activePointerIdRef.current = pointerId;
+ movedDuringPressRef.current = lastPressMovedRef.current;
+ lastPressMovedRef.current = false;
+ dragTarget.setPointerCapture(pointerId);
+ dragTarget.style.userSelect = 'none';
+
+ useSmartGuidesStore.getState().clearGuides();
+ useGridSelectionStore.getState().setDraggingOrResizing(true);
+ onMultiDragStart?.();
+
+ const handlePointerMove = (moveEvent: PointerEvent) => {
+ if (dragEnded || moveEvent.pointerId !== activePointerIdRef.current) {
+ return;
+ }
+ if (rafId !== null) return;
+
+ rafId = requestAnimationFrame(() => {
+ rafId = null;
+ if (dragEnded) return;
+
+ const rawDeltaX = (moveEvent.clientX - startClientX) / zoom;
+ const rawDeltaY = (moveEvent.clientY - startClientY) / zoom;
+ const newX = startX + rawDeltaX;
+ const newY = startY + rawDeltaY;
+ const gridSettings = useSettingsStore.getState().gridSettings;
+ const alignmentGuidesEnabled = gridSettings?.alignmentGuides !== false;
+ const spacingGuidesEnabled = gridSettings?.spacingGuides !== false;
+ const otherElements = getOtherElements(elementId);
+ const nonSelectedElements = otherElements.filter(
+ (element) => !selectedIds.has(element.id),
+ );
+ const draggedBounds = calculateBounds(
+ newX,
+ newY,
+ elementWidth,
+ elementHeight,
+ elementId,
+ );
+
+ let groupBounds: ElementBounds | null = null;
+ if (selectedElements.length > 1) {
+ const selectedBounds = selectedElements
+ .map((selectedElement) => {
+ // index 보조 비교는 index가 실재할 때만 — 플러그인처럼 index가 없는
+ // 요소끼리 undefined === undefined로 전부 현재 요소로 오인되는 것 방지
+ const isCurrentElement =
+ selectedElement.id === elementId ||
+ (elementIndex !== undefined &&
+ selectedElement.type === elementType &&
+ selectedElement.index === elementIndex);
+ if (isCurrentElement) return draggedBounds;
+
+ const found = getSelectedElementIds(selectedElement)
+ .map((id) => selectedStartBounds.get(id))
+ .find(
+ (bounds): bounds is ElementBounds => bounds !== undefined,
+ );
+ if (!found) return null;
+ return calculateBounds(
+ found.left + rawDeltaX,
+ found.top + rawDeltaY,
+ found.width,
+ found.height,
+ found.id,
+ );
+ })
+ .filter((bounds): bounds is ElementBounds => bounds !== null);
+ groupBounds = calculateGroupBounds(selectedBounds);
+ }
+
+ const snapTargetBounds =
+ selectedElements.length > 1 && groupBounds
+ ? groupBounds
+ : draggedBounds;
+ const snapResult = alignmentGuidesEnabled
+ ? calculateSnapPoints(
+ snapTargetBounds,
+ nonSelectedElements,
+ undefined,
+ {
+ groupBounds,
+ disableSpacing: !spacingGuidesEnabled,
+ },
+ )
+ : null;
+
+ let finalX: number;
+ let finalY: number;
+ if (snapResult?.didSnapX) {
+ finalX =
+ selectedElements.length > 1 && groupBounds
+ ? newX + snapResult.snappedX - groupBounds.left
+ : snapResult.snappedX;
+ } else {
+ const snapSize = gridSettings?.gridSnapSize || 5;
+ finalX = Math.round(newX / snapSize) * snapSize;
+ }
+ if (snapResult?.didSnapY) {
+ finalY =
+ selectedElements.length > 1 && groupBounds
+ ? newY + snapResult.snappedY - groupBounds.top
+ : snapResult.snappedY;
+ } else {
+ const snapSize = gridSettings?.gridSnapSize || 5;
+ finalY = Math.round(newY / snapSize) * snapSize;
+ }
+
+ const snappedDeltaX = Math.round(finalX - startX);
+ const snappedDeltaY = Math.round(finalY - startY);
+ const smartGuidesStore = useSmartGuidesStore.getState();
+ if (snapResult && (snapResult.didSnapX || snapResult.didSnapY)) {
+ const displayBounds =
+ selectedElements.length > 1 && groupBounds
+ ? calculateBounds(
+ groupBounds.left +
+ (snapResult.didSnapX
+ ? snapResult.snappedX - groupBounds.left
+ : 0),
+ groupBounds.top +
+ (snapResult.didSnapY
+ ? snapResult.snappedY - groupBounds.top
+ : 0),
+ groupBounds.width,
+ groupBounds.height,
+ 'group',
+ )
+ : calculateBounds(
+ finalX,
+ finalY,
+ elementWidth,
+ elementHeight,
+ elementId,
+ );
+ smartGuidesStore.setDraggedBounds(displayBounds);
+ smartGuidesStore.setActiveGuides(snapResult.guides);
+ smartGuidesStore.setSpacingGuides(
+ spacingGuidesEnabled && snapResult.spacingGuides?.length
+ ? snapResult.spacingGuides
+ : [],
+ );
+ } else {
+ smartGuidesStore.clearGuides();
+ }
+
+ const moveDeltaX = snappedDeltaX - lastSnappedDeltaX;
+ const moveDeltaY = snappedDeltaY - lastSnappedDeltaY;
+ if (moveDeltaX !== 0 || moveDeltaY !== 0) {
+ lastSnappedDeltaX = snappedDeltaX;
+ lastSnappedDeltaY = snappedDeltaY;
+ movedDuringPressRef.current = true;
+ lastPressMovedRef.current = true;
+ onMultiDrag?.(moveDeltaX, moveDeltaY);
+ }
+ });
+ };
+
+ const finishDrag = () => {
+ if (dragEnded) return;
+ dragEnded = true;
+ activePointerIdRef.current = null;
+ activeCleanupRef.current = null;
+ releaseDragSession();
+
+ if (dragTarget.hasPointerCapture(pointerId)) {
+ dragTarget.releasePointerCapture(pointerId);
+ }
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ dragTarget.removeEventListener('pointermove', handlePointerMove);
+ dragTarget.removeEventListener('pointerup', handlePointerEnd);
+ dragTarget.removeEventListener('pointercancel', handlePointerEnd);
+ dragTarget.removeEventListener('lostpointercapture', finishDrag);
+ window.removeEventListener('blur', finishDrag);
+ dragTarget.style.userSelect = previousUserSelect;
+ useSmartGuidesStore.getState().clearGuides();
+ useGridSelectionStore.getState().setDraggingOrResizing(false);
+ onMultiDragEnd?.();
+ };
+
+ const handlePointerEnd = (endEvent: PointerEvent) => {
+ if (endEvent.pointerId !== activePointerIdRef.current) return;
+ finishDrag();
+ };
+
+ activeCleanupRef.current = finishDrag;
+ dragTarget.addEventListener('pointermove', handlePointerMove);
+ dragTarget.addEventListener('pointerup', handlePointerEnd);
+ dragTarget.addEventListener('pointercancel', handlePointerEnd);
+ dragTarget.addEventListener('lostpointercapture', finishDrag);
+ window.addEventListener('blur', finishDrag);
+ };
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ beginPointerDrag(event, event.currentTarget);
+ };
+
+ useEffect(() => {
+ return () => activeCleanupRef.current?.();
+ }, []);
+
+ return { handlePointerDown, movedDuringPressRef };
+};
diff --git a/src/renderer/hooks/Grid/useSmartGuidesElements.ts b/src/renderer/hooks/Grid/useSmartGuidesElements.ts
index 6f9d4327..7c7fe027 100644
--- a/src/renderer/hooks/Grid/useSmartGuidesElements.ts
+++ b/src/renderer/hooks/Grid/useSmartGuidesElements.ts
@@ -10,127 +10,105 @@ import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayEle
import { calculateBounds, type ElementBounds } from '@utils/grid/smartGuides';
/**
- * 현재 탭의 모든 요소(키 + 플러그인 요소)의 bounds를 반환하는 함수를 제공하는 훅
+ * 특정 요소를 제외한 모든 요소의 bounds를 반환
+ * 드래그 시점에만 호출되므로 최신 스냅샷을 getState()로 읽는다 —
+ * 반응형 구독을 걸면 임의 요소 하나의 변경이 이 훅을 쓰는 모든
+ * 요소(Key·PluginElement·KnobItem·GraphItem)의 리렌더로 번짐
+ * @param excludeIds 제외할 요소의 ID (단일 문자열 또는 문자열 배열)
*/
-export function useSmartGuidesElements() {
- const positions = useKeyStore((state) => state.positions);
- const selectedKeyType = useKeyStore((state) => state.selectedKeyType);
- const statPositions = useStatItemStore((state) => state.positions);
- const graphPositions = useGraphItemStore((state) => state.positions);
- const knobPositions = useKnobItemStore((state) => state.positions);
- const pluginElements = usePluginDisplayElementStore(
- (state) => state.elements,
- );
+const getOtherElementsSnapshot = (
+ excludeIds: string | string[],
+): ElementBounds[] => {
+ const { positions, selectedKeyType } = useKeyStore.getState();
+ const statPositions = useStatItemStore.getState().positions;
+ const graphPositions = useGraphItemStore.getState().positions;
+ const knobPositions = useKnobItemStore.getState().positions;
+ const pluginElements = usePluginDisplayElementStore.getState().elements;
- /**
- * 특정 요소를 제외한 모든 요소의 bounds를 반환
- * @param excludeIds 제외할 요소의 ID (단일 문자열 또는 문자열 배열)
- */
- const getOtherElements = (excludeIds: string | string[]): ElementBounds[] => {
- const bounds: ElementBounds[] = [];
- // 배열로 정규화
- const excludeSet = new Set(
- Array.isArray(excludeIds) ? excludeIds : [excludeIds],
- );
+ const bounds: ElementBounds[] = [];
+ // 배열로 정규화
+ const excludeSet = new Set(
+ Array.isArray(excludeIds) ? excludeIds : [excludeIds],
+ );
- // 키 요소 bounds
- const keyPositions = positions[selectedKeyType] || [];
- keyPositions.forEach((pos, index) => {
- if (pos.hidden) return;
- const id = `key-${index}`;
- if (!excludeSet.has(id)) {
- bounds.push(
- calculateBounds(
- pos.dx,
- pos.dy,
- pos.width || 60,
- pos.height || 60,
- id,
- ),
- );
- }
- });
+ // 키 요소 bounds
+ const keyPositions = positions[selectedKeyType] || [];
+ keyPositions.forEach((pos, index) => {
+ if (pos.hidden) return;
+ const id = `key-${index}`;
+ if (!excludeSet.has(id)) {
+ bounds.push(
+ calculateBounds(pos.dx, pos.dy, pos.width || 60, pos.height || 60, id),
+ );
+ }
+ });
- // 통계 요소 bounds
- const stats = statPositions[selectedKeyType] || [];
- stats.forEach((pos, index) => {
- if (!pos || pos.hidden) return;
- const id = `stat-${index}`;
- if (!excludeSet.has(id)) {
- bounds.push(
- calculateBounds(
- pos.dx,
- pos.dy,
- pos.width || 60,
- pos.height || 60,
- id,
- ),
- );
- }
- });
+ // 통계 요소 bounds
+ const stats = statPositions[selectedKeyType] || [];
+ stats.forEach((pos, index) => {
+ if (!pos || pos.hidden) return;
+ const id = `stat-${index}`;
+ if (!excludeSet.has(id)) {
+ bounds.push(
+ calculateBounds(pos.dx, pos.dy, pos.width || 60, pos.height || 60, id),
+ );
+ }
+ });
- // 그래프 요소 bounds
- const graphs = graphPositions[selectedKeyType] || [];
- graphs.forEach((pos, index) => {
- if (!pos || pos.hidden) return;
- const id = `graph-${index}`;
- if (!excludeSet.has(id)) {
- bounds.push(
- calculateBounds(
- pos.dx,
- pos.dy,
- pos.width || 200,
- pos.height || 100,
- id,
- ),
- );
- }
- });
+ // 그래프 요소 bounds
+ const graphs = graphPositions[selectedKeyType] || [];
+ graphs.forEach((pos, index) => {
+ if (!pos || pos.hidden) return;
+ const id = `graph-${index}`;
+ if (!excludeSet.has(id)) {
+ bounds.push(
+ calculateBounds(
+ pos.dx,
+ pos.dy,
+ pos.width || 200,
+ pos.height || 100,
+ id,
+ ),
+ );
+ }
+ });
- // 노브 요소 bounds
- const knobs = knobPositions[selectedKeyType] || [];
- knobs.forEach((pos, index) => {
- if (!pos || pos.hidden) return;
- const id = `knob-${index}`;
- if (!excludeSet.has(id)) {
- bounds.push(
- calculateBounds(
- pos.dx,
- pos.dy,
- pos.width || 60,
- pos.height || 60,
- id,
- ),
- );
- }
- });
+ // 노브 요소 bounds
+ const knobs = knobPositions[selectedKeyType] || [];
+ knobs.forEach((pos, index) => {
+ if (!pos || pos.hidden) return;
+ const id = `knob-${index}`;
+ if (!excludeSet.has(id)) {
+ bounds.push(
+ calculateBounds(pos.dx, pos.dy, pos.width || 60, pos.height || 60, id),
+ );
+ }
+ });
- // 플러그인 요소 bounds (현재 탭에 속하는 요소만)
- pluginElements.forEach((el) => {
- if (el.hidden) return;
- // tabId가 없으면 모든 탭에 표시되는 요소로 간주
- // tabId가 있으면 현재 선택된 탭과 일치해야 함
- const belongsToCurrentTab = !el.tabId || el.tabId === selectedKeyType;
+ // 플러그인 요소 bounds (현재 탭에 속하는 요소만)
+ pluginElements.forEach((el) => {
+ if (el.hidden) return;
+ // tabId가 없으면 모든 탭에 표시되는 요소로 간주
+ // tabId가 있으면 현재 선택된 탭과 일치해야 함
+ const belongsToCurrentTab = !el.tabId || el.tabId === selectedKeyType;
- if (
- !excludeSet.has(el.fullId) &&
- el.measuredSize &&
- belongsToCurrentTab
- ) {
- bounds.push(
- calculateBounds(
- el.position.x,
- el.position.y,
- el.measuredSize.width,
- el.measuredSize.height,
- el.fullId,
- ),
- );
- }
- });
+ if (!excludeSet.has(el.fullId) && el.measuredSize && belongsToCurrentTab) {
+ bounds.push(
+ calculateBounds(
+ el.position.x,
+ el.position.y,
+ el.measuredSize.width,
+ el.measuredSize.height,
+ el.fullId,
+ ),
+ );
+ }
+ });
- return bounds;
- };
+ return bounds;
+};
- return { getOtherElements };
+// 구독 없는 훅 — 함수 참조 안정
+export function useSmartGuidesElements() {
+ return { getOtherElements: getOtherElementsSnapshot };
}
diff --git a/src/renderer/hooks/overlay/useNoteSystem.ts b/src/renderer/hooks/overlay/useNoteSystem.ts
index a9519a8a..1687b539 100644
--- a/src/renderer/hooks/overlay/useNoteSystem.ts
+++ b/src/renderer/hooks/overlay/useNoteSystem.ts
@@ -42,11 +42,16 @@ interface NoteState {
interface NoteSettings {
speed?: number;
trackHeight?: number;
+ frameLimit?: number;
delayedNoteEnabled?: boolean;
shortNoteThresholdMs?: number;
shortNoteMinLengthPx?: number;
}
+// 셰이더는 travel ≥ trackHeight 시점에 노트를 컬하지만, 프레임 제한 시 uTime(stableTime)이
+// wall clock보다 최대 limiter interval만큼 늦으므로 그만큼만 여유를 두고 정리
+const NOTE_CLEANUP_SLACK_MS = 50;
+
interface UseNoteSystemOptions {
noteEffect: boolean;
noteSettings?: NoteSettings;
@@ -116,6 +121,9 @@ export function useNoteSystem({
const activeNotes = useRef