diff --git a/package-lock.json b/package-lock.json index 3ed54815..621bf81d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,6 @@ "lenis": "^1.3.15", "ogl": "^1.0.11", "react": "^19.2.4", - "react-color-palette": "^7.3.1", "react-dom": "^19.2.4", "three": "^0.180.0", "zod": "^4.1.11", @@ -168,7 +167,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -633,7 +631,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -682,7 +679,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2196,7 +2192,6 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -2576,7 +2571,6 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2587,7 +2581,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2637,7 +2630,6 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -3007,7 +2999,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3345,7 +3336,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3904,7 +3894,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4791,7 +4780,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4822,7 +4810,6 @@ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -5601,7 +5588,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5808,29 +5794,15 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/react-color-palette": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/react-color-palette/-/react-color-palette-7.3.1.tgz", - "integrity": "sha512-O8eTbogOKExVbhFOCaQ6WACQtGznymLqbd95i4C42PZd8im9OwL1NjuGxswk2JxJR5jfcTu7KQzl7VbpU48m+g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5983,7 +5955,6 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6683,7 +6654,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6780,7 +6750,6 @@ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -6798,7 +6767,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6889,7 +6857,6 @@ "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", @@ -7237,7 +7204,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 061429c6..5af39107 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,6 @@ "lenis": "^1.3.15", "ogl": "^1.0.11", "react": "^19.2.4", - "react-color-palette": "^7.3.1", "react-dom": "^19.2.4", "three": "^0.180.0", "zod": "^4.1.11", diff --git a/src/renderer/__tests__/keyStatsDedupe.test.ts b/src/renderer/__tests__/keyStatsDedupe.test.ts new file mode 100644 index 00000000..f4f44419 --- /dev/null +++ b/src/renderer/__tests__/keyStatsDedupe.test.ts @@ -0,0 +1,34 @@ +/** + * keyStatsService 유휴 통지 dedupe 회귀 테스트 + * 50ms 주기 틱이 값 미변경 시 리스너 fan-out을 만들지 않아야 함 + */ +import { describe, it, expect, vi } from 'vitest'; +import { keyStatsService } from '@utils/keyStatsService'; + +type ServicePrivate = { + updateStats: () => void; + timestamps: number[]; +}; + +describe('keyStatsService 유휴 통지 dedupe', () => { + it('값이 변하지 않으면 주기 틱이 리스너를 호출하지 않는다', () => { + const listener = vi.fn(); + const unsubscribe = keyStatsService.subscribe(listener); + const svc = keyStatsService as unknown as ServicePrivate; + + // 구독 즉시 1회 + 첫 틱(lastNotified 초기화) 허용 + svc.updateStats(); + listener.mockClear(); + + // 유휴 200틱 (수정 전: 200회 통지) + for (let i = 0; i < 200; i += 1) svc.updateStats(); + expect(listener).toHaveBeenCalledTimes(0); + + // 키 입력 발생 → 통지 재개 + svc.timestamps.push(Date.now()); + svc.updateStats(); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); +}); diff --git a/src/renderer/__tests__/overlayCommitBudget.test.tsx b/src/renderer/__tests__/overlayCommitBudget.test.tsx new file mode 100644 index 00000000..ce430be4 --- /dev/null +++ b/src/renderer/__tests__/overlayCommitBudget.test.tsx @@ -0,0 +1,167 @@ +/** + * 오버레이 상주 컴포넌트의 React 커밋 예산 회귀 테스트 + * - 카운터: count 증가 1회당 커밋 수가 애니메이션 프레임 수에 비례하면 안 됨 + * - 그래프: 유휴(모든 값 0) 상태에서 틱마다 커밋하면 안 됨 + */ +import React, { Profiler, act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import CountDisplay from '@components/overlay/counters/CountDisplay'; +import OverlayGraphItem from '@components/overlay/counters/OverlayGraphItem'; + +// 그래프 렌더 자체가 아니라 커밋 횟수만 측정 — 무거운 자식은 스텁 +vi.mock('@components/shared/GraphPanel', () => ({ + default: () => null, +})); + +// 테스트가 신호 값을 직접 제어 +const mockStatSignal = { value: 0 }; +vi.mock('@stores/signals/statsSignals', () => ({ + getStatValueSignal: () => mockStatSignal, +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; +let commits: number; + +const onRender = (): void => { + commits += 1; +}; + +// 실브라우저에서 틱/프레임은 각각 독립 태스크로 커밋됨 — act 배칭을 피하려고 +// 스텝 단위로 나눠 진행 +const advanceInSteps = (totalMs: number, stepMs: number): void => { + const steps = Math.ceil(totalMs / stepMs); + for (let i = 0; i < steps; i += 1) { + act(() => { + vi.advanceTimersByTime(stepMs); + }); + } +}; + +beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + }); + commits = 0; + mockStatSignal.value = 0; +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); +}); + +describe('CountDisplay 커밋 예산', () => { + const renderCount = (count: number): void => { + act(() => { + root.render( + + + , + ); + }); + }; + + it('count 증가 1회는 상수 커밋만 발생 (스케일 애니메이션이 커밋을 만들지 않음)', () => { + renderCount(0); + advanceInSteps(500, 16); + commits = 0; + + renderCount(1); + // 애니메이션 300ms + 여유 (16ms 스텝 = 프레임별 독립 커밋 재현) + advanceInSteps(600, 16); + + // 수정 전: count 커밋 1 + rAF당 setScale 커밋 (~18) = 20 내외 + expect(commits).toBeLessThanOrEqual(3); + }); + + it('연속 증가(10회)도 증가 횟수에 비례하는 커밋만 발생', () => { + renderCount(0); + advanceInSteps(500, 16); + commits = 0; + + for (let i = 1; i <= 10; i += 1) { + renderCount(i); + advanceInSteps(96, 16); + } + advanceInSteps(600, 16); + + // 수정 전: 10 커밋 + 프레임별 setScale 커밋 수십 + expect(commits).toBeLessThanOrEqual(13); + }); + + it('애니메이션 후 스케일이 1로 복원됨 (동작 보존)', () => { + renderCount(0); + renderCount(1); + act(() => { + vi.advanceTimersByTime(50); + }); + const span = container.querySelector('span.counter') as HTMLSpanElement; + // 애니메이션 중에는 1보다 큰 스케일 + expect(span.style.transform).not.toBe('scale(1)'); + act(() => { + vi.advanceTimersByTime(600); + }); + expect(span.style.transform).toBe('scale(1)'); + }); +}); + +describe('OverlayGraphItem 유휴 커밋 예산', () => { + const renderGraph = (): void => { + act(() => { + root.render( + + + , + ); + }); + }; + + it('유휴(값 0) 10초 동안 틱 커밋이 발생하지 않음', () => { + renderGraph(); + // 초기 안정화 (마운트 직후 첫 틱 포함) + advanceInSteps(500, 50); + commits = 0; + + advanceInSteps(10_000, 50); + + // 수정 전: 50ms 틱마다 커밋 = 200회 + expect(commits).toBeLessThanOrEqual(2); + }); + + it('값이 흐르면 다시 커밋하고, 드레인 완료 후 유휴로 복귀', () => { + renderGraph(); + advanceInSteps(500, 50); + + // 활성: 신호 값이 흐르는 동안 커밋 발생 + commits = 0; + mockStatSignal.value = 5; + advanceInSteps(1_000, 50); + expect(commits).toBeGreaterThanOrEqual(10); + + // 값 0 복귀 → history가 비워질 때까지(graphSpeed 1000ms = 버퍼 10칸)와 + // 마지막 제로 상태 커밋까지 허용 + mockStatSignal.value = 0; + advanceInSteps(2_000, 50); + + // 완전 유휴 5초: 추가 커밋 없음 + commits = 0; + advanceInSteps(5_000, 50); + expect(commits).toBeLessThanOrEqual(2); + }); +}); diff --git a/src/renderer/__tests__/pluginElementIsolation.test.tsx b/src/renderer/__tests__/pluginElementIsolation.test.tsx new file mode 100644 index 00000000..9f16072d --- /dev/null +++ b/src/renderer/__tests__/pluginElementIsolation.test.tsx @@ -0,0 +1,242 @@ +/** + * 플러그인 요소 격리 회귀 테스트 + * 부모(Grid)가 렌더마다 새 인라인 콜백·offset 객체를 만들어도 + * 요소 하나의 갱신이 형제 요소 리렌더로 번지면 안 된다 + */ +import React, { act, useLayoutEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { useSmartGuidesElements } from '@hooks/Grid'; +import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; + +type SelectionContextMenuPayload = { + elementId: string; + clientX: number; + clientY: number; + referenceElement: HTMLDivElement | null; +}; + +type StubProps = { + element: { fullId: string }; + onSelectionContextMenu?: (payload: SelectionContextMenuPayload) => boolean; +}; + +const probe = vi.hoisted(() => ({ + counts: {} as Record, + lastProps: null as null | StubProps, +})); + +// 실제 PluginElement(1500줄, Shadow DOM)는 무겁고, 검증 대상은 +// "renderer가 memo를 깨뜨리지 않는 props를 주는가"이므로 memo 스텁으로 대체 +vi.mock('@components/shared/PluginElement', async () => { + const ReactActual = (await import('react')).default; + const Stub = (props: StubProps) => { + probe.counts[props.element.fullId] = + (probe.counts[props.element.fullId] ?? 0) + 1; + probe.lastProps = props; + return null; + }; + return { PluginElement: ReactActual.memo(Stub) }; +}); + +import { PluginElementsRenderer } from '@components/shared/PluginElementsRenderer'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const makeElement = (fullId: string): PluginDisplayElementInternal => + ({ + fullId, + pluginId: 'test', + width: 100, + } as unknown as PluginDisplayElementInternal); + +// Grid와 동일한 최악 조건: 렌더마다 새 콜백·새 offset 객체 +const Harness = () => ( + false} + onMultiDrag={() => {}} + onMultiDragStart={() => {}} + onMultiDragEnd={() => {}} + /> +); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + probe.counts = {}; + // main 모드 이펙트가 구독하는 bridge 채널 최소 스텁 + (window as unknown as { api: unknown }).api = { + bridge: { on: () => () => {} }, + }; + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + }); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); +}); + +describe('플러그인 요소 격리', () => { + it('요소 하나의 갱신이 형제 리렌더로 번지지 않는다', () => { + usePluginDisplayElementStore.setState({ + elements: [ + makeElement('plugin:a'), + makeElement('plugin:b'), + makeElement('plugin:c'), + ], + }); + + act(() => { + root.render(); + }); + expect(probe.counts).toEqual({ + 'plugin:a': 1, + 'plugin:b': 1, + 'plugin:c': 1, + }); + + // 부모 재렌더 (새 인라인 콜백·offset) — 아무 요소도 다시 렌더되면 안 됨 + act(() => { + root.render(); + }); + expect(probe.counts).toEqual({ + 'plugin:a': 1, + 'plugin:b': 1, + 'plugin:c': 1, + }); + + // a만 갱신 → a만 리렌더 + act(() => { + usePluginDisplayElementStore + .getState() + .updateElement('plugin:a', { width: 140 }); + }); + expect(probe.counts).toEqual({ + 'plugin:a': 2, + 'plugin:b': 1, + 'plugin:c': 1, + }); + }); + + it('안정화 콜백은 commit 직후(layout, passive 이전)에도 최신 콜백을 호출한다', () => { + usePluginDisplayElementStore.setState({ + elements: [makeElement('plugin:a')], + }); + + const calls: string[] = []; + // 상위 layout effect는 renderer(자식)의 layout effect 이후, passive 이전에 실행됨 + // — commit~passive 사이에 이전 콜백이 호출되는 창을 그대로 검사 + const LayoutProbe = ({ version }: { version: string }) => { + useLayoutEffect(() => { + probe.lastProps?.onSelectionContextMenu?.({ + elementId: 'plugin:a', + clientX: 0, + clientY: 0, + referenceElement: null, + }); + }, [version]); + return ( + { + calls.push(version); + return false; + }} + onMultiDrag={() => {}} + onMultiDragStart={() => {}} + onMultiDragEnd={() => {}} + /> + ); + }; + + act(() => { + root.render(); + }); + act(() => { + root.render(); + }); + + expect(calls).toEqual(['v1', 'v2']); + }); + + it('동일한 선택을 다시 설정해도 selectedElements 참조가 유지된다', () => { + const store = useGridSelectionStore.getState(); + store.setSelectedElements([ + { type: 'plugin', id: 'plugin:a' }, + { type: 'key', id: 'key-1', index: 1 }, + ]); + const first = useGridSelectionStore.getState().selectedElements; + + store.setSelectedElements([ + { type: 'plugin', id: 'plugin:a' }, + { type: 'key', id: 'key-1', index: 1 }, + ]); + expect(useGridSelectionStore.getState().selectedElements).toBe(first); + + // 실제로 다른 선택이면 교체 + store.setSelectedElements([{ type: 'plugin', id: 'plugin:b' }]); + expect(useGridSelectionStore.getState().selectedElements).not.toBe(first); + }); +}); + +describe('useSmartGuidesElements 비구독', () => { + it('스토어가 바뀌어도 소비자는 리렌더되지 않고, 호출 시점 스냅샷을 읽는다', () => { + const captured: { + renders: number; + getOther: ((exclude: string) => unknown[]) | null; + } = { renders: 0, getOther: null }; + // Profiler 콜백·effect는 컴포넌트 렌더 밖이라 외부 상태 기록 가능 + const handleRender = (): void => { + captured.renders += 1; + }; + const Consumer = () => { + const { getOtherElements } = useSmartGuidesElements(); + useLayoutEffect(() => { + captured.getOther = getOtherElements; + }); + return null; + }; + + usePluginDisplayElementStore.setState({ elements: [] }); + act(() => { + root.render( + + + , + ); + }); + const rendersAfterMount = captured.renders; + + // 플러그인 요소 추가 — 구독이 없으므로 리렌더 0 + act(() => { + usePluginDisplayElementStore.setState({ + elements: [ + { + ...makeElement('plugin:new'), + position: { x: 10, y: 20 }, + measuredSize: { width: 50, height: 40 }, + } as unknown as PluginDisplayElementInternal, + ], + }); + }); + expect(captured.renders).toBe(rendersAfterMount); + + // 하지만 호출하면 최신 스냅샷이 보임 + const bounds = captured.getOther!('none') as { id: string }[]; + expect(bounds.some((b) => b.id === 'plugin:new')).toBe(true); + }); +}); diff --git a/src/renderer/__tests__/pluginElementStoreIdentity.test.ts b/src/renderer/__tests__/pluginElementStoreIdentity.test.ts new file mode 100644 index 00000000..07456227 --- /dev/null +++ b/src/renderer/__tests__/pluginElementStoreIdentity.test.ts @@ -0,0 +1,33 @@ +/** + * PluginElement React.memo의 전제 검증: + * 스토어 update 경로가 미변경 요소의 객체 참조를 유지해야 + * 요소 하나의 갱신이 나머지 요소 리렌더로 번지지 않는다 + */ +import { describe, it, expect } from 'vitest'; +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; + +const makeElement = (fullId: string): PluginDisplayElementInternal => + ({ + fullId, + pluginId: 'test', + width: 100, + } as unknown as PluginDisplayElementInternal); + +describe('plugin display element store identity', () => { + it('updateElement는 미변경 요소의 참조를 유지한다', () => { + const a = makeElement('plugin:a'); + const b = makeElement('plugin:b'); + const c = makeElement('plugin:c'); + usePluginDisplayElementStore.setState({ elements: [a, b, c] }); + + usePluginDisplayElementStore + .getState() + .updateElement('plugin:a', { width: 120 }); + + const elements = usePluginDisplayElementStore.getState().elements; + expect(elements.find((el) => el.fullId === 'plugin:a')).not.toBe(a); + expect(elements.find((el) => el.fullId === 'plugin:b')).toBe(b); + expect(elements.find((el) => el.fullId === 'plugin:c')).toBe(c); + }); +}); diff --git a/src/renderer/__tests__/setup.ts b/src/renderer/__tests__/setup.ts index 08752588..d48c112c 100644 --- a/src/renderer/__tests__/setup.ts +++ b/src/renderer/__tests__/setup.ts @@ -1,5 +1,50 @@ // jsdom에 없는 API stub +if (!window.PointerEvent) { + class PointerEventPolyfill extends MouseEvent { + readonly pointerId: number; + readonly pointerType: string; + readonly isPrimary: boolean; + + constructor( + type: string, + init: MouseEventInit & { + pointerId?: number; + pointerType?: string; + isPrimary?: boolean; + } = {}, + ) { + super(type, init); + this.pointerId = init.pointerId ?? 1; + this.pointerType = init.pointerType ?? 'mouse'; + this.isPrimary = init.isPrimary ?? true; + } + } + + Object.defineProperty(window, 'PointerEvent', { + value: PointerEventPolyfill, + }); + Object.defineProperty(globalThis, 'PointerEvent', { + value: PointerEventPolyfill, + }); +} + +const capturedPointers = new WeakMap>(); + +HTMLElement.prototype.setPointerCapture = function (pointerId: number) { + const pointerIds = capturedPointers.get(this) ?? new Set(); + pointerIds.add(pointerId); + capturedPointers.set(this, pointerIds); +}; + +HTMLElement.prototype.releasePointerCapture = function (pointerId: number) { + capturedPointers.get(this)?.delete(pointerId); +}; + +HTMLElement.prototype.hasPointerCapture = function (pointerId: number) { + return capturedPointers.get(this)?.has(pointerId) ?? false; +}; + // colorUtils의 parseHexColor에서 사용 if (!window.CSS) { Object.defineProperty(window, 'CSS', { diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 2c833d72..d4c801ae 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -799,28 +799,17 @@ const PropertiesPanel: React.FC = ({ } if (hasSelection) { + // 열린 패널의 페이지는 sticky — 레이어 목록 표시 중 캔버스 클릭은 선택만 바꾸고 + // 편집(property) 진입은 더블클릭·목록 더블클릭·헤더 토글만 수행한다 (포토샵식) if (!hadSelection) { manuallyClosedRef.current = false; if (!isPanelVisible) { setPanelMode('property'); setIsPanelVisible(true); - } else if ( - panelModeRef.current === 'layer' && - !selectionFromLayerPanelRef.current && - !skipFromKeyboard - ) { - setPanelMode('property'); } } else if (!isPanelVisible && !manuallyClosedRef.current) { setPanelMode('property'); setIsPanelVisible(true); - } else if ( - panelModeRef.current === 'layer' && - !selectionFromLayerPanelRef.current && - !skipFromKeyboard && - isPanelVisible - ) { - setPanelMode('property'); } } else if (hadSelection) { if (keyTypeChangedRef.current && isPanelVisible) { @@ -864,6 +853,28 @@ const PropertiesPanel: React.FC = ({ }; }, []); + // 빈 선택 폴백으로 레이어 목록이 표시되는 동안 내부 모드도 layer로 정규화 — + // property로 남아 있으면 다음 캔버스 클릭이 목록을 건너뛰고 편집으로 점프함 + // (플러그인 설정 패널 종료·설정 왕복 리마운트 경로 포함) + useEffect(() => { + if ( + isPanelVisible && + !pluginSettingsPanel && + panelMode === 'property' && + selectedKeyElements.length === 0 && + selectedElements.length === 0 + ) { + setPanelMode('layer'); + } + }, [ + isPanelVisible, + pluginSettingsPanel, + panelMode, + selectedKeyElements.length, + selectedElements, + setPanelMode, + ]); + // 다중 선택 시 패널 자동 열기 useEffect(() => { if ( diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx index 9853e222..01c62a62 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PropertyInputs.tsx @@ -18,6 +18,7 @@ import { FORM_LABEL_CLASS, } from '@utils/cardRecipes'; import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; +import { ColorSwatchButton } from '@components/main/Modal/content/pickers/ColorSwatch'; // ============================================================================ // 속성 행 @@ -723,19 +724,17 @@ export const ColorInput: React.FC = ({ return ( <> - + + {audioBuffer ? formatSecLabel(trimDurationMs) : '--'} + + + +
+

{t('soundTrimModal.nameLabel')} - +

setSoundName(e.target.value)} placeholder={t('soundTrimModal.namePlaceholder')} - className="w-full h-[30px] px-[10px] rounded-md bg-inset text-body leading-[16px] text-fg placeholder-fg-faint outline-none focus:shadow-focus-ring transition-shadow duration-fast" + className="flex-1 min-w-0 h-[23px] px-[8px] bg-inset rounded-md text-body text-fg placeholder-fg-faint outline-none focus:shadow-focus-ring transition-shadow duration-fast" disabled={isSaving} />
- {/* 파형 섹션 */} -
-
- {/* 재생 버튼 */} -
- - - {audioBuffer ? formatSecLabel(trimDurationMs) : '--'} - -
- - {/* 파형 캔버스 */} -
- {audioBuffer ? ( - - ) : ( -
- {isDecoding - ? isEditMode - ? t('soundTrimModal.statusLoading') - : t('soundTrimModal.decodingMessage') - : t('soundTrimModal.emptyMessage')} -
- )} -
-
-
- - {errorText ? ( -

- {errorText} -

- ) : null} - - - {/* 힌트 바 */} -
{!isEditMode ? ( - ) : ( - - )} -

- {t('soundTrimModal.dragHint')} -

+ ) : null}
- {!isEditMode ? ( - + {errorText ? ( +

+ {errorText} +

) : null} - - {/* 푸터 */} -
- - -
- + + {!isEditMode ? ( + + ) : null} + ); }; diff --git a/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx b/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx index fb543f9b..7ff86fc5 100644 --- a/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ColorPicker.tsx @@ -2,13 +2,10 @@ import React, { useEffect, useRef, useState, useLayoutEffect } from 'react'; import { useTranslation } from '@contexts/useTranslation'; import { - Saturation, - Hue, - Alpha, - useColor, - type IColor, -} from 'react-color-palette'; -import 'react-color-palette/css'; + SaturationArea, + HueSlider, + AlphaSlider, +} from './colorPickerPrimitives'; import FloatingPopup from '../../FloatingPopup'; import TabSwitch from '@components/main/common/TabSwitch'; import { @@ -17,13 +14,19 @@ import { normalizeColorInput, buildGradient, parseHexColor, + hsvToColorObject, toColorObject, type GradientColor, type ColorObject, } from '@utils/color/colorUtils'; import { loadPalette, addToPalette } from '@utils/color/colorPaletteStorage'; +import { ColorSwatchButton, ColorSwatchSurface } from './ColorSwatch'; type ColorValue = string | GradientColor; + +// normalizeColorInput 기본색과 동일 +const DEFAULT_PICKER_COLOR: ColorObject = + parseHexColor('#561ecb') ?? hsvToColorObject({ h: 0, s: 0, v: 100, a: 1 }); type GradientSide = 'top' | 'bottom'; type OpacityTarget = 'solid' | 'top' | 'bottom'; @@ -113,7 +116,9 @@ const ColorPickerWrapper = ({ : MODES.solid; const [mode, setMode] = useState(initialMode); const baseColor = normalizeColorInput(color); - const [selectedColor, setSelectedColor] = useColor(baseColor); + const [selectedColor, setSelectedColor] = useState( + () => toColorObject(baseColor) ?? DEFAULT_PICKER_COLOR, + ); const [alpha, setAlpha] = useState(() => extractAlphaFromColor(color), ); @@ -275,7 +280,10 @@ const ColorPickerWrapper = ({ const targetHex = gradientSelected === 'bottom' ? bottomHex : topHex; const parsedTarget = parseHexColor(targetHex); if (parsedTarget) { - setSelectedColor(parsedTarget); + // 같은 색이면 유지 — hex 왕복으로 hue(360°, s=0 등)가 소실되지 않도록 + setSelectedColor((prev) => + prev.hex === parsedTarget.hex ? prev : parsedTarget, + ); } if (!wasGradient) { @@ -285,9 +293,20 @@ const ColorPickerWrapper = ({ } } else if (typeof color === 'string') { const normalized = normalizeColorInput(color); - const parsed = parseHexColor(normalized); + const parsed = toColorObject(normalized); if (parsed) { - setSelectedColor(parsed); + // 같은 색이면 hsv 유지(hex 왕복의 hue 소실 방지)하되 alpha는 병합 — + // alpha만 바뀐 외부 변경이 슬라이더 노브에 반영되도록 + setSelectedColor((prev) => { + if (prev.hex !== parsed.hex) return parsed; + const nextAlpha = parsed.rgb.a ?? 1; + if (prev.rgb.a === nextAlpha) return prev; + return { + ...prev, + rgb: { ...prev.rgb, a: nextAlpha }, + hsv: { ...prev.hsv, a: nextAlpha }, + }; + }); // RGBA에서 alpha 추출하여 설정 const newAlpha = extractAlphaFromColor(color); setAlpha(newAlpha); @@ -344,7 +363,7 @@ const ColorPickerWrapper = ({ const setAlphaWithSync = (nextAlpha: number, isComplete: boolean = false) => { const clamped = Math.min(Math.max(Number(nextAlpha) || 0, 0), 1); setAlpha(clamped); - setSelectedColor((prev: IColor) => { + setSelectedColor((prev: ColorObject) => { if (!prev) return prev; return { ...prev, @@ -373,12 +392,10 @@ const ColorPickerWrapper = ({ }, [mode, gradientTop, gradientBottom, onColorChange, solidOnly]); const applyColor = ( - next: IColor | string | Partial, + next: string | Partial, isComplete: boolean = false, ) => { - const parsed = toColorObject( - next as string | Partial | null | undefined, - ); + const parsed = toColorObject(next); if (!parsed) return; setSelectedColor(parsed); if (!solidOnly && mode === MODES.gradient) { @@ -416,12 +433,12 @@ const ColorPickerWrapper = ({ } }; - const handleChange = (nextColor: IColor) => { + const handleChange = (nextColor: ColorObject) => { isDraggingRef.current = true; applyColor(nextColor, false); }; - const handleChangeComplete = (nextColor: IColor) => { + const handleChangeComplete = (nextColor: ColorObject) => { applyColor(nextColor, true); isDraggingRef.current = false; }; @@ -621,17 +638,10 @@ const ColorPickerWrapper = ({ if (panelElement) { const panelRect = panelElement.getBoundingClientRect(); - // picker 요소의 실제 크기를 측정하거나 기본값 사용 + // 실측 우선 — 폴백은 정상 경로에선 도달하지 않는 방어값 const pickerEl = pickerContainerRef.current; - const pickerWidth = pickerEl ? pickerEl.offsetWidth : 164; - // 솔리드 모드 높이를 기준으로 함 - const solidPickerHeight = - (solidOnly ? 280 : 264) + - (showStateSwitch ? 31 : 0) + - (showOpacityControl ? 36 : 0); // 상태 탭(대기/입력) + 추가 투명도 컨트롤 포함 - const actualPickerHeight = pickerEl - ? pickerEl.offsetHeight - : solidPickerHeight; + const pickerWidth = pickerEl ? pickerEl.offsetWidth : 168; + const actualPickerHeight = pickerEl ? pickerEl.offsetHeight : 300; const gap = 5; // 패널과 피커 사이의 간격 const padding = 5; // 화면 가장자리 패딩 @@ -752,7 +762,7 @@ const ColorPickerWrapper = ({ return resolvedOpacityBottom ?? 100; })(); - const opacitySliderColor: IColor = (() => { + const opacitySliderColor: ColorObject = (() => { if (!showOpacityControl) return selectedColor; const a = clampOpacityPercent(opacitySliderPercent) / 100; return { @@ -788,7 +798,7 @@ const ColorPickerWrapper = ({ >
} - - - {solidOnly && ( - + { - // Alpha 변경 시 hex 값은 유지하고 alpha만 동기화 (hex 입력 깜빡임 방지) - setAlphaWithSync(color.rgb.a, false); - }} - onChangeComplete={(color: IColor) => { - setAlphaWithSync(color.rgb.a, true); - }} + onChange={handleChange} + onChangeComplete={handleChangeComplete} /> - )} - - {showOpacityControl && ( - { - const target = opacitySliderTarget; - const next = clampOpacityPercent((c?.rgb?.a ?? 1) * 100); - if ( - opacityPercentFocusTarget === null || - opacityPercentFocusTarget !== target - ) { + {solidOnly && ( + { + // Alpha 변경 시 hex 값은 유지하고 alpha만 동기화 (hex 입력 깜빡임 방지) + setAlphaWithSync(color.rgb.a, false); + }} + onChangeComplete={(color: ColorObject) => { + setAlphaWithSync(color.rgb.a, true); + }} + /> + )} + + {showOpacityControl && ( + { + const target = opacitySliderTarget; + const next = clampOpacityPercent((c?.rgb?.a ?? 1) * 100); + if ( + opacityPercentFocusTarget === null || + opacityPercentFocusTarget !== target + ) { + if (target === 'solid') + setOpacityPercentSolidInput(String(next)); + else if (target === 'top') + setOpacityPercentTopInput(String(next)); + else setOpacityPercentBottomInput(String(next)); + } + onOpacityPercentChange?.(next, target); + }} + onChangeComplete={(c: ColorObject) => { + const target = opacitySliderTarget; + const next = clampOpacityPercent((c?.rgb?.a ?? 1) * 100); if (target === 'solid') setOpacityPercentSolidInput(String(next)); else if (target === 'top') setOpacityPercentTopInput(String(next)); else setOpacityPercentBottomInput(String(next)); - } - onOpacityPercentChange?.(next, target); - }} - onChangeComplete={(c: IColor) => { - const target = opacitySliderTarget; - const next = clampOpacityPercent((c?.rgb?.a ?? 1) * 100); - if (target === 'solid') setOpacityPercentSolidInput(String(next)); - else if (target === 'top') - setOpacityPercentTopInput(String(next)); - else setOpacityPercentBottomInput(String(next)); - onOpacityPercentChange?.(next, target); - onOpacityPercentChangeComplete?.(next, target); - }} - /> - )} + onOpacityPercentChange?.(next, target); + onOpacityPercentChangeComplete?.(next, target); + }} + /> + )} +
{solidOnly || mode === MODES.solid ? ( @@ -968,7 +981,6 @@ interface ColorPaletteSectionProps { gradientPalette: (string | GradientColor)[]; onPaletteClick: (color: ColorValue, type: string) => void; showGradient: boolean; - solidOnly: boolean; } function ColorPaletteSection({ @@ -976,9 +988,8 @@ function ColorPaletteSection({ gradientPalette, onPaletteClick, showGradient, - solidOnly, }: ColorPaletteSectionProps) { - const PALETTE_SIZE = 5; + const PALETTE_SIZE = 7; // 빈 슬롯 채우기 const filledSolid: (string | GradientColor | null)[] = [...solidPalette]; @@ -994,23 +1005,22 @@ function ColorPaletteSection({ } return ( -
+
{/* 솔리드 팔레트 */} -
+
{filledSolid.map((color, index) => ( color && onPaletteClick(color, 'solid')} - solidOnly={solidOnly} /> ))}
{/* 그라디언트 팔레트 (solidOnly가 아닐 때만 표시) */} {showGradient && ( -
+
{filledGradient.map((color, index) => ( void; - solidOnly?: boolean; } -function PaletteSlot({ - color, - type, - onClick, - solidOnly: _solidOnly, -}: PaletteSlotProps) { +function PaletteSlot({ color, type, onClick }: PaletteSlotProps) { const isEmpty = !color; - - // 배경 스타일 계산 - const getBackgroundStyle = (): React.CSSProperties => { - if (isEmpty) { - return { backgroundColor: 'var(--ui-bg-surface)' }; - } - - if ( - type === 'gradient' && - color && - typeof color === 'object' && - (color as GradientColor).type === 'gradient' - ) { - const gradientColor = color as GradientColor; - return { - background: `linear-gradient(to bottom, ${gradientColor.top}, ${gradientColor.bottom})`, - }; - } - - // 솔리드 색상 - if (typeof color === 'string') { - // RGBA 형식인 경우 - if (color.startsWith('rgba(')) { - return { backgroundColor: color }; - } - // Hex 형식 - return { backgroundColor: color.startsWith('#') ? color : `#${color}` }; - } - - return { backgroundColor: 'var(--ui-bg-surface)' }; - }; + const gradient = + type === 'gradient' && + color && + typeof color === 'object' && + (color as GradientColor).type === 'gradient' + ? (color as GradientColor) + : undefined; + const solidColor = + typeof color === 'string' + ? color.startsWith('#') || color.startsWith('rgb') + ? color + : `#${color}` + : isEmpty + ? 'var(--ui-bg-surface)' + : undefined; // 툴팁 텍스트 생성 const getTitle = (): string => { @@ -1113,12 +1101,14 @@ function PaletteSlot({ }; return ( - + ); + }, +); + +ColorSwatchButton.displayName = 'ColorSwatchButton'; diff --git a/src/renderer/components/main/Modal/content/pickers/Palette.tsx b/src/renderer/components/main/Modal/content/pickers/Palette.tsx index 21607b61..6dd3c6fa 100644 --- a/src/renderer/components/main/Modal/content/pickers/Palette.tsx +++ b/src/renderer/components/main/Modal/content/pickers/Palette.tsx @@ -4,6 +4,7 @@ import { buildGradient, isGradientColor, } from '@utils/color/colorUtils'; +import { ColorSwatchButton } from './ColorSwatch'; interface PaletteProps { color: string; @@ -77,12 +78,15 @@ const Palette = ({ color, onColorChange }: PaletteProps) => { export default Palette; -function Color({ color, onClick }: ColorProps) { +const Color = ({ color, onClick }: ColorProps) => { return ( - @@ -386,9 +381,22 @@ const NoteTabContent = forwardRef( } }} > -
{glowColorLabel} diff --git a/src/renderer/components/main/Modal/content/settings/TabList.tsx b/src/renderer/components/main/Modal/content/settings/TabList.tsx index 79153b0e..ee4ca08d 100644 --- a/src/renderer/components/main/Modal/content/settings/TabList.tsx +++ b/src/renderer/components/main/Modal/content/settings/TabList.tsx @@ -57,6 +57,8 @@ const TabList = ({ onClose: _onClose }: TabListProps) => { } }; + // 탭 삭제는 의도적으로 Undo 경계를 만들지 않음 — 확인창이 방어선 (1.2.x부터) + // 다른 편집의 Undo 스냅샷에는 탭이 포함돼 결합 복원됨 (1.6.0부터) — 기록 누락 버그로 오판 금지 const handleDelete = async () => { try { const result = await window.api.keys.customTabs.delete(selectedKeyType); diff --git a/src/renderer/components/overlay/WebGLTracksOGL.tsx b/src/renderer/components/overlay/WebGLTracksOGL.tsx index 2877aba9..b2b01e6b 100644 --- a/src/renderer/components/overlay/WebGLTracksOGL.tsx +++ b/src/renderer/components/overlay/WebGLTracksOGL.tsx @@ -4,7 +4,11 @@ import type { OGLRenderingContext } from 'ogl'; import { animationScheduler } from '@utils/animation/animationScheduler'; import { resolvedFadeValues } from '@src/types/settings/noteSettings'; import type { NoteSettings } from '@src/types/settings/noteSettings'; -import { MAX_NOTES } from '@stores/signals/noteBuffer'; +import { + MAX_NOTES, + resolvedGlowSize, + type TrackLayoutInput, +} from '@stores/signals/noteBuffer'; import { isMac } from '@utils/core/platform'; const vertexShader = ` @@ -99,7 +103,10 @@ const vertexShader = ` noteTopY = max(noteTopY, trackTopY); noteBottomY = min(noteBottomY, trackBottomY); - if (noteBottomY <= trackTopY || noteBottomY < 0.0) { + // noteBottomY < noteTopY: 트랙을 벗어난 역방향 완료 노트 — + // 음수 길이 쿼드 래스터라이즈와 clamp(r, 0, 음수) undefined 방지 + // strict less로 길이 0(스폰 프레임 글로우 퍼프)은 보존 + if (noteBottomY <= trackTopY || noteBottomY < 0.0 || noteBottomY < noteTopY) { gl_Position = vec4(2.0, 2.0, 2.0, 0.0); vColorTop = vec4(0.0); vColorBottom = vec4(0.0); @@ -141,8 +148,8 @@ const vertexShader = ` const fragmentShader = ` precision highp float; - uniform float uScreenHeight; - uniform float uDpr; + uniform float uCanvasBottomDomY; + uniform float uDomPerPx; uniform float uFadeTopPx; uniform float uFadeBottomPx; @@ -161,10 +168,10 @@ const fragmentShader = ` varying float vTrackBottomY; void main() { - // gl_FragCoord는 고해상도 디스플레이(DPR > 1, 예: macOS Retina)에서 물리 픽셀 단위. - // DOM 기반 트랙 좌표와 일치하도록 CSS 픽셀로 변환. - // max(uDpr, 1.0)은 uDpr이 0.0 또는 잘못된 값일 때 방어. - float currentDOMY = uScreenHeight - (gl_FragCoord.y / max(uDpr, 1.0)); + // 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); float trackRelativeY = gradientRatio; @@ -349,6 +356,71 @@ const FRAME_PACING_EPSILON_MS = 0.3; const MAX_DRIFT_FRAMES = 8; const MACOS_DPR_CAP = 1; +const resolveDpr = (): number => { + const rawDpr = window.devicePixelRatio || 1; + return isMac() ? Math.min(rawDpr, MACOS_DPR_CAP) : rawDpr; +}; + +// 캔버스 crop: 노트가 실제로 그려질 수 있는 트랙 union 영역 (DOM 좌표) +interface CropRect { + x: number; + y: number; + w: number; + h: number; +} + +// crop 적용 상태 — windowH/dpr이 바뀌면 rect가 같아도 재적용 필요 +interface AppliedCrop { + rect: CropRect; + windowH: number; + dpr: number; +} + +const CROP_AA_PAD = 2; + +const computeTrackBounds = ( + tracks: TrackLayoutInput[], + trackHeight: number, +): CropRect | null => { + if (tracks.length === 0) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + 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); + } + // 뷰포트 교집합 + floor/ceil 반올림 (1px 클리핑·떨림 방지) + const x = Math.max(0, Math.floor(minX)); + const y = Math.max(0, Math.floor(minY)); + const right = Math.min(window.innerWidth, Math.ceil(maxX)); + const bottom = Math.min(window.innerHeight, Math.ceil(maxY)); + if (right <= x || bottom <= y) return null; + return { x, y, w: right - x, h: bottom - y }; +}; + +const unionCropRect = (a: CropRect, b: CropRect): CropRect => { + const x = Math.min(a.x, b.x); + const y = Math.min(a.y, b.y); + return { + x, + y, + w: Math.max(a.x + a.w, b.x + b.w) - x, + h: Math.max(a.y + a.h, b.y + b.h) - y, + }; +}; + +const cropRectEquals = (a: CropRect | null, b: CropRect | null): boolean => { + if (a === b) return true; + if (!a || !b) return false; + return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; +}; + interface FrameClock { nextFrameTime: number; stableTime: number; @@ -383,7 +455,7 @@ interface NoteBuffer { } interface WebGLTracksOGLProps { - tracks: unknown; + tracks: TrackLayoutInput[]; notesRef: unknown; subscribe: (callback: (event: NoteEvent) => void) => () => void; noteSettings: NoteSettings; @@ -392,7 +464,7 @@ interface WebGLTracksOGLProps { } export function WebGLTracksOGL({ - tracks: _tracks, + tracks, notesRef: _notesRef, subscribe, noteSettings, @@ -416,6 +488,8 @@ export function WebGLTracksOGL({ normalizeFrameLimit(noteSettings?.frameLimit), ); const frameClockRef = useRef({ nextFrameTime: 0, stableTime: 0 }); + const cropAppliedRef = useRef(null); + const refreshCropRef = useRef<() => void>(() => {}); const subscribeRef = useRef(subscribe); useEffect(() => { subscribeRef.current = subscribe; @@ -424,11 +498,6 @@ export function WebGLTracksOGL({ useEffect(() => { const canvas = canvasRef.current; if (!canvas || !noteBuffer) return; - const macOS = isMac(); - const resolveDpr = (): number => { - const rawDpr = window.devicePixelRatio || 1; - return macOS ? Math.min(rawDpr, MACOS_DPR_CAP) : rawDpr; - }; const initialDpr = resolveDpr(); const renderer = new Renderer({ @@ -437,8 +506,9 @@ export function WebGLTracksOGL({ antialias: false, dpr: initialDpr, premultipliedAlpha: true, + // 2D 노트는 depthTest/depthWrite 모두 안 씀 — depth attachment와 매 프레임 depth clear 제거 + depth: false, }); - renderer.setSize(window.innerWidth, window.innerHeight); rendererRef.current = renderer; const { gl } = renderer; @@ -455,15 +525,7 @@ export function WebGLTracksOGL({ near: 1, far: 1000, }); - // OGL Camera.orthographic()는 `this.left || -1` 기본값 사용, `left/bottom = 0`일 때 - // updateProjectionMatrix() 호출 시 `-1`로 잘못 설정될 수 있음. - // WebGL 좌표가 DOM 픽셀과 1:1 대응하도록 명시적 직교 투영 강제. - camera.orthographic({ - left: 0, - right: window.innerWidth, - top: window.innerHeight, - bottom: 0, - }); + // 실제 직교 경계는 crop 이펙트의 applyCrop이 네 경계값을 모두 명시해 설정 camera.position.z = 5; cameraRef.current = camera; @@ -547,7 +609,8 @@ export function WebGLTracksOGL({ uTime: { value: 0 }, uFlowSpeed: { value: noteSettings.speed || 180 }, uScreenHeight: { value: window.innerHeight }, - uDpr: { value: initialDpr }, + uCanvasBottomDomY: { value: window.innerHeight }, + uDomPerPx: { value: 1 }, uTrackHeight: { value: noteSettings.trackHeight || 150 }, uReverse: { value: noteSettings.reverse ? 1.0 : 0.0 }, uFadeTopPx: { value: resolvedFadeValues(noteSettings).topPx }, @@ -561,6 +624,9 @@ export function WebGLTracksOGL({ gl.blendEquation(gl.FUNC_ADD); const mesh = new Mesh(gl, { geometry, program }); + // 노트 위치는 셰이더가 전적으로 결정 — 원점 쿼드 바운즈 기반 CPU 컬링은 + // crop 카메라(원점 미포함)에서 메시 전체를 오컬링하므로 비활성화 + mesh.frustumCulled = false; mesh.setParent(scene); const animate = (currentTime: number): void => { @@ -711,11 +777,13 @@ export function WebGLTracksOGL({ requestAnimationFrame(() => { if (!rendererRef.current) return; const { gl: context } = rendererRef.current; - context.clear( - context.COLOR_BUFFER_BIT | context.DEPTH_BUFFER_BIT, - ); + context.clear(context.COLOR_BUFFER_BIT); }); } + if (noteBuffer.activeCount === 0) { + // 버퍼가 비면 유예했던 crop 축소를 반영 + refreshCropRef.current(); + } break; default: break; @@ -724,29 +792,12 @@ export function WebGLTracksOGL({ const unsubscribe = subscribeRef.current(handleNoteEvent); + // 뷰포트/DPR 변경 시 crop 재계산 — applyCrop이 renderer/camera/uniform을 함께 갱신 const handleResize = (): void => { - const width = window.innerWidth; - const height = window.innerHeight; - const dpr = resolveDpr(); - // 서로 다른 DPR 모니터 간 이동 시 renderer/program 동기화 유지. - renderer.dpr = dpr; - renderer.setSize(width, height); - if (cameraRef.current) { - cameraRef.current.orthographic({ - left: 0, - right: width, - top: height, - bottom: 0, - }); - } - if (programRef.current) { - programRef.current.uniforms.uScreenHeight.value = height; - programRef.current.uniforms.uDpr.value = dpr; - } + refreshCropRef.current(); }; window.addEventListener('resize', handleResize); - handleResize(); if (noteBuffer.activeCount > 0 && !isAnimating.current) { resetFrameClock(frameClockRef.current); @@ -763,6 +814,7 @@ export function WebGLTracksOGL({ animationScheduler.remove(animate); } resetFrameClock(frameClock); + cropAppliedRef.current = null; geometryRef.current?.remove(); rendererRef.current?.gl ?.getExtension('WEBGL_lose_context') @@ -788,6 +840,69 @@ export function WebGLTracksOGL({ uniforms.uFadeBottomPx.value = fade.bottomPx; }, [noteSettings]); + // 트랙 union bounds로 캔버스 crop — backing 크기가 컴포지팅 비용을 결정하므로 + // 창 전체 대신 노트가 그려질 수 있는 영역만 백버퍼로 유지 + useEffect(() => { + const applyCrop = (rect: CropRect | null): void => { + const renderer = rendererRef.current; + const camera = cameraRef.current; + const program = programRef.current; + const canvas = canvasRef.current; + if (!renderer || !camera || !program || !canvas) return; + if (!rect) { + canvas.style.display = 'none'; + cropAppliedRef.current = null; + return; + } + const dpr = resolveDpr(); + const windowH = window.innerHeight; + canvas.style.display = ''; + renderer.dpr = dpr; + renderer.setSize(rect.w, rect.h); + canvas.style.left = `${rect.x}px`; + canvas.style.top = `${rect.y}px`; + // OGL Camera.orthographic은 `this.left || -1` 폴백이 있어 경계값 0이 -1로 + // 오염될 수 있음 — 네 경계값을 항상 명시적으로 전달 + camera.orthographic({ + left: rect.x, + right: rect.x + rect.w, + top: windowH - rect.y, + 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); + cropAppliedRef.current = { rect, windowH, dpr }; + }; + + const refreshCrop = (): void => { + const trackHeight = noteSettings?.trackHeight || 150; + const desired = computeTrackBounds(tracks, trackHeight); + const applied = cropAppliedRef.current; + // 노트가 살아있는 동안엔 확장만 — 축소는 버퍼가 빌 때 반영 (기존 노트 클리핑 방지) + let next = desired; + if (noteBuffer && noteBuffer.activeCount > 0 && applied) { + next = desired ? unionCropRect(applied.rect, desired) : applied.rect; + } + const sameEnv = + applied != null && + applied.windowH === window.innerHeight && + applied.dpr === resolveDpr(); + if ( + cropRectEquals(next, applied?.rect ?? null) && + (next == null || sameEnv) + ) { + return; + } + applyCrop(next); + }; + + refreshCropRef.current = refreshCrop; + refreshCrop(); + }, [tracks, noteSettings, noteBuffer]); + return ( diff --git a/src/renderer/components/overlay/counters/CountDisplay.tsx b/src/renderer/components/overlay/counters/CountDisplay.tsx index 5dfb315c..6e636d42 100644 --- a/src/renderer/components/overlay/counters/CountDisplay.tsx +++ b/src/renderer/components/overlay/counters/CountDisplay.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { toCssRgba } from '@utils/color/colorUtils'; import { COUNTER_DEFAULT_BEZIER, @@ -40,7 +40,7 @@ const CountDisplay = ({ animationScale = 1.1, animationDurationMs = 300, }: CountDisplayProps) => { - const [scale, setScale] = useState(1); + const spanRef = useRef(null); const scaleRef = useRef(1); const prevCount = useRef(count); const animationRef = useRef(null); @@ -62,6 +62,13 @@ const CountDisplay = ({ ); useEffect(() => { + // 스케일은 React 상태 대신 DOM에 직접 반영 — 애니메이션 프레임당 커밋 방지 + const applyScale = (value: number): void => { + scaleRef.current = value; + const el = spanRef.current; + if (el) el.style.transform = `scale(${value})`; + }; + if (!animationEnabled) { prevCount.current = count; if (animationRef.current) { @@ -69,8 +76,7 @@ const CountDisplay = ({ animationRef.current = null; } if (scaleRef.current !== 1) { - scaleRef.current = 1; - setScale(1); + applyScale(1); } return; } @@ -91,16 +97,14 @@ const CountDisplay = ({ const nextScale = 1 + (targetScale - 1) * (1 - easedProgress); if (Math.abs(scaleRef.current - nextScale) > 0.0005) { - scaleRef.current = nextScale; - setScale(nextScale); + applyScale(nextScale); } if (progress < 1) { animationRef.current = requestAnimationFrame(animate); } else { if (scaleRef.current !== 1) { - scaleRef.current = 1; - setScale(1); + applyScale(1); } animationRef.current = null; } @@ -130,12 +134,13 @@ const CountDisplay = ({ return ( { const maxValueRef = useRef(1); const valueSumRef = useRef(0); const valueCountRef = useRef(0); + const wasIdleRef = useRef(false); const [graphState, setGraphState] = useState(() => ({ history: createInitialHistory(graphSpeed), @@ -116,6 +117,7 @@ const OverlayGraphItem = ({ position, index = 0 }: OverlayGraphItemProps) => { avg: 0, maxval: 1, }); + wasIdleRef.current = false; }, [statType]); useEffect(() => { @@ -146,6 +148,12 @@ const OverlayGraphItem = ({ position, index = 0 }: OverlayGraphItemProps) => { while (history.length > targetSize) history.shift(); while (history.length < targetSize) history.unshift(0); + // 유휴(현재 값 0 + 히스토리 전부 0)면 이전 커밋과 동일 상태 — 재커밋 생략 + const isIdle = + currentValue === 0 && !history.some((value) => value !== 0); + if (isIdle && wasIdleRef.current) return; + wasIdleRef.current = isIdle; + setGraphState({ history: [...history], avg, diff --git a/src/renderer/components/overlay/counters/OverlayKnobItem.tsx b/src/renderer/components/overlay/counters/OverlayKnobItem.tsx index 6d625af4..9182f854 100644 --- a/src/renderer/components/overlay/counters/OverlayKnobItem.tsx +++ b/src/renderer/components/overlay/counters/OverlayKnobItem.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { useSignals } from '@preact/signals-react/runtime'; import { getAxisSignal } from '@stores/signals/axisSignals'; import { resolveImageSource } from '@utils/core/imageSource'; +import { warmupImageSource } from '@utils/core/imageWarmup'; interface KnobPosition { hidden?: boolean; @@ -95,13 +96,20 @@ const OverlayKnobItem = ({ position, index = 0 }: OverlayKnobItemProps) => { }; }, [axisId]); + const inactiveImageSrc = resolveImageSource(inactiveImage); + const activeImageSrc = resolveImageSource(activeImage); + + // 첫 회전 입력에서 active 이미지 cold decode가 겹치지 않도록 선행 디코드 (Key/StatItem과 동일 패턴) + useEffect(() => { + warmupImageSource(inactiveImageSrc); + warmupImageSource(activeImageSrc); + }, [inactiveImageSrc, activeImageSrc]); + if (!position || position.hidden) return null; // accum은 회전수 단위(물리 1회전 ≈ 1.0) — sensitivity는 순수 배율 const angle = accum * 360 * sensitivity * (reverse ? -1 : 1); - const inactiveImageSrc = resolveImageSource(inactiveImage); - const activeImageSrc = resolveImageSource(activeImage); const imageSrc = (isActive && activeImageSrc ? activeImageSrc : inactiveImageSrc) || null; const isUsingActiveImage = isActive && !!activeImageSrc; diff --git a/src/renderer/components/shared/GraphPanel.tsx b/src/renderer/components/shared/GraphPanel.tsx index 7f196475..1a0a9eed 100644 --- a/src/renderer/components/shared/GraphPanel.tsx +++ b/src/renderer/components/shared/GraphPanel.tsx @@ -31,7 +31,9 @@ interface GraphPanelProps { dataEditing?: boolean; isViewportTransforming?: boolean; onClick?: (e: React.MouseEvent) => void; + onDoubleClick?: (e: React.MouseEvent) => void; onMouseDown?: (e: React.MouseEvent) => void; + onPointerDown?: (e: React.PointerEvent) => void; onContextMenu?: (e: React.MouseEvent) => void; onDragStart?: (e: React.DragEvent) => void; } @@ -185,7 +187,9 @@ const GraphPanel = forwardRef( dataEditing, isViewportTransforming = false, onClick, + onDoubleClick, onMouseDown, + onPointerDown, onContextMenu, onDragStart, }, @@ -403,7 +407,9 @@ const GraphPanel = forwardRef( return (
( display: 'flex', flexDirection: 'column', overflow: 'hidden', - cursor: interactive ? 'pointer' : 'default', + cursor: interactive ? undefined : 'default', fontFamily: "'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', sans-serif", willChange: @@ -431,7 +437,9 @@ const GraphPanel = forwardRef( data-state="inactive" data-editing={dataEditing ? 'true' : undefined} onClick={onClick} + onDoubleClick={onDoubleClick} onMouseDown={onMouseDown} + onPointerDown={onPointerDown} onContextMenu={onContextMenu} onDragStart={onDragStart} > diff --git a/src/renderer/components/shared/Key.tsx b/src/renderer/components/shared/Key.tsx index 5406c98b..f4ad3634 100644 --- a/src/renderer/components/shared/Key.tsx +++ b/src/renderer/components/shared/Key.tsx @@ -5,6 +5,7 @@ import { getKeyCounterSignal } from '@stores/signals/keyCounterSignals'; import { useSignals } from '@preact/signals-react/runtime'; import { isMac } from '@utils/core/platform'; import { useDraggable } from '@hooks/Grid'; +import { useSelectionDrag } from '@hooks/Grid/useSelectionDrag'; import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps'; import { createDefaultCounterSettings, @@ -12,7 +13,6 @@ import { type KeyCounterSettings, } from '@src/types/key/keys'; import { useSmartGuidesElements } from '@hooks/Grid'; -import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore'; import { useSettingsStore } from '@stores/useSettingsStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { resolveImageSource } from '@utils/core/imageSource'; @@ -23,12 +23,6 @@ import { } from '@hooks/overlay/useKeyElementStyles'; import InsideCounterLayout from '@components/overlay/counters/InsideCounterLayout'; import CountDisplay from '@components/overlay/counters/CountDisplay'; -import { - calculateBounds, - calculateSnapPoints, - calculateGroupBounds, - type ElementBounds, -} from '@utils/grid/smartGuides'; // DraggableKey에서 counter가 KeyCounterSettings 타입인 확장 position interface KeyPosition extends KeyElementPosition { @@ -48,6 +42,7 @@ interface DraggableKeyProps { keyName: string; onPositionChange: (index: number, dx: number, dy: number) => void; onClick?: (e: React.MouseEvent) => void; + onDoubleClick?: (e: React.MouseEvent) => void; onCtrlClick?: (e: React.MouseEvent) => void; onShiftClick?: (e: React.MouseEvent) => void; isSelected?: boolean; @@ -85,6 +80,7 @@ const DraggableKey = React.memo( keyName, onPositionChange, onClick, + onDoubleClick, onCtrlClick, onShiftClick, isSelected = false, @@ -157,19 +153,6 @@ const DraggableKey = React.memo( (state) => state.isDraggingOrResizing, ); - const multiDragRef = useRef<{ - isDragging: boolean; - startX: number; - startY: number; - lastSnappedDeltaX: number; - lastSnappedDeltaY: number; - }>({ - isDragging: false, - startX: 0, - startY: 0, - lastSnappedDeltaX: 0, - lastSnappedDeltaY: 0, - }); const nodeRef = useRef(null); const effectiveElementId = elementId || `key-${index}`; @@ -194,219 +177,25 @@ const DraggableKey = React.memo( disabled: isSelectionMode, }); - const handleSelectionDragMouseDown = (e: React.MouseEvent) => { - if (!isSelectionMode || e.button !== 0) return; - - e.preventDefault(); - e.stopPropagation(); - - useSmartGuidesStore.getState().clearGuides(); - - useGridSelectionStore.getState().setDraggingOrResizing(true); - - onMultiDragStart?.(); - - const startDx = dx; - const startDy = dy; - const currentWidth = width || 60; - const currentHeight = height || 60; - const currentElementId = effectiveElementId; - - multiDragRef.current = { - isDragging: true, - startX: e.clientX, - startY: e.clientY, - lastSnappedDeltaX: 0, - lastSnappedDeltaY: 0, - }; - - let rafId: number | null = null; - let dragEnded = false; - const smartGuidesStore = useSmartGuidesStore.getState(); - - const handleMouseMove = (moveEvent: MouseEvent) => { - if (!multiDragRef.current.isDragging || dragEnded) return; - - if (rafId) return; - rafId = requestAnimationFrame(() => { - rafId = null; - - if (dragEnded) return; - - const currentZoom = zoom; - const rawDeltaX = - (moveEvent.clientX - multiDragRef.current.startX) / currentZoom; - const rawDeltaY = - (moveEvent.clientY - multiDragRef.current.startY) / currentZoom; - - const newX = startDx + rawDeltaX; - const newY = startDy + rawDeltaY; - - const gridSettings = useSettingsStore.getState().gridSettings; - const alignmentGuidesEnabled = - gridSettings?.alignmentGuides !== false; - const spacingGuidesEnabled = gridSettings?.spacingGuides !== false; - - const otherElements = getOtherElements(currentElementId); - - const nonSelectedElements = otherElements.filter( - (el) => !selectedElements.some((sel) => sel.id === el.id), - ); - - const draggedBounds = calculateBounds( - newX, - newY, - currentWidth, - currentHeight, - currentElementId, - ); - - let groupBounds: ElementBounds | null = null; - if (selectedElements.length > 1) { - const selectedBoundsArray = selectedElements - .map((sel) => { - if ( - sel.id === currentElementId || - (sel.type === 'key' && sel.index === index) - ) { - return draggedBounds; - } - const found = otherElements.find((el) => el.id === sel.id); - if (found) { - return calculateBounds( - found.left + rawDeltaX, - found.top + rawDeltaY, - found.width, - found.height, - found.id, - ); - } - return null; - }) - .filter(Boolean); - groupBounds = calculateGroupBounds(selectedBoundsArray); - } - - 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) { - if (selectedElements.length > 1 && groupBounds) { - const groupSnapDeltaX = snapResult.snappedX - groupBounds.left; - finalX = newX + groupSnapDeltaX; - } else { - finalX = snapResult.snappedX; - } - } else { - const snapSize = gridSettings?.gridSnapSize || 5; - finalX = Math.round(newX / snapSize) * snapSize; - } - - if (snapResult?.didSnapY) { - if (selectedElements.length > 1 && groupBounds) { - const groupSnapDeltaY = snapResult.snappedY - groupBounds.top; - finalY = newY + groupSnapDeltaY; - } else { - finalY = snapResult.snappedY; - } - } else { - const snapSize = gridSettings?.gridSnapSize || 5; - finalY = Math.round(newY / snapSize) * snapSize; - } - - const snappedDeltaX = Math.round(finalX - startDx); - const snappedDeltaY = Math.round(finalY - startDy); - - 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, - currentWidth, - currentHeight, - currentElementId, - ); - smartGuidesStore.setDraggedBounds(displayBounds); - smartGuidesStore.setActiveGuides(snapResult.guides); - - if ( - spacingGuidesEnabled && - snapResult.spacingGuides && - snapResult.spacingGuides.length > 0 - ) { - smartGuidesStore.setSpacingGuides(snapResult.spacingGuides); - } else { - smartGuidesStore.setSpacingGuides([]); - } - } else { - smartGuidesStore.clearGuides(); - } - - const moveDeltaX = - snappedDeltaX - multiDragRef.current.lastSnappedDeltaX; - const moveDeltaY = - snappedDeltaY - multiDragRef.current.lastSnappedDeltaY; - - if (moveDeltaX !== 0 || moveDeltaY !== 0) { - multiDragRef.current.lastSnappedDeltaX = snappedDeltaX; - multiDragRef.current.lastSnappedDeltaY = snappedDeltaY; - onMultiDrag?.(moveDeltaX, moveDeltaY); - } - }); - }; - - const handleMouseUp = () => { - dragEnded = true; - multiDragRef.current.isDragging = false; - - if (rafId) { - cancelAnimationFrame(rafId); - rafId = null; - } - - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - window.removeEventListener('blur', handleMouseUp); - useSmartGuidesStore.getState().clearGuides(); - useGridSelectionStore.getState().setDraggingOrResizing(false); - onMultiDragEnd?.(); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - window.addEventListener('blur', handleMouseUp); - }; + const { + handlePointerDown: handleSelectionDragPointerDown, + movedDuringPressRef, + } = useSelectionDrag({ + enabled: isSelectionMode, + zoom, + startX: dx, + startY: dy, + elementId: effectiveElementId, + elementWidth: width || 60, + elementHeight: height || 60, + elementType: 'key', + elementIndex: index, + selectedElements, + getOtherElements, + onMultiDragStart, + onMultiDrag, + onMultiDragEnd, + }); const handleClick = (e: React.MouseEvent) => { // macOS ctrl+클릭은 우클릭 제스처 — Chromium이 contextmenu 뒤에 click도 발화하므로 @@ -448,6 +237,21 @@ const DraggableKey = React.memo( } }; + // 더블클릭 편집 진입 — 순수 더블클릭만 통과. + // 두 번째 press가 다중 드래그로 이어진 경우(movedDuringPressRef)와 + // 단일 드래그(wasMoved), 수식키·지우개·뷰포트 변환 중은 제외 + const handleDoubleClick = (e: React.MouseEvent) => { + if (!onDoubleClick) return; + if (macOS && e.ctrlKey) return; + if (e.shiftKey || e.metaKey || e.ctrlKey) return; + if (activeTool === 'eraser') return; + if (isViewportTransforming) return; + if (draggable.recentPressMovedRef.current || movedDuringPressRef.current) + return; + e.stopPropagation(); + onDoubleClick(e); + }; + const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -618,7 +422,7 @@ const DraggableKey = React.memo( return (
e.preventDefault()} > diff --git a/src/renderer/components/shared/PluginElement.tsx b/src/renderer/components/shared/PluginElement.tsx index d2ab9715..fe985d58 100644 --- a/src/renderer/components/shared/PluginElement.tsx +++ b/src/renderer/components/shared/PluginElement.tsx @@ -7,24 +7,20 @@ import { DisplayElementTemplateHelpers, } from '@src/types/plugin/api'; import { useDraggable } from '@hooks/Grid'; +import { useSelectionDrag } from '@hooks/Grid/useSelectionDrag'; import { useHistoryStore } from '@stores/data/useHistoryStore'; import { useKeyStore as useKeyStoreForHistory } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useSmartGuidesElements } from '@hooks/Grid'; -import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore'; import { useSettingsStore } from '@stores/useSettingsStore'; -import { - calculateBounds, - calculateSnapPoints, - calculateGroupBounds, -} from '@utils/grid/smartGuides'; import { useGridSelectionStore, SelectedElement, isElementInMarquee, } from '@stores/grid/useGridSelectionStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useTranslation } from '@contexts/useTranslation'; import ListPopup, { ListItem } from '../main/Modal/ListPopup'; @@ -109,7 +105,7 @@ interface PluginElementProps { onMultiDragEnd?: () => void; } -export const PluginElement: React.FC = ({ +const PluginElementImpl: React.FC = ({ element, windowType, activeTool, @@ -433,19 +429,6 @@ export const PluginElement: React.FC = ({ ); // 선택 드래그 상태 - const multiDragRef = useRef<{ - isDragging: boolean; - startX: number; - startY: number; - lastSnappedDeltaX: number; - lastSnappedDeltaY: number; - }>({ - isDragging: false, - startX: 0, - startY: 0, - lastSnappedDeltaX: 0, - lastSnappedDeltaY: 0, - }); // 선택된 상태면 선택 모드 활성화 const isSelectionMode = isSelected; @@ -499,257 +482,30 @@ export const PluginElement: React.FC = ({ }); // 선택 요소 드래그 핸들러 (스마트 가이드 포함) - const handleSelectionDragMouseDown = (e: React.MouseEvent) => { - if (!isSelectionMode || e.button !== 0) return; - - e.preventDefault(); - e.stopPropagation(); - - // 드래그 시작 전 기존 스마트 가이드 클리어 (이전 드래그가 정상 종료되지 않은 경우 대비) - useSmartGuidesStore.getState().clearGuides(); - - // 드래그 시작 시 애니메이션 비활성화 - useGridSelectionStore.getState().setDraggingOrResizing(true); - - // 드래그 시작 시 히스토리 저장 - onMultiDragStart?.(); - - // 현재 요소의 시작 위치 저장 (스냅 계산용) - const startX = element.position.x; - const startY = element.position.y; - const currentWidth = - element.measuredSize?.width ?? element.estimatedSize?.width ?? 200; - const currentHeight = - element.measuredSize?.height ?? element.estimatedSize?.height ?? 150; - const elementId = element.fullId; - - multiDragRef.current = { - isDragging: true, - startX: e.clientX, - startY: e.clientY, - lastSnappedDeltaX: 0, - lastSnappedDeltaY: 0, - }; - - let rafId: number | null = null; - // 드래그 종료 플래그 (rAF 콜백에서 체크) - let dragEnded = false; - const smartGuidesStore = useSmartGuidesStore.getState(); - - const handleMouseMove = (moveEvent: MouseEvent) => { - if (!multiDragRef.current.isDragging || dragEnded) return; - - if (rafId) return; - rafId = requestAnimationFrame(() => { - rafId = null; - - // 드래그가 종료되었으면 rAF 콜백에서도 무시 - if (dragEnded) return; - - const currentZoom = zoom; - // raw delta (스냅 전) - const rawDeltaX = - (moveEvent.clientX - multiDragRef.current.startX) / currentZoom; - const rawDeltaY = - (moveEvent.clientY - multiDragRef.current.startY) / currentZoom; - - // 이동 후 예상 위치 - const newX = startX + rawDeltaX; - const newY = startY + rawDeltaY; - - // 스마트 가이드 계산 (현재 요소 기준으로 다른 비선택 요소들과 스냅) - const otherElements = getOtherElements(elementId); - - // gridSettings에서 정렬/간격 가이드 활성화 여부 확인 - const gridSettings = useSettingsStore.getState().gridSettings; - const alignmentGuidesEnabled = gridSettings?.alignmentGuides !== false; - const spacingGuidesEnabled = gridSettings?.spacingGuides !== false; - - // 선택된 다른 요소들도 제외 (자기 자신만 기준) - const nonSelectedElements = otherElements.filter( - (el) => - !selectedElements.some( - (sel) => - sel.id === el.id || - (sel.type === 'key' && el.id === `key-${sel.index}`), - ), - ); - - const draggedBounds = calculateBounds( - newX, - newY, - currentWidth, - currentHeight, - elementId, - ); - - // 다중 선택 시 그룹 전체의 bounds 계산 (캔버스 중앙 스냅용) - let groupBounds = null; - if (selectedElements.length > 1) { - // 선택된 요소들의 현재 bounds 수집 - const selectedBoundsArray = selectedElements - .map((sel) => { - // 현재 드래그 중인 요소인 경우 새 위치 사용 - if (sel.id === elementId) { - return draggedBounds; - } - // 다른 선택된 요소들은 otherElements에서 찾아서 이동량 적용 - const found = otherElements.find( - (el) => - el.id === sel.id || - (sel.type === 'key' && el.id === `key-${sel.index}`), - ); - if (found) { - return calculateBounds( - found.left + rawDeltaX, - found.top + rawDeltaY, - found.width, - found.height, - found.id, - ); - } - return null; - }) - .filter((b): b is NonNullable => b !== null); - groupBounds = calculateGroupBounds(selectedBoundsArray); - } - - // 다중 선택 시 그룹 바운딩 박스를 스냅 기준으로 사용 - 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) { - // 다중 선택 시: 그룹 바운딩 박스의 스냅 이동량을 개별 요소에 적용 - if (selectedElements.length > 1 && groupBounds) { - const groupSnapDeltaX = snapResult.snappedX - groupBounds.left; - finalX = newX + groupSnapDeltaX; - } else { - finalX = snapResult.snappedX; - } - } else { - // 그리드 스냅 - const snapSize = gridSettings?.gridSnapSize || 5; - finalX = Math.round(newX / snapSize) * snapSize; - } - - if (snapResult?.didSnapY) { - // 다중 선택 시: 그룹 바운딩 박스의 스냅 이동량을 개별 요소에 적용 - if (selectedElements.length > 1 && groupBounds) { - const groupSnapDeltaY = snapResult.snappedY - groupBounds.top; - finalY = newY + groupSnapDeltaY; - } else { - finalY = snapResult.snappedY; - } - } else { - // 그리드 스냅 - const snapSize = gridSettings?.gridSnapSize || 5; - finalY = Math.round(newY / snapSize) * snapSize; - } - - // 스냅된 delta 계산 - const snappedDeltaX = Math.round(finalX - startX); - const snappedDeltaY = Math.round(finalY - startY); - - // 가이드라인 업데이트 - 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, - currentWidth, - currentHeight, - elementId, - ); - smartGuidesStore.setDraggedBounds(displayBounds); - smartGuidesStore.setActiveGuides(snapResult.guides); - - // 간격 가이드도 업데이트 - if ( - spacingGuidesEnabled && - snapResult.spacingGuides && - snapResult.spacingGuides.length > 0 - ) { - smartGuidesStore.setSpacingGuides(snapResult.spacingGuides); - } else { - smartGuidesStore.setSpacingGuides([]); - } - } else { - smartGuidesStore.clearGuides(); - } - - // 이전 delta와의 차이만큼 이동 - const moveDeltaX = - snappedDeltaX - multiDragRef.current.lastSnappedDeltaX; - const moveDeltaY = - snappedDeltaY - multiDragRef.current.lastSnappedDeltaY; - - if (moveDeltaX !== 0 || moveDeltaY !== 0) { - multiDragRef.current.lastSnappedDeltaX = snappedDeltaX; - multiDragRef.current.lastSnappedDeltaY = snappedDeltaY; - onMultiDrag?.(moveDeltaX, moveDeltaY); - } - }); - }; - - const handleMouseUp = () => { - // 드래그 종료 플래그 설정 (rAF 콜백 무시) - dragEnded = true; - // 진행 중인 rAF 취소 - if (rafId) { - cancelAnimationFrame(rafId); - rafId = null; - } - multiDragRef.current.isDragging = false; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - // window blur 시에도 cleanup 되도록 이벤트 제거 - window.removeEventListener('blur', handleMouseUp); - // 스마트 가이드 클리어 - useSmartGuidesStore.getState().clearGuides(); - // 드래그 종료 시 애니메이션 복원 - useGridSelectionStore.getState().setDraggingOrResizing(false); - // 드래그 종료 시 오버레이 동기화 - onMultiDragEnd?.(); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - // window blur 시에도 드래그 종료 처리 (창이 포커스를 잃었을 때) - window.addEventListener('blur', handleMouseUp); - }; + const { + handlePointerDown: handleSelectionDragPointerDown, + movedDuringPressRef, + } = useSelectionDrag({ + enabled: windowType === 'main' && isSelectionMode, + zoom, + startX: element.position.x, + startY: element.position.y, + elementId: element.fullId, + elementWidth: + element.measuredSize?.width ?? element.estimatedSize?.width ?? 200, + elementHeight: + element.measuredSize?.height ?? element.estimatedSize?.height ?? 150, + elementType: 'plugin', + selectedElements, + getOtherElements, + getSelectedElementIds: (selectedElement) => + selectedElement.type === 'key' + ? [selectedElement.id, `key-${selectedElement.index}`] + : [selectedElement.id], + onMultiDragStart, + onMultiDrag, + onMultiDragEnd, + }); const { ref: draggableRef, dx: renderX, dy: renderY } = draggable; @@ -1287,12 +1043,8 @@ export const PluginElement: React.FC = ({ // 명시적인 zIndex가 있으면 사용, 없으면 키 개수 + 배열 인덱스로 계산 // 키들 뒤에 순서대로 배치되어 통합 z-order 동작 zIndex: element.zIndex ?? keyCount + arrayIndex, - cursor: - element.draggable && windowType === 'main' - ? 'move' - : element.onClick && windowType === 'main' - ? 'pointer' - : 'default', + // 커서는 dmn-grabbable 클래스가 소유 (호버 무변화, 잡는 동안만 grabbing) + cursor: windowType === 'main' ? undefined : 'default', willChange: shouldPromoteTransformLayer ? 'transform' : 'auto', pointerEvents: windowType === 'main' ? 'auto' : 'none', }; @@ -1494,6 +1246,14 @@ export const PluginElement: React.FC = ({ return; } + // 선택된 상태에서는 일반 클릭 흡수 (키와 동일 순서) — + // 다중 선택 멤버 재클릭이 선택을 단일로 축소하지 않아야 + // 더블클릭의 "멤버면 선택 보존 + 배치 편집 진입" 정책이 성립한다 + if (isSelectionMode) { + e.stopPropagation(); + return; + } + const settingsUI = definition?.settingsUI ?? 'panel'; if (windowType === 'main' && settingsUI !== 'modal') { e.stopPropagation(); @@ -1513,12 +1273,6 @@ export const PluginElement: React.FC = ({ return; } - // 선택된 상태에서는 일반 클릭 이벤트 무시 - if (isSelectionMode) { - e.stopPropagation(); - return; - } - // onClick 핸들러가 있고, 메인 윈도우에서만 if (!element.onClick || windowType !== 'main') return; @@ -1533,6 +1287,36 @@ export const PluginElement: React.FC = ({ } }; + // 더블클릭 편집 진입 — 순수 더블클릭만 통과 (드래그·수식키·지우개·뷰포트 변환 제외). + // 다중 선택의 멤버면 선택을 보존해 배치 편집으로, 아니면 이 요소만 선택. + // settingsUI가 modal인 플러그인은 클릭 선택 자체가 없으므로 제외 + const handleDoubleClick = (e: React.MouseEvent) => { + if (windowType !== 'main') return; + if ((definition?.settingsUI ?? 'panel') === 'modal') return; + if (isMac() && e.ctrlKey) return; + if (e.shiftKey || e.metaKey || e.ctrlKey) return; + if (activeTool === 'eraser') return; + if (isViewportTransforming) return; + if (draggable.recentPressMovedRef.current || movedDuringPressRef.current) + return; + e.stopPropagation(); + + const { selectedElements: currentSelection } = + useGridSelectionStore.getState(); + const isMultiMember = + currentSelection.length > 1 && + currentSelection.some((el) => el.id === element.fullId); + if (!isMultiMember) { + useGridSelectionStore.getState().selectElement({ + type: 'plugin', + id: element.fullId, + }); + } + const panel = usePropertiesPanelStore.getState(); + panel.setCanvasPanelMode('property'); + panel.setCanvasPanelOpen(true); + }; + const createActionsProxy = (elementId: string) => new Proxy( {}, @@ -1665,13 +1449,20 @@ export const PluginElement: React.FC = ({
{element.scoped && shadowRoot @@ -1697,3 +1488,7 @@ export const PluginElement: React.FC = ({ ); }; + +// 요소 하나의 갱신이 나머지 전체 리렌더로 번지지 않도록 차단 +// (스토어 update 경로는 미변경 요소의 참조를 유지하므로 shallow 비교로 스킵됨) +export const PluginElement = React.memo(PluginElementImpl); diff --git a/src/renderer/components/shared/PluginElementsRenderer.tsx b/src/renderer/components/shared/PluginElementsRenderer.tsx index d698cd3e..adf4e112 100644 --- a/src/renderer/components/shared/PluginElementsRenderer.tsx +++ b/src/renderer/components/shared/PluginElementsRenderer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { PluginElement } from './PluginElement'; @@ -77,6 +77,69 @@ export const PluginElementsRenderer: React.FC = ({ onMultiDragEnd, }) => { const elements = usePluginDisplayElementStore((state) => state.elements); + // 상위(App)가 렌더마다 새 offset 객체를 만들어도 값이 같으면 참조 유지 — + // PluginElement의 React.memo가 형제 요소 리렌더를 실제로 막도록 보장 + const stablePositionOffset = useMemo( + () => positionOffset, + // eslint-disable-next-line react-hooks/exhaustive-deps + [positionOffset.x, positionOffset.y], + ); + + // 상위(Grid)가 렌더마다 새 콜백을 만들어도 참조가 유지되도록 최신-참조 래퍼로 안정화 + // 정의 여부는 보존해 콜백 유무 분기(멀티드래그 지원 여부 등)를 바꾸지 않음 + const callbacksRef = useRef({ + onSelectionContextMenu, + onMultiDrag, + onMultiDragStart, + onMultiDragEnd, + }); + // passive effect면 commit~effect 사이에 이전 콜백이 호출될 수 있어 layout 단계에서 갱신 + useLayoutEffect(() => { + callbacksRef.current = { + onSelectionContextMenu, + onMultiDrag, + onMultiDragStart, + onMultiDragEnd, + }; + }); + const stableOnSelectionContextMenu = useMemo( + () => + onSelectionContextMenu + ? (payload: { + elementId: string; + clientX: number; + clientY: number; + referenceElement: HTMLDivElement | null; + }) => callbacksRef.current.onSelectionContextMenu?.(payload) ?? false + : undefined, + // eslint-disable-next-line react-hooks/exhaustive-deps + [onSelectionContextMenu != null], + ); + const stableOnMultiDrag = useMemo( + () => + onMultiDrag + ? (deltaX: number, deltaY: number) => + callbacksRef.current.onMultiDrag?.(deltaX, deltaY) + : undefined, + // eslint-disable-next-line react-hooks/exhaustive-deps + [onMultiDrag != null], + ); + const stableOnMultiDragStart = useMemo( + () => + onMultiDragStart + ? () => callbacksRef.current.onMultiDragStart?.() + : undefined, + // eslint-disable-next-line react-hooks/exhaustive-deps + [onMultiDragStart != null], + ); + const stableOnMultiDragEnd = useMemo( + () => + onMultiDragEnd + ? () => callbacksRef.current.onMultiDragEnd?.() + : undefined, + // eslint-disable-next-line react-hooks/exhaustive-deps + [onMultiDragEnd != null], + ); const setElements = usePluginDisplayElementStore( (state) => state.setElements, ); @@ -197,7 +260,7 @@ export const PluginElementsRenderer: React.FC = ({ element={element} windowType={windowType} activeTool={activeTool} - positionOffset={positionOffset} + positionOffset={stablePositionOffset} zoom={zoom} panX={panX} panY={panY} @@ -208,10 +271,10 @@ export const PluginElementsRenderer: React.FC = ({ (sel) => sel.type === 'plugin' && sel.id === element.fullId, )} selectedElements={selectedElements} - onSelectionContextMenu={onSelectionContextMenu} - onMultiDrag={onMultiDrag} - onMultiDragStart={onMultiDragStart} - onMultiDragEnd={onMultiDragEnd} + onSelectionContextMenu={stableOnSelectionContextMenu} + onMultiDrag={stableOnMultiDrag} + onMultiDragStart={stableOnMultiDragStart} + onMultiDragEnd={stableOnMultiDragEnd} /> ))} diff --git a/src/renderer/hooks/Grid/dragSession.ts b/src/renderer/hooks/Grid/dragSession.ts new file mode 100644 index 00000000..59180caa --- /dev/null +++ b/src/renderer/hooks/Grid/dragSession.ts @@ -0,0 +1,14 @@ +// 그리드 전역 드래그 세션 소유권 — 훅 인스턴스·종류를 넘어 동시 1세션만 허용. +// 터치 입력에서 두 포인터가 서로 다른 요소를 잡아 같은 선택 집합을 +// 이중으로 끌고 히스토리를 중복 저장하는 것을 차단한다 +let activeDragSession = false; + +export const tryAcquireDragSession = (): boolean => { + if (activeDragSession) return false; + activeDragSession = true; + return true; +}; + +export const releaseDragSession = (): void => { + activeDragSession = false; +}; diff --git a/src/renderer/hooks/Grid/useDraggable.test.tsx b/src/renderer/hooks/Grid/useDraggable.test.tsx new file mode 100644 index 00000000..c1548b41 --- /dev/null +++ b/src/renderer/hooks/Grid/useDraggable.test.tsx @@ -0,0 +1,340 @@ +/* eslint-disable react-hooks/refs -- callback ref 반환 계약 테스트 */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest'; +import { useDraggable } from './useDraggable'; +import { releaseDragSession } from './dragSession'; + +const { clearGuides, setDraggingOrResizing } = vi.hoisted(() => ({ + clearGuides: vi.fn(), + setDraggingOrResizing: vi.fn(), +})); + +vi.mock('@stores/grid/useSmartGuidesStore', () => ({ + useSmartGuidesStore: { + getState: () => ({ + clearGuides, + setDraggedBounds: vi.fn(), + setActiveGuides: vi.fn(), + setSpacingGuides: vi.fn(), + }), + }, +})); + +vi.mock('@stores/grid/useGridSelectionStore', () => ({ + useGridSelectionStore: { + getState: () => ({ + isMiddleButtonDragging: false, + setDraggingOrResizing, + }), + }, +})); + +vi.mock('@stores/useSettingsStore', () => ({ + useSettingsStore: { + getState: () => ({ + gridSettings: { + gridSnapSize: 5, + alignmentGuides: false, + spacingGuides: false, + }, + }), + }, +})); + +interface HarnessProps { + activeTool?: 'select' | 'eraser'; + onClick: () => void; + onDoubleClick: () => void; + onPositionChange: (x: number, y: number) => void; +} + +const Harness = ({ + activeTool = 'select', + onClick, + onDoubleClick, + onPositionChange, +}: HarnessProps) => { + const draggable = useDraggable({ + initialX: 0, + initialY: 0, + onPositionChange, + }); + + return ( +
{ + if (!draggable.wasMoved) onClick(); + }} + onDoubleClick={(event) => { + if (event.shiftKey || event.metaKey || event.ctrlKey) return; + if (activeTool === 'eraser' || draggable.recentPressMovedRef.current) + return; + onDoubleClick(); + }} + /> + ); +}; + +describe('useDraggable pointer contract', () => { + let host: HTMLDivElement; + let root: Root; + let rafCallbacks: Map; + let nextRafId: number; + let onClick: Mock<() => void>; + let onDoubleClick: Mock<() => void>; + let onPositionChange: Mock<(x: number, y: number) => void>; + + const renderHarness = async (activeTool: 'select' | 'eraser' = 'select') => { + await act(async () => { + root.render( + , + ); + }); + return host.querySelector('[data-testid="draggable"]')!; + }; + + const dispatchPointer = ( + target: Element, + type: string, + init: PointerEventInit = {}, + ) => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: 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(); + onDoubleClick = vi.fn(); + onPositionChange = vi.fn(); + clearGuides.mockClear(); + setDraggingOrResizing.mockClear(); + document.body.style.cursor = ''; + releaseDragSession(); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('treats a stationary press as one click', async () => { + const element = await renderHarness(); + + await act(async () => { + dispatchPointer(element, 'pointerdown'); + dispatchPointer(element, 'pointerup'); + element.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onPositionChange).not.toHaveBeenCalled(); + }); + + it('allows only a stationary unmodified select-mode double click', async () => { + const element = await renderHarness(); + + await act(async () => { + element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + element.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, shiftKey: true }), + ); + element.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, metaKey: true }), + ); + element.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, ctrlKey: true }), + ); + }); + expect(onDoubleClick).toHaveBeenCalledTimes(1); + + await renderHarness('eraser'); + await act(async () => { + element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + expect(onDoubleClick).toHaveBeenCalledTimes(1); + + await renderHarness(); + await act(async () => { + dispatchPointer(element, 'pointerdown'); + dispatchPointer(element, 'pointermove', { clientX: 10 }); + flushRaf(); + dispatchPointer(element, 'pointerup', { clientX: 10 }); + }); + await act(async () => { + element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + expect(onDoubleClick).toHaveBeenCalledTimes(1); + }); + + it('keeps movement under 5px eligible for click', async () => { + const element = await renderHarness(); + + await act(async () => { + dispatchPointer(element, 'pointerdown'); + dispatchPointer(element, 'pointermove', { clientX: 4 }); + dispatchPointer(element, 'pointerup', { clientX: 4 }); + element.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onPositionChange).not.toHaveBeenCalled(); + }); + + it('commits one drag over 5px and suppresses the following click', async () => { + const element = await renderHarness(); + + await act(async () => { + dispatchPointer(element, 'pointerdown'); + dispatchPointer(element, 'pointermove', { clientX: 11 }); + flushRaf(); + dispatchPointer(element, 'pointerup', { clientX: 11 }); + }); + await act(async () => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onPositionChange).toHaveBeenCalledTimes(1); + expect(onPositionChange).toHaveBeenCalledWith(10, 0); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('ignores an additional press while the active pointer owns the session', async () => { + const element = await renderHarness(); + const setPointerCapture = vi.spyOn(element, 'setPointerCapture'); + + await act(async () => { + dispatchPointer(element, 'pointerdown', { pointerId: 1 }); + dispatchPointer(element, 'pointerdown', { pointerId: 2 }); + dispatchPointer(element, 'pointermove', { pointerId: 1, clientX: 11 }); + flushRaf(); + dispatchPointer(element, 'pointerup', { pointerId: 1, clientX: 11 }); + }); + + expect(setPointerCapture).toHaveBeenCalledTimes(1); + expect(onPositionChange).toHaveBeenCalledTimes(1); + }); + + it('ends on blur and restores both element and body cursors', async () => { + const element = await renderHarness(); + + await act(async () => { + dispatchPointer(element, 'pointerdown'); + dispatchPointer(element, 'pointermove', { clientX: 11 }); + flushRaf(); + }); + expect(element.style.cursor).toBe('grabbing'); + expect(document.body.style.cursor).toBe('grabbing'); + + await act(async () => window.dispatchEvent(new Event('blur'))); + + expect(element.style.cursor).toBe(''); + expect(document.body.style.cursor).toBe(''); + expect(onPositionChange).toHaveBeenCalledTimes(1); + }); + + it('ignores non-primary pointers', async () => { + const element = await renderHarness(); + + await act(async () => { + dispatchPointer(element, 'pointerdown', { isPrimary: false }); + }); + + expect(element.style.cursor).toBe(''); + expect(setDraggingOrResizing).not.toHaveBeenCalled(); + }); + + it('blocks double click when the first press of the pair dragged', async () => { + const element = await renderHarness(); + + // 첫 press: 드래그 후 제자리 복귀 → 두 번째 press: 정지 → dblclick + await act(async () => { + dispatchPointer(element, 'pointerdown', { clientX: 0, clientY: 0 }); + dispatchPointer(element, 'pointermove', { clientX: 10, clientY: 0 }); + flushRaf(); + dispatchPointer(element, 'pointermove', { clientX: 0, clientY: 0 }); + flushRaf(); + dispatchPointer(element, 'pointerup', { clientX: 0, clientY: 0 }); + dispatchPointer(element, 'pointerdown', { clientX: 0, clientY: 0 }); + dispatchPointer(element, 'pointerup', { clientX: 0, clientY: 0 }); + }); + element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + + expect(onDoubleClick).not.toHaveBeenCalled(); + + // 그 다음의 정지 press 쌍에서는 다시 허용 + await act(async () => { + dispatchPointer(element, 'pointerdown'); + dispatchPointer(element, 'pointerup'); + dispatchPointer(element, 'pointerdown'); + dispatchPointer(element, 'pointerup'); + }); + element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + + expect(onDoubleClick).toHaveBeenCalledTimes(1); + }); + + it('runs drag completion once across duplicate terminal signals', async () => { + const element = await renderHarness(); + + await act(async () => { + dispatchPointer(element, 'pointerdown'); + dispatchPointer(element, 'pointermove', { clientX: 11 }); + flushRaf(); + dispatchPointer(element, 'pointerup', { clientX: 11 }); + dispatchPointer(element, 'lostpointercapture'); + window.dispatchEvent(new Event('blur')); + }); + + expect(onPositionChange).toHaveBeenCalledTimes(1); + expect(setDraggingOrResizing).toHaveBeenLastCalledWith(false); + expect( + setDraggingOrResizing.mock.calls.filter(([value]) => value === false), + ).toHaveLength(1); + }); +}); diff --git a/src/renderer/hooks/Grid/useDraggable.ts b/src/renderer/hooks/Grid/useDraggable.ts index 34253e2b..0836572f 100644 --- a/src/renderer/hooks/Grid/useDraggable.ts +++ b/src/renderer/hooks/Grid/useDraggable.ts @@ -1,5 +1,4 @@ -/* eslint-disable react-hooks/immutability */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, type RefObject } from 'react'; import { MIN_GRID_POSITION, MAX_GRID_POSITION, @@ -9,6 +8,7 @@ import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useSettingsStore } from '@stores/useSettingsStore'; import { calculateBounds, calculateSnapPoints } from '@utils/grid/smartGuides'; import { DRAG_THRESHOLD } from './constants'; +import { tryAcquireDragSession, releaseDragSession } from './dragSession'; interface ElementBounds { id: string; @@ -44,6 +44,8 @@ interface UseDraggableReturn { dy: number; wasMoved: boolean; isDragging: boolean; + /** 이번 또는 직전 press에서 실이동 발생 — dblclick 편집 진입 가드용 */ + recentPressMovedRef: RefObject; } // 위치 클램핑 함수 @@ -109,6 +111,15 @@ export const useDraggable = ({ >(getOtherElements); const disabledRef = useRef(disabled); const previousBodyCursorRef = useRef(null); + // 진행 중 드래그 세션 존재 여부 — 트랙패드 이중 press가 세션을 겹쳐 시작하면 + // 먼저 끝난 세션의 정리 코드가 커서·리스너를 지워 남은 세션이 오염됨 + const activeDragRef = useRef(false); + const activePointerIdRef = useRef(null); + const activeDragCleanupRef = useRef<(() => void) | null>(null); + // dblclick은 두 press의 합성 — 직전 press의 이동까지 기억해야 + // 드래그(제자리 복귀 포함) 직후의 빠른 재클릭이 편집 진입으로 새지 않음 + const movedThisPressRef = useRef(false); + const recentPressMovedRef = useRef(false); useEffect(() => { zoomRef.current = zoom; @@ -126,6 +137,7 @@ export const useDraggable = ({ // initialX, initialY 변경 시 동기화 useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- 외부 위치 변경 동기화 setOffset({ dx: initialX, dy: initialY }); lastSnappedRef.current = { dx: initialX, dy: initialY }; }, [initialX, initialY]); @@ -149,30 +161,30 @@ export const useDraggable = ({ document.body.style.cursor = cursor; }; - const handleMouseOver = () => { - // 미들 버튼 드래그 중이면 커서 변경하지 않음 - if (useGridSelectionStore.getState().isMiddleButtonDragging) return; - if (node && !isDragging) node.style.cursor = 'grab'; - }; - - const handleMouseOut = () => { - // 미들 버튼 드래그 중이면 커서 변경하지 않음 - if (useGridSelectionStore.getState().isMiddleButtonDragging) return; - if (node && !isDragging) node.style.cursor = ''; - }; - - const handleMouseDown = (e: MouseEvent) => { + const handlePointerDown = (e: PointerEvent) => { if (!node) return; + const dragTarget = e.currentTarget as HTMLElement; // 좌클릭만 처리 (미들 버튼은 그리드 팬에 사용) if (e.button !== 0) return; + // primary 포인터만 — 터치의 두 번째 손가락 등 비주 포인터는 드래그 시작 금지 + if (!e.isPrimary) return; + // disabled 상태면 드래그 무시 if (disabledRef.current) return; // 미들 버튼 드래그 중이면 요소 드래그 무시 (그리드 팬 우선) if (useGridSelectionStore.getState().isMiddleButtonDragging) return; + // 세션 재진입 가드 — 드래그 중 추가 press(트랙패드 이중 탭 등)는 무시. + // 전역 소유권까지 획득해야 다른 요소 인스턴스와의 동시 세션도 차단됨 + if (activeDragRef.current) return; + if (!tryAcquireDragSession()) return; + activeDragRef.current = true; + activePointerIdRef.current = e.pointerId; + dragTarget.setPointerCapture(e.pointerId); + // 드래그 시작 전 기존 스마트 가이드 클리어 (이전 드래그가 정상 종료되지 않은 경우 대비) useSmartGuidesStore.getState().clearGuides(); @@ -183,6 +195,12 @@ export const useDraggable = ({ setIsDragging(true); setWasMoved(false); + recentPressMovedRef.current = movedThisPressRef.current; + movedThisPressRef.current = false; + + // 잡는 동안만 grabbing — 호버 커서 변경 없음. WKWebView가 hover 중 + // CSS 커서 갱신을 놓치는 문제로 CSS :hover/:active 대신 JS 인라인 유지 + dragTarget.style.cursor = 'grabbing'; // 현재 줌/팬 값 캡처 const currentZoom = zoomRef.current; @@ -211,9 +229,10 @@ export const useDraggable = ({ // 스마트 가이드 스토어 참조 const smartGuidesStore = useSmartGuidesStore.getState(); - const handleMouseMove = (moveEvent: MouseEvent) => { + const handlePointerMove = (moveEvent: PointerEvent) => { // 드래그가 종료되었으면 무시 - if (dragEnded) return; + if (dragEnded || moveEvent.pointerId !== activePointerIdRef.current) + return; // 드래그 임계값 체크 const deltaX = Math.abs(moveEvent.clientX - startClientX); @@ -224,11 +243,9 @@ export const useDraggable = ({ (deltaX > dragThresholdRef.current || deltaY > dragThresholdRef.current) ) { actuallyDragging = true; - node.style.cursor = 'grabbing'; setBodyCursor('grabbing'); // 실제 드래그가 시작될 때만 최적화 적용 - node.style.pointerEvents = 'none'; - node.style.userSelect = 'none'; + dragTarget.style.userSelect = 'none'; // 드래그 시작 시 애니메이션 비활성화 useGridSelectionStore.getState().setDraggingOrResizing(true); // 드래그 시작 콜백 호출 (히스토리 저장용) @@ -368,6 +385,8 @@ export const useDraggable = ({ snappedY !== initialPosition.dy ) { setWasMoved(true); + movedThisPressRef.current = true; + recentPressMovedRef.current = true; } lastSnappedRef.current = { dx: snappedX, dy: snappedY }; @@ -375,9 +394,19 @@ export const useDraggable = ({ }); }; - const handleMouseUp = () => { + const finishDrag = () => { + if (dragEnded) return; + // 드래그 종료 플래그 설정 (pending rAF 콜백이 실행되지 않도록) dragEnded = true; + const pointerId = activePointerIdRef.current; + activeDragRef.current = false; + activePointerIdRef.current = null; + activeDragCleanupRef.current = null; + releaseDragSession(); + if (pointerId !== null && dragTarget.hasPointerCapture(pointerId)) { + dragTarget.releasePointerCapture(pointerId); + } restoreBodyCursor(); // pending rAF가 있으면 취소 @@ -386,9 +415,11 @@ export const useDraggable = ({ rafId = null; } - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - window.removeEventListener('blur', handleMouseUp); + dragTarget.removeEventListener('pointermove', handlePointerMove); + dragTarget.removeEventListener('pointerup', handlePointerEnd); + dragTarget.removeEventListener('pointercancel', handlePointerEnd); + dragTarget.removeEventListener('lostpointercapture', finishDrag); + window.removeEventListener('blur', finishDrag); setIsDragging(false); @@ -397,9 +428,8 @@ export const useDraggable = ({ // 실제 드래그가 발생했을 때만 복구 if (actuallyDragging) { - node.style.cursor = 'grab'; - node.style.pointerEvents = 'auto'; - node.style.userSelect = 'auto'; + dragTarget.style.cursor = ''; + dragTarget.style.userSelect = 'auto'; // 드래그 종료 시 애니메이션 복원 useGridSelectionStore.getState().setDraggingOrResizing(false); @@ -408,36 +438,41 @@ export const useDraggable = ({ onPositionChange?.(finalDx, finalDy); } else { // 클릭만 했을 경우 커서만 복구 - node.style.cursor = 'grab'; + dragTarget.style.cursor = ''; } }; - document.addEventListener('mousemove', handleMouseMove, { + const handlePointerEnd = (endEvent: PointerEvent) => { + if (endEvent.pointerId !== activePointerIdRef.current) return; + finishDrag(); + }; + + activeDragCleanupRef.current = finishDrag; + dragTarget.addEventListener('pointermove', handlePointerMove, { passive: true, }); - document.addEventListener('mouseup', handleMouseUp); - window.addEventListener('blur', handleMouseUp); + dragTarget.addEventListener('pointerup', handlePointerEnd); + dragTarget.addEventListener('pointercancel', handlePointerEnd); + dragTarget.addEventListener('lostpointercapture', finishDrag); + window.addEventListener('blur', finishDrag); }; useEffect(() => { if (!node) return; - node.addEventListener('mousedown', handleMouseDown); - node.addEventListener('mouseenter', handleMouseOver); - node.addEventListener('mouseleave', handleMouseOut); + node.addEventListener('pointerdown', handlePointerDown); return () => { - node.removeEventListener('mousedown', handleMouseDown); - node.removeEventListener('mouseenter', handleMouseOver); - node.removeEventListener('mouseleave', handleMouseOut); + node.removeEventListener('pointerdown', handlePointerDown); }; }); useEffect(() => { return () => { + activeDragCleanupRef.current?.(); restoreBodyCursor(); }; - }); + }, []); - return { ref, dx, dy, wasMoved, isDragging }; + return { ref, dx, dy, wasMoved, isDragging, recentPressMovedRef }; }; diff --git a/src/renderer/hooks/Grid/useSelectionDrag.test.tsx b/src/renderer/hooks/Grid/useSelectionDrag.test.tsx new file mode 100644 index 00000000..d4cd9ce4 --- /dev/null +++ b/src/renderer/hooks/Grid/useSelectionDrag.test.tsx @@ -0,0 +1,369 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest'; +import { useSelectionDrag } from './useSelectionDrag'; +import { releaseDragSession } from './dragSession'; +import { calculateGroupBounds } from '@utils/grid/smartGuides'; + +const { clearGuides, setDraggingOrResizing } = vi.hoisted(() => ({ + clearGuides: vi.fn(), + setDraggingOrResizing: vi.fn(), +})); + +vi.mock('@stores/grid/useSmartGuidesStore', () => ({ + useSmartGuidesStore: { + getState: () => ({ + clearGuides, + setDraggedBounds: vi.fn(), + setActiveGuides: vi.fn(), + setSpacingGuides: vi.fn(), + }), + }, +})); + +vi.mock('@stores/grid/useGridSelectionStore', () => ({ + useGridSelectionStore: { + getState: () => ({ setDraggingOrResizing }), + }, +})); + +vi.mock('@stores/useSettingsStore', () => ({ + useSettingsStore: { + getState: () => ({ + gridSettings: { + gridSnapSize: 5, + alignmentGuides: false, + spacingGuides: false, + }, + }), + }, +})); + +// 그룹 bounds 수집 회귀 검증용 스파이 — 나머지는 실구현 유지 +vi.mock('@utils/grid/smartGuides', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@utils/grid/smartGuides') + >(); + return { + ...actual, + calculateGroupBounds: vi.fn(actual.calculateGroupBounds), + }; +}); + +interface HarnessProps { + onClick: () => void; + onMovedCheck: (moved: boolean) => void; + onMultiDragStart: () => void; + onMultiDrag: (dx: number, dy: number) => void; + onMultiDragEnd: () => void; +} + +const Harness = ({ + onClick, + onMovedCheck, + onMultiDragStart, + onMultiDrag, + onMultiDragEnd, +}: HarnessProps) => { + const { handlePointerDown, movedDuringPressRef } = useSelectionDrag({ + enabled: true, + zoom: 1, + startX: 0, + startY: 0, + elementId: 'key-0', + elementWidth: 60, + elementHeight: 60, + elementType: 'key', + elementIndex: 0, + selectedElements: [{ id: 'key-0', type: 'key', index: 0 }], + getOtherElements: () => [], + onMultiDragStart, + onMultiDrag, + onMultiDragEnd, + }); + + return ( +
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
; +}; + +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('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..4d0dbabb --- /dev/null +++ b/src/renderer/hooks/Grid/useSelectionDrag.ts @@ -0,0 +1,295 @@ +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; + 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 selectedIds = new Set( + selectedElements.flatMap((element) => getSelectedElementIds(element)), + ); + 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 selectedIds = getSelectedElementIds(selectedElement); + const found = otherElements.find((element) => + selectedIds.includes(element.id), + ); + 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>(new Map()); const flowSpeedRef = useRef(DEFAULT_NOTE_SETTINGS.speed); const trackHeightRef = useRef(DEFAULT_NOTE_SETTINGS.trackHeight); + const frameLimitRef = useRef(0); + // 수명 계산에 쓰이는 스칼라만 추적 — 설정 객체 identity 변경만으로 재스케줄 방지 + const prevCleanupScalarsRef = useRef(''); // 딜레이 기반 단노트 분리용 설정 const delayEnabledRef = useRef(false); const delayMsRef = useRef(0); @@ -141,12 +149,23 @@ export function useNoteSystem({ return () => subscribers.current.delete(callback); }; + // 프레임 제한이 낮을수록 uTime 지연이 커지므로 슬랙을 limiter interval까지 확장 + const cleanupSlackPx = (flowSpeed: number): number => { + const frameLimit = frameLimitRef.current; + const slackMs = Math.max( + NOTE_CLEANUP_SLACK_MS, + frameLimit > 0 ? 1000 / frameLimit : 0, + ); + return (flowSpeed * slackMs) / 1000; + }; + // In-place 클린업 함수 const runCleanup = (): void => { const currentTime = performance.now(); const flowSpeed = flowSpeedRef.current; const trackHeight = trackHeightRef.current || DEFAULT_NOTE_SETTINGS.trackHeight; + const keepDistancePx = trackHeight + cleanupSlackPx(flowSpeed); const currentNotes = notesRef.current; const removedNoteIds: string[] = []; const removedNotes: Note[] = []; @@ -171,7 +190,7 @@ export function useNoteSystem({ // 완료된 노트가 화면 밖으로 나갔는지 확인 const timeSinceCompletion = currentTime - (note.endTime as number); const yPosition = (timeSinceCompletion * flowSpeed) / 1000; - shouldKeep = yPosition < trackHeight + 200; + shouldKeep = yPosition < keepDistancePx; if (!shouldKeep) { const removedId = note.id; @@ -237,7 +256,7 @@ export function useNoteSystem({ for (const note of keyNotes) { if (!note.isActive && note.endTime != null) { // 이 노트가 화면 밖으로 나갈 시간 계산 - const travelTimeMs = ((trackHeight + 200) * 1000) / flowSpeed; + const travelTimeMs = (keepDistancePx * 1000) / flowSpeed; const cleanupTime = note.endTime + travelTimeMs; if (cleanupTime < earliestCleanupTime) { earliestCleanupTime = cleanupTime; @@ -263,7 +282,8 @@ export function useNoteSystem({ trackHeightRef.current || DEFAULT_NOTE_SETTINGS.trackHeight; // 이 노트가 화면 밖으로 완전히 사라질 시간 계산 - const travelTimeMs = ((trackHeight + 200) * 1000) / flowSpeed; + const travelTimeMs = + ((trackHeight + cleanupSlackPx(flowSpeed)) * 1000) / flowSpeed; const newCleanupTime = finalizedNote.endTime + travelTimeMs; // 현재 예약된 것보다 더 빨리 실행해야 하는 경우에만 재스케줄 @@ -290,7 +310,20 @@ export function useNoteSystem({ // 단노트 최소 픽셀 길이 shortNoteMinLengthPxRef.current = Number(settings?.shortNoteMinLengthPx) || 0; - }, [noteSettings]); + frameLimitRef.current = Number(settings?.frameLimit) || 0; + // speed/trackHeight/frameLimit 실제 변경 시에만 기존 타이머를 새 수명 기준으로 재계산 + const cleanupScalars = `${flowSpeedRef.current}/${trackHeightRef.current}/${frameLimitRef.current}`; + if ( + prevCleanupScalarsRef.current !== cleanupScalars && + cleanupTimerRef.current !== null + ) { + clearTimeout(cleanupTimerRef.current); + cleanupTimerRef.current = null; + nextCleanupTimeRef.current = Infinity; + runCleanup(); + } + prevCleanupScalarsRef.current = cleanupScalars; + }, [noteSettings]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { noteEffectEnabled.current = !!noteEffect; diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index f6e17eb8..7dbf1035 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -596,9 +596,6 @@ "soundTrimModal": { "defaultTitle": "Add Sound", "editTitle": "Edit Sound", - "statusDecoding": "Decoding...", - "statusWaiting": "Waiting for file", - "statusReady": "Ready", "nameLabel": "Sound name", "namePlaceholder": "Sound name", "decodeError": "Unable to decode the audio file.", @@ -609,7 +606,6 @@ "saving": "Saving...", "submit": "Add", "submitEdit": "Save", - "cancel": "Cancel", "statusLoading": "Loading...", "loadOriginalError": "Unable to load the original audio.", "saveErrorDefault": "Failed to save the processed sound.", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 0c0d1032..3f055d2d 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -596,9 +596,6 @@ "soundTrimModal": { "defaultTitle": "사운드 추가", "editTitle": "사운드 편집", - "statusDecoding": "해석 중...", - "statusWaiting": "파일 대기", - "statusReady": "사용 가능", "nameLabel": "사운드 이름", "namePlaceholder": "사운드 이름", "decodeError": "오디오 파일을 해석할 수 없습니다.", @@ -609,7 +606,6 @@ "saving": "저장 중...", "submit": "추가하기", "submitEdit": "저장", - "cancel": "취소", "statusLoading": "불러오는 중...", "loadOriginalError": "원본 오디오를 불러올 수 없습니다.", "saveErrorDefault": "편집된 사운드를 저장하지 못했습니다.", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 02c24e3c..05f1bc1e 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -596,9 +596,6 @@ "soundTrimModal": { "defaultTitle": "Добавить звук", "editTitle": "Редактирование звука", - "statusDecoding": "Декодирование...", - "statusWaiting": "Ожидание файла", - "statusReady": "Готово", "nameLabel": "Название звука", "namePlaceholder": "Название звука", "decodeError": "Не удалось декодировать аудиофайл.", @@ -609,7 +606,6 @@ "saving": "Сохранение...", "submit": "Добавить", "submitEdit": "Сохранить", - "cancel": "Отмена", "statusLoading": "Загрузка...", "loadOriginalError": "Не удалось загрузить исходное аудио.", "saveErrorDefault": "Не удалось сохранить обработанный звук.", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index faf3a1f5..487c210c 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -596,9 +596,6 @@ "soundTrimModal": { "defaultTitle": "新增音效", "editTitle": "編輯音效", - "statusDecoding": "解析中...", - "statusWaiting": "等待檔案", - "statusReady": "可使用", "nameLabel": "音效名稱", "namePlaceholder": "音效名稱", "decodeError": "無法解析音訊檔案。", @@ -609,7 +606,6 @@ "saving": "儲存中...", "submit": "新增", "submitEdit": "儲存", - "cancel": "取消", "statusLoading": "載入中...", "loadOriginalError": "無法載入原始音訊。", "saveErrorDefault": "無法儲存處理後的音效。", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index b4877a0a..5f776fc4 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -596,9 +596,6 @@ "soundTrimModal": { "defaultTitle": "添加音频", "editTitle": "编辑音频", - "statusDecoding": "解析中...", - "statusWaiting": "等待文件", - "statusReady": "可用", "nameLabel": "音频名称", "namePlaceholder": "音频名称", "decodeError": "无法解析音频文件。", @@ -609,7 +606,6 @@ "saving": "保存中...", "submit": "添加", "submitEdit": "保存", - "cancel": "取消", "statusLoading": "加载中...", "loadOriginalError": "无法加载原始音频。", "saveErrorDefault": "无法保存处理后的音频。", diff --git a/src/renderer/plugins/runtime/api/defineElement.ts b/src/renderer/plugins/runtime/api/defineElement.ts index 04844f4c..f962b28f 100644 --- a/src/renderer/plugins/runtime/api/defineElement.ts +++ b/src/renderer/plugins/runtime/api/defineElement.ts @@ -463,7 +463,10 @@ export const createDefineElement = (deps: DefineElementDependencies) => { referenceElement: target as HTMLElement, onColorChange: (newColor) => { // 스와치(버튼 자체) 미리보기 업데이트 - target.style.backgroundColor = newColor; + target.style.setProperty( + '--dmn-color-swatch-color', + newColor, + ); }, onColorChangeComplete: (newColor) => { wrappedChange(newColor); @@ -482,10 +485,15 @@ export const createDefineElement = (deps: DefineElementDependencies) => { // 패널 ColorInput과 동일한 스와치 단독 버튼 componentHtml = ` + > + + + + + `; } else if (schema.type === 'string' || schema.type === 'number') { const strVal = String(value); diff --git a/src/renderer/plugins/runtime/api/defineSettings.ts b/src/renderer/plugins/runtime/api/defineSettings.ts index 56f59476..899eedba 100644 --- a/src/renderer/plugins/runtime/api/defineSettings.ts +++ b/src/renderer/plugins/runtime/api/defineSettings.ts @@ -317,7 +317,10 @@ export const createDefineSettings = (deps: DefineSettingsDependencies) => { referenceElement: target as HTMLElement, onColorChange: (newColor) => { // 스와치(버튼 자체) 미리보기 업데이트 - target.style.backgroundColor = newColor; + target.style.setProperty( + '--dmn-color-swatch-color', + newColor, + ); }, onColorChangeComplete: (newColor) => { commitSettingValue(key, newColor); @@ -336,10 +339,15 @@ export const createDefineSettings = (deps: DefineSettingsDependencies) => { // 패널 ColorInput과 동일한 스와치 단독 버튼 componentHtml = ` + > + + + + + `; } else if (schema.type === 'string' || schema.type === 'number') { const strVal = String(value); diff --git a/src/renderer/stores/grid/useGridSelectionStore.ts b/src/renderer/stores/grid/useGridSelectionStore.ts index ea455877..428a097b 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.ts @@ -197,7 +197,24 @@ export const useGridSelectionStore = create((set, get) => ({ }, setSelectedElements: (elements) => { - set({ selectedElements: elements, selectedGroupIds: [] }); + set((state) => { + // 논리적으로 같은 선택이면 참조를 유지해 구독자 리렌더 방지 + const same = + state.selectedElements.length === elements.length && + state.selectedElements.every((el, i) => { + const next = elements[i]; + return ( + el.type === next.type && + el.id === next.id && + el.index === next.index + ); + }); + if (same && state.selectedGroupIds.length === 0) return {}; + return { + selectedElements: same ? state.selectedElements : elements, + selectedGroupIds: [], + }; + }); }, setFullSelection: (elements, groupIds) => { diff --git a/src/renderer/stores/signals/noteBuffer.ts b/src/renderer/stores/signals/noteBuffer.ts index bcd14a39..e72aee16 100644 --- a/src/renderer/stores/signals/noteBuffer.ts +++ b/src/renderer/stores/signals/noteBuffer.ts @@ -106,6 +106,14 @@ type ResolvedTrackLayout = TrackLayoutInput & { const clampPercentToUnit = (value: number) => Math.min(Math.max(value / 100, 0), 1); +// 트랙의 실효 글로우 크기 — 캔버스 crop bounds 계산에서도 동일 규칙 사용 +export const resolvedGlowSize = ( + layout: Pick, +): number => { + if (!(layout.noteGlowEnabled ?? false)) return 0; + return Math.min(Math.max(layout.noteGlowSize ?? 20, 0), 50); +}; + const resolveTrackLayout = (layout: TrackLayoutInput): ResolvedTrackLayout => { const baseOpacityPercent = layout.noteOpacity != null && Number.isFinite(layout.noteOpacity) @@ -122,8 +130,7 @@ const resolveTrackLayout = (layout: TrackLayoutInput): ResolvedTrackLayout => { : baseOpacityPercent; const glowEnabled = layout.noteGlowEnabled ?? false; - const rawGlowSize = layout.noteGlowSize ?? 20; - const glowSize = glowEnabled ? Math.min(Math.max(rawGlowSize, 0), 50) : 0; + const glowSize = resolvedGlowSize(layout); const baseGlowOpacityPercent = layout.noteGlowOpacity != null && Number.isFinite(layout.noteGlowOpacity) diff --git a/src/renderer/styles/main.css b/src/renderer/styles/main.css index 14dd204f..0d2977d1 100644 --- a/src/renderer/styles/main.css +++ b/src/renderer/styles/main.css @@ -8,32 +8,88 @@ body { color: var(--ui-fg); } +/* 플러그인 색상 스와치 */ +.dmn-color-swatch-button { + position: relative; + padding: 0; + border: 0; + overflow: visible; +} + +.dmn-color-swatch-surface { + position: absolute; + inset: 0; + display: block; + overflow: hidden; + border-radius: inherit; + background: var(--ui-checker-pattern) center / var(--ui-checker-size-sm) + var(--ui-checker-size-sm) repeat; +} + +.dmn-color-swatch-color, +.dmn-color-swatch-ring { + position: absolute; + inset: 0; + display: block; + border-radius: inherit; + pointer-events: none; +} + +.dmn-color-swatch-color { + background-color: var(--dmn-color-swatch-color, transparent); +} + +.dmn-color-swatch-ring { + box-shadow: inset 0 0 0 1px var(--ui-line); +} + /* 커스텀 커서 미리보기 */ body.dmn-custom-cursor, body.dmn-custom-cursor * { cursor: none !important; } +/* 캔버스 아이템 드래그 커서 — 호버 무변화, 잡는 동안만 grabbing (전 요소 동일 정책) */ +.dmn-grabbable { + cursor: default; +} + +.dmn-grabbable:active { + cursor: -webkit-grabbing; + cursor: grabbing; +} + /* ===== 글래스 백드롭 ===== */ /* 글래스 백드롭 — 블러에 밝기·채도 클램프를 결합. - 블러 반경은 매 프레임 면적×반경 비례로 재필터되는 비용 다이얼 — - 어두운 틴트 아래서 큰 반경은 체감 차이 없이 비용만 늘어 16px 상한 유지. - 글래스 체감은 반경이 아니라 틴트 알파·클램프 완화·채도 부스트가 만든다 */ + 비용 지배 요인은 반경이 아니라 표면 수·면적·아래 damage(재필터 트리거) — + Skia가 큰 반경을 저해상도 다운샘플로 처리해 반경 비용은 평탄화됨. + 글래스는 일시 표면(모달·팝업) 전용 유지, 표면 자체에 transform 애니메이션 금지 */ .backdrop-glass { - -webkit-backdrop-filter: blur(16px) brightness(var(--ui-glass-backdrop-dim)) + -webkit-backdrop-filter: blur(32px) brightness(var(--ui-glass-backdrop-dim)) saturate(var(--ui-glass-backdrop-sat)); - backdrop-filter: blur(16px) brightness(var(--ui-glass-backdrop-dim)) + backdrop-filter: blur(32px) brightness(var(--ui-glass-backdrop-dim)) saturate(var(--ui-glass-backdrop-sat)); } .backdrop-glass-popup { - -webkit-backdrop-filter: blur(12px) brightness(var(--ui-glass-backdrop-dim)) + -webkit-backdrop-filter: blur(24px) brightness(var(--ui-glass-backdrop-dim)) saturate(var(--ui-glass-backdrop-sat)); - backdrop-filter: blur(12px) brightness(var(--ui-glass-backdrop-dim)) + backdrop-filter: blur(24px) brightness(var(--ui-glass-backdrop-dim)) saturate(var(--ui-glass-backdrop-sat)); } +/* 스크림 프로스트 — 검은 딤 + 은은한 전화면 블러 (디자인 개편 전 레시피 복원). + 틴트는 유틸리티가 아니라 여기서 소유 — 딤·블러 레시피 단일 소스. + 전화면 재필터라 반경은 최소 유지, 블러 키프레임 보간 금지가 최적화의 핵심 — + 블러는 첫 프레임부터 상수, 틴트만 페이드. Windows에서 모달 연 채 + 키 연타 프레임 실측이 유지 조건 */ +.backdrop-glass-scrim { + background: rgba(0, 0, 0, 0.75); + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); +} + /* 포지셔닝 보정 — 이미 fixed/absolute인 표면은 :where(0 특이도)라 그대로 유지 */ :where(.backdrop-glass, .backdrop-glass-popup) { position: relative; @@ -66,6 +122,12 @@ body.dmn-custom-cursor * { --ui-glass-dim: rgba(12, 12, 14, 0.94); } + /* 스크림은 블러만 끄고 알파 딤으로 폴백 */ + .backdrop-glass-scrim { + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + /* 루미노시티 캡은 블러 없는 표면에서 무의미 — 블렌드 패스도 함께 제거 */ .backdrop-glass::after, .backdrop-glass-popup::after { @@ -334,53 +396,15 @@ body.dmn-custom-cursor * { animation: modalScrim var(--ui-duration-fast) var(--ui-ease-out) both; } -/* ===== ColorPicker ===== */ - -.rcp-saturation { - border-radius: 8px !important; - cursor: pointer !important; - box-shadow: inset 0 0 2px rgba(255, 255, 255, 0.25) !important; -} - -.rcp-saturation-cursor { - height: 10px !important; - width: 10px !important; - transform: translate(-50%, -50%) !important; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.7) !important; - border: 2px solid white !important; - border-radius: 100% !important; -} - -.rcp-hue { - border-radius: 8px !important; - cursor: pointer !important; - height: 10px !important; - box-shadow: inset 0 0 2px rgba(255, 255, 255, 0.25) !important; -} - -.rcp-hue-cursor { - height: 10px !important; - width: 10px !important; - transform: translate(-50%, 0%) !important; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.7) !important; - border: 2px solid white !important; - border-radius: 100% !important; -} - -.rcp-alpha { - border-radius: 8px !important; - cursor: pointer !important; - height: 10px !important; - box-shadow: inset 0 0 2px rgba(255, 255, 255, 0.25) !important; +/* 스테이지 콘텐츠 등장 — 비동기 준비(파형 디코드 등)를 마친 콘텐츠의 한 박자 페이드 */ +@keyframes stageContentIn { + from { + opacity: 0; + } } -.rcp-alpha-cursor { - height: 10px !important; - width: 10px !important; - transform: translate(-50%, 0%) !important; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.7) !important; - border: 2px solid white !important; - border-radius: 100% !important; +.animate-stage-in { + animation: stageContentIn var(--ui-duration-fast) var(--ui-ease-out) both; } /* ===== 코드 에디터 ===== */ @@ -510,6 +534,7 @@ body.dmn-custom-cursor * { .tooltip-fade-in, .animate-modal-scale > *, .animate-modal-scrim, + .animate-stage-in, .animate-pulse { animation: none; } diff --git a/src/renderer/styles/tokens.css b/src/renderer/styles/tokens.css index 5483469c..5b76b712 100644 --- a/src/renderer/styles/tokens.css +++ b/src/renderer/styles/tokens.css @@ -17,10 +17,12 @@ /* 불투명 웰 — 코드 에디터처럼 배경이 흔들리면 안 되는 표면 전용 */ --ui-bg-inset-solid: #101013; - /* 글래스 표면 — 스모크 재질. 팝업·패널 0.72, 모달은 가독 우선 0.78, 캔버스 크롬 0.6 + /* 글래스 표면 — 스모크 재질. 팝업·패널 0.72, 캔버스 크롬 0.6 알파 ↓ = 배경 투과 ↑ (글래스 체감의 1차 다이얼). 분리는 그림자 토큰의 인셋 링+상단 하이라이트가 담당, 보더 금지 */ --ui-glass: rgba(14, 14, 17, 0.72); - --ui-glass-heavy: rgba(14, 14, 17, 0.78); + /* 모달·시트·피커용 — 중앙 모달은 스크림(.backdrop-glass-scrim 딤+블러)이 뒤에 겹쳐 + 실효 밀도는 스크림×틴트. 스크림 알파와 함께 조절할 것 */ + --ui-glass-heavy: rgba(14, 14, 17, 0.45); /* 캔버스 상주 크롬용 — 배경보다 한 톤 어두운 스모크 */ --ui-glass-dim: rgba(12, 12, 14, 0.6); /* 사이드 패널용 */ @@ -32,11 +34,11 @@ --ui-glass-dim-solid: rgba(12, 12, 14, 0.94); /* 글래스 백드롭 클램프 — 틴트 전에 배경 밝기를 누르고, 죽은 채도는 보상 dim ↓ = 밝은 배경 투과 억제. 과하게 누르면 투과 변화 자체가 죽어 글래스가 안 읽힘 / sat = 색기운 유지량 */ - --ui-glass-backdrop-dim: 0.55; + --ui-glass-backdrop-dim: 0.8; --ui-glass-backdrop-sat: 1.5; /* 루미노시티 캡 — 투과 결과의 휘도를 어두운 쪽으로 강제 (색조는 유지) 0 = 끔, 1 = 완전 캡. 필터와 독립 동작하는 2차 안전망 */ - --ui-glass-backdrop-lum: 0.35; + --ui-glass-backdrop-lum: 0.1; /* 캡의 기준 휘도 — 글래스 틴트와 같은 근흑색 */ --ui-glass-backdrop-lum-tint: #0e0e11; @@ -46,6 +48,17 @@ /* 캔버스/스테이지 보조 격자선 — 메이저(--ui-line)보다 한 톤 흐림 */ --ui-line-faint: rgba(255, 255, 255, 0.06); + /* 투명 색상 체커 */ + --ui-checker-pattern: conic-gradient( + #666 0.25turn, + #999 0.25turn 0.5turn, + #666 0.5turn 0.75turn, + #999 0.75turn + ); + --ui-checker-size: 8px; + /* 소형 스와치(11~23px)용 — 8px 타일은 2×2칸이라 깨진 텍스처로 읽힘 */ + --ui-checker-size-sm: 4px; + /* 크롬 필 — 반투명 화이트 오버레이 상태 래더 (idle → hover → active) */ --ui-fill: rgba(255, 255, 255, 0.08); --ui-fill-hover: rgba(255, 255, 255, 0.11); diff --git a/src/renderer/utils/color/colorPaletteStorage.ts b/src/renderer/utils/color/colorPaletteStorage.ts index f99bd14d..cade8003 100644 --- a/src/renderer/utils/color/colorPaletteStorage.ts +++ b/src/renderer/utils/color/colorPaletteStorage.ts @@ -13,7 +13,7 @@ const STORAGE_KEYS: Record = { gradient: 'dmnote-color-palette-gradient', }; -const MAX_PALETTE_SIZE = 5; +const MAX_PALETTE_SIZE = 7; export const loadPalette = (type: PaletteType): PaletteColor[] => { try { diff --git a/src/renderer/utils/color/colorUtils.test.ts b/src/renderer/utils/color/colorUtils.test.ts index 4b43abcb..e2779684 100644 --- a/src/renderer/utils/color/colorUtils.test.ts +++ b/src/renderer/utils/color/colorUtils.test.ts @@ -4,6 +4,8 @@ import { normalizeColorInput, parseHexColor, rgbToHsv, + hsvToRgb, + hsvToColorObject, toColorObject, toCssRgba, toRgbHexColor, @@ -113,6 +115,60 @@ describe('rgbToHsv', () => { }); }); +describe('hsvToRgb', () => { + it('빨간색 변환', () => { + const rgb = hsvToRgb(0, 100, 100); + expect(rgb).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('h=360은 h=0과 동일', () => { + expect(hsvToRgb(360, 100, 100)).toEqual(hsvToRgb(0, 100, 100)); + }); + + it('무채색은 s=0', () => { + const rgb = hsvToRgb(210, 0, 50); + expect(rgb.r).toBe(rgb.g); + expect(rgb.g).toBe(rgb.b); + }); + + it('rgbToHsv 왕복 보존', () => { + const cases: [number, number, number][] = [ + [255, 0, 0], + [0, 128, 255], + [12, 200, 89], + [183, 150, 255], + ]; + for (const [r, g, b] of cases) { + const hsv = rgbToHsv(r, g, b); + const rgb = hsvToRgb(hsv.h, hsv.s, hsv.v); + expect(rgb.r).toBe(r); + expect(rgb.g).toBe(g); + expect(rgb.b).toBe(b); + } + }); + + it('알파값 전달', () => { + expect(hsvToRgb(0, 100, 100, 0.5).a).toBe(0.5); + }); +}); + +describe('hsvToColorObject', () => { + it('hex는 대문자 6자리, hsv는 그대로 보존', () => { + const color = hsvToColorObject({ h: 270, s: 0, v: 100, a: 1 }); + expect(color.hex).toBe('#FFFFFF'); + // s=0이어도 hue 소실 없음 + expect(color.hsv.h).toBe(270); + }); + + it('범위 밖 값은 클램프', () => { + const color = hsvToColorObject({ h: 400, s: 120, v: -5, a: 2 }); + expect(color.hsv.h).toBe(360); + expect(color.hsv.s).toBe(100); + expect(color.hsv.v).toBe(0); + expect(color.hsv.a).toBe(1); + }); +}); + describe('toColorObject', () => { it('hex 문자열에서 ColorObject 생성', () => { const result = toColorObject('#00FF00'); diff --git a/src/renderer/utils/color/colorUtils.ts b/src/renderer/utils/color/colorUtils.ts index 29461a78..9ad3f527 100644 --- a/src/renderer/utils/color/colorUtils.ts +++ b/src/renderer/utils/color/colorUtils.ts @@ -127,6 +127,47 @@ const rgbToHsv = ( }; }; +const hsvToRgb = ( + h: number, + s: number, + v: number, + a: number = 1, +): { r: number; g: number; b: number; a: number } => { + const sn = s / 100; + const vn = v / 100; + const sector = Math.floor((h % 360) / 60); + const f = (h % 360) / 60 - sector; + const p = vn * (1 - sn); + const q = vn * (1 - sn * f); + const t = vn * (1 - sn * (1 - f)); + + const index = ((sector % 6) + 6) % 6; + const r = [vn, q, p, p, t, vn][index] * 255; + const g = [t, vn, vn, q, p, p][index] * 255; + const b = [p, p, t, vn, vn, q][index] * 255; + + return { r: Math.round(r), g: Math.round(g), b: Math.round(b), a }; +}; + +// HSV 값으로 ColorObject 생성 — 전달된 hsv를 그대로 보존해 +// 드래그 중 s=0/v=0에서 hue가 소실되지 않음 +const hsvToColorObject = (hsv: { + h: number; + s: number; + v: number; + a: number; +}): ColorObject => { + const h = clamp(hsv.h, 0, 360); + const s = clamp(hsv.s, 0, 100); + const v = clamp(hsv.v, 0, 100); + const a = clamp(hsv.a, 0, 1); + const rgb = hsvToRgb(h, s, v, a); + const hex = `#${[rgb.r, rgb.g, rgb.b] + .map((channel) => channel.toString(16).padStart(2, '0')) + .join('')}`.toUpperCase(); + return { hex, rgb, hsv: { h, s, v, a } }; +}; + const parseHexColor = (value: string): ColorObject | null => { const normalized = value.startsWith('#') ? value : `#${value}`; const hexBody = normalized.slice(1); @@ -214,6 +255,31 @@ const hexWithAlphaPercent = (hex: string, percent: number): string => { return `rgba(${parsed.rgb.r}, ${parsed.rgb.g}, ${parsed.rgb.b}, ${a})`; }; +// hex 외 CSS 색 문자열(named, hsl, rgb 등)을 캔버스 fillStyle 정규화로 해석 +let cssColorCtx: CanvasRenderingContext2D | null | undefined; + +const parseCssColor = (value: string): ColorObject | null => { + if (!(window.CSS?.supports?.('color', value) ?? false)) { + return null; + } + if (cssColorCtx === undefined) { + cssColorCtx = document.createElement('canvas').getContext('2d'); + } + if (!cssColorCtx) return null; + + cssColorCtx.fillStyle = value; + const normalized = cssColorCtx.fillStyle; + if (normalized.startsWith('#')) { + return parseHexColor(normalized); + } + const rgba = parseRgbaString(normalized); + if (!rgba) return null; + const hex = `#${[rgba.r, rgba.g, rgba.b] + .map((channel) => channel.toString(16).padStart(2, '0')) + .join('')}`.toUpperCase(); + return { hex, rgb: rgba, hsv: rgbToHsv(rgba.r, rgba.g, rgba.b, rgba.a) }; +}; + const toColorObject = ( value: string | Partial | null | undefined, ): ColorObject | null => { @@ -222,7 +288,7 @@ const toColorObject = ( } if (typeof value === 'string') { - return parseHexColor(value); + return parseHexColor(value) ?? parseCssColor(value); } if (typeof value === 'object' && value.hex) { @@ -317,6 +383,8 @@ export { buildGradient, parseHexColor, rgbToHsv, + hsvToRgb, + hsvToColorObject, toColorObject, toCssRgba, parseAlphaPercent, diff --git a/src/renderer/utils/keyStatsService.ts b/src/renderer/utils/keyStatsService.ts index b8c3c20e..ef97f896 100644 --- a/src/renderer/utils/keyStatsService.ts +++ b/src/renderer/utils/keyStatsService.ts @@ -38,6 +38,9 @@ class KeyStatsService { private currentMode = ''; private keyCounters: KeyCounters = {}; + // 마지막 통지 스냅샷 — 유휴 틱의 동일 값 재통지 방지 + private lastNotified: KeyStatsPayload | null = null; + // 현재 눌린 키 추적 (홀드 방지) private pressedKeys: Set = new Set(); @@ -218,6 +221,19 @@ class KeyStatsService { const stats = this.getCurrentStats(); + // 값이 전부 그대로면 통지 생략 (신규 구독자는 subscribe에서 즉시 1회 수신) + const last = this.lastNotified; + if ( + last && + last.kps === stats.kps && + last.kpsAvg === stats.kpsAvg && + last.kpsMax === stats.kpsMax && + last.total === stats.total + ) { + return; + } + this.lastNotified = stats; + this.listeners.forEach((listener) => { try { listener(stats); @@ -304,6 +320,7 @@ class KeyStatsService { this.kpsSum = 0; this.kpsCount = 0; this.pressedKeys.clear(); + this.lastNotified = null; // total은 카운터에서 계산되므로 리셋하지 않음 }