{/* 이름 필드 — 짧은 라벨 + 예시형 플레이스홀더, 입력이 남는 폭을 정확히 채워
옆 필드와 갭이 동일하게 유지됨 (라벨 길이가 긴 로케일도 flex로 자동 흡수) */}
diff --git a/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx
index 37dfee1f..a68f5add 100644
--- a/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx
+++ b/src/renderer/components/main/Modal/content/managers/SoundTrimModal.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from '@contexts/useTranslation';
-import Modal from '../../Modal';
+import FullSurfaceModalLayout from '@components/main/Modal/FullSurfaceModalLayout';
+import { isTopmostPopupLayer } from '@components/main/Modal/popupLayer';
import IconSwap from '@components/main/common/IconSwap';
import {
getCursor,
@@ -111,6 +112,15 @@ function drawWaveform(
const ctx = canvas.getContext('2d');
if (!ctx) return;
+ // canvas 2D는 var()를 못 읽어 토큰 계산값을 드로우마다 해석
+ const tokens = getComputedStyle(canvas);
+ const selectionFillColor = tokens
+ .getPropertyValue('--ui-selection-fill')
+ .trim();
+ const selectionColor = tokens.getPropertyValue('--ui-selection').trim();
+ const dimBarColor = tokens.getPropertyValue('--ui-fg-disabled').trim();
+ const chromeColor = tokens.getPropertyValue('--ui-fg').trim();
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
@@ -130,7 +140,7 @@ function drawWaveform(
const visStartX = Math.max(padX, startX);
const visEndX = Math.min(padX + drawableW, endX);
if (visEndX > visStartX) {
- ctx.fillStyle = 'rgba(139, 92, 246, 0.12)';
+ ctx.fillStyle = selectionFillColor;
ctx.fillRect(visStartX, 0, visEndX - visStartX, height);
}
@@ -143,7 +153,7 @@ function drawWaveform(
const x = padX + px;
const inRange = x >= startX && x <= endX;
- ctx.fillStyle = inRange ? '#B9A5F9' : '#4A4B55';
+ ctx.fillStyle = inRange ? selectionColor : dimBarColor;
ctx.fillRect(x, y, 1, barHeight);
}
@@ -156,7 +166,7 @@ function drawWaveform(
const gripR = 3;
const drawHandle = (cx: number) => {
- ctx.fillStyle = '#FFFFFF';
+ ctx.fillStyle = chromeColor;
roundRect(
ctx,
cx - handleLineW / 2,
@@ -187,7 +197,7 @@ function drawWaveform(
if (playbackRatio !== null) {
const playX = audioToX(playbackRatio);
if (playX >= padX && playX <= padX + drawableW) {
- ctx.fillStyle = '#FFFFFF';
+ ctx.fillStyle = chromeColor;
ctx.globalAlpha = 0.9;
ctx.fillRect(playX - 0.5, 0, 1, height);
ctx.globalAlpha = 1;
@@ -500,6 +510,34 @@ const SoundTrimModal = ({
animFrameRef.current = requestAnimationFrame(animate);
};
+ const handlePlayRef = useRef<() => void>(() => {});
+ handlePlayRef.current = handlePlay;
+
+ // 스페이스 = 재생/일시정지 — 포커스된 버튼의 스페이스 활성화(취소 오동작)를 가로챔
+ useEffect(() => {
+ if (!isOpen) return;
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.code !== 'Space' || e.repeat || e.defaultPrevented) return;
+ // 시트 위에 다른 팝업 레이어가 떠 있으면 스페이스 소유권 양보
+ const backdrop = waveformRef.current?.closest
(
+ '[data-dmn-modal-backdrop="true"]',
+ );
+ if (!isTopmostPopupLayer(backdrop ?? null)) return;
+ const target = e.target as HTMLElement | null;
+ if (
+ target?.tagName === 'INPUT' ||
+ target?.tagName === 'TEXTAREA' ||
+ target?.isContentEditable
+ ) {
+ return;
+ }
+ e.preventDefault();
+ handlePlayRef.current();
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [isOpen]);
+
const resetStateImpl = useRef<() => void>(() => {});
resetStateImpl.current = () => {
pausedAtRatioRef.current = null;
@@ -529,6 +567,8 @@ const SoundTrimModal = ({
};
const closeModal = () => {
+ // 저장 중 닫기 금지 — 진행 중 저장의 완료 콜백이 닫힌 시트로 새는 것을 차단
+ if (isSaving) return;
resetState();
onClose();
};
@@ -996,212 +1036,170 @@ const SoundTrimModal = ({
}
};
- const headerLabel = (() => {
- if (!audioBuffer) {
- if (isDecoding) {
- return isEditMode
- ? t('soundTrimModal.statusLoading')
- : t('soundTrimModal.statusDecoding');
- }
- return t('soundTrimModal.statusWaiting');
- }
- return t('soundTrimModal.statusReady');
- })();
+ const sheetTitle = isEditMode
+ ? t('soundTrimModal.editTitle')
+ : t('soundTrimModal.defaultTitle');
- const headerTitle = isEditMode
- ? editingDisplayName || t('soundTrimModal.editTitle')
- : originalFileName || t('soundTrimModal.defaultTitle');
+ // 제목은 모드 고정 — 파일 정체성은 헤더 보조 캡션이 담당
+ const headerFileName = isEditMode
+ ? editingDisplayName || ''
+ : originalFileName;
if (!isOpen) return null;
return (
-
- event.stopPropagation()}
- >
- {/* 헤더 바 */}
-
-
-
- Sound
-
-
- {headerTitle}
-
-
-
- {headerLabel}
+
+ {headerFileName}
+ ) : undefined
+ }
+ submitLabel={
+ isSaving
+ ? t('soundTrimModal.saving')
+ : isEditMode
+ ? t('soundTrimModal.submitEdit')
+ : t('soundTrimModal.submit')
+ }
+ submitDisabled={!canSubmit}
+ onSubmit={() => {
+ void handleSave();
+ }}
+ cancelLabel={t('common.cancel')}
+ >
+ {/* 본문 — 상단: 파형 히어로 스테이지, 하단: 트랜스포트 데크 */}
+
+ {/* 파형 카드 — 카드 내부를 통째로 채우는 풀블리드 캔버스 */}
+
+
+ {audioBuffer ? (
+ /* 디코드 완료 시 한 박자 페이드 — 스켈레톤→파형 팝인 정리 */
+
+
+ {/* 하단 안내 — 스크림 없이 흐린 캡션만 */}
+
+
+ {t('soundTrimModal.dragHint')}
+
+
+
+ ) : (
+
+ {isDecoding
+ ? isEditMode
+ ? t('soundTrimModal.statusLoading')
+ : t('soundTrimModal.decodingMessage')
+ : t('soundTrimModal.emptyMessage')}
+
+ )}
+
- {/* 콘텐츠 영역 */}
-
- {/* 이름 입력 */}
-
-
-
+
+ {!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 (
-
)}
@@ -1289,7 +1269,7 @@ function GradientInputs({
rightTitle,
}: GradientInputsProps) {
return (
-
+
-
onSelect?.()}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') onSelect?.();
}}
- className="absolute left-[6px] top-[7px] w-[11px] h-[11px] rounded-[2px]"
- style={{
- background: value ? `#${value}` : '#561ecb',
- }}
+ className="absolute left-[6px] top-1/2 -translate-y-1/2 w-[11px] h-[11px] rounded-[2px]"
+ color={value ? `#${value}` : '#561ecb'}
/>
@@ -1406,7 +1384,7 @@ function GradientInput({
event.currentTarget.blur();
}
}}
- className={`px-[6px] text-center w-full h-[23px] bg-inset rounded-md focus:shadow-focus-ring text-body tabular-nums text-fg pt-[1px] leading-[23px] ${''}`}
+ className="block px-[6px] text-center w-full h-[23px] bg-inset rounded-md focus:shadow-focus-ring text-body tabular-nums text-fg"
title={rightTitle}
/>
diff --git a/src/renderer/components/main/Modal/content/pickers/ColorSwatch.tsx b/src/renderer/components/main/Modal/content/pickers/ColorSwatch.tsx
new file mode 100644
index 00000000..90a9dca3
--- /dev/null
+++ b/src/renderer/components/main/Modal/content/pickers/ColorSwatch.tsx
@@ -0,0 +1,133 @@
+import React, { forwardRef } from 'react';
+
+export const CHECKER_PATTERN = 'var(--ui-checker-pattern)';
+export const CHECKER_SIZE = 'var(--ui-checker-size)';
+
+export interface ColorSwatchGradient {
+ top: string;
+ bottom: string;
+}
+
+interface GradientOpacity {
+ top: number;
+ bottom: number;
+}
+
+type ColorSwatchOpacity = number | GradientOpacity;
+
+interface ColorSwatchSurfaceProps
+ extends Omit
, 'color'> {
+ color?: string | null;
+ gradient?: ColorSwatchGradient | null;
+ opacity?: ColorSwatchOpacity;
+}
+
+interface ColorSwatchButtonProps
+ extends Omit, 'color'> {
+ color?: string | null;
+ gradient?: ColorSwatchGradient | null;
+ opacity?: ColorSwatchOpacity;
+ open?: boolean;
+ selected?: boolean;
+ surfaceClassName?: string;
+}
+
+const clampOpacity = (value: number): number => Math.min(1, Math.max(0, value));
+
+const colorWithOpacity = (color: string, opacity: number): string => {
+ const percentage = clampOpacity(opacity) * 100;
+ return `color-mix(in srgb, ${color} ${percentage}%, transparent)`;
+};
+
+export const ColorSwatchSurface = ({
+ color = 'transparent',
+ gradient,
+ opacity,
+ className = '',
+ style,
+ ...props
+}: ColorSwatchSurfaceProps) => {
+ const gradientOpacity =
+ gradient && typeof opacity === 'object' ? opacity : null;
+ const colorLayerStyle: React.CSSProperties = gradient
+ ? {
+ background: `linear-gradient(to bottom, ${
+ gradientOpacity
+ ? colorWithOpacity(gradient.top, gradientOpacity.top)
+ : gradient.top
+ }, ${
+ gradientOpacity
+ ? colorWithOpacity(gradient.bottom, gradientOpacity.bottom)
+ : gradient.bottom
+ })`,
+ opacity:
+ typeof opacity === 'number' ? clampOpacity(opacity) : undefined,
+ }
+ : {
+ backgroundColor: color ?? 'transparent',
+ opacity:
+ typeof opacity === 'number' ? clampOpacity(opacity) : undefined,
+ };
+
+ // position 클래스는 호출부가 소유 — 기본 relative를 깔면 absolute 칩과
+ // 스타일시트 순서 경합이 생김 (position ≠ static이면 내부 레이어 기준 성립)
+ return (
+
+ );
+};
+
+export const ColorSwatchButton = forwardRef<
+ HTMLButtonElement,
+ ColorSwatchButtonProps
+>(
+ (
+ {
+ color,
+ gradient,
+ opacity,
+ open = false,
+ selected = false,
+ surfaceClassName = '',
+ className = '',
+ type = 'button',
+ ...props
+ },
+ ref,
+ ) => {
+ 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 (
-
);
-}
+};
diff --git a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx
index 3914dd28..d1c9566d 100644
--- a/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx
+++ b/src/renderer/components/main/Modal/content/pickers/SoundPicker.tsx
@@ -23,7 +23,7 @@ interface SoundPickerProps {
}
type TrimState =
- | { mode: 'create'; file: File }
+ | { mode: 'create'; file: File | null }
| { mode: 'edit'; item: SoundListItem };
let soundListCache: SoundListItem[] | null = null;
@@ -342,7 +342,11 @@ const SoundPicker = ({
isLoading={isLoading}
loadingText={t('propertiesPanel.loading') || '로딩...'}
errorText={loadError}
- onAdd={() => addFileInputRef.current?.click()}
+ onAdd={() => {
+ // 시트를 먼저 띄우고 대화상자를 열어 닫힘 순간 캔버스 노출 방지
+ setTrimState({ mode: 'create', file: null });
+ addFileInputRef.current?.click();
+ }}
addLabel={t('soundPicker.add') || '사운드 추가'}
/>
diff --git a/src/renderer/components/main/Modal/content/pickers/colorPickerPrimitives.tsx b/src/renderer/components/main/Modal/content/pickers/colorPickerPrimitives.tsx
new file mode 100644
index 00000000..a57310fa
--- /dev/null
+++ b/src/renderer/components/main/Modal/content/pickers/colorPickerPrimitives.tsx
@@ -0,0 +1,394 @@
+import React, { useEffect, useRef } from 'react';
+import { hsvToColorObject, type ColorObject } from '@utils/color/colorUtils';
+import { CHECKER_PATTERN, CHECKER_SIZE } from './ColorSwatch';
+
+// 디자인 조절점 — 내부 폭 148px 기준 비율 ≈1.42:1
+const SATURATION_HEIGHT = 104;
+const SATURATION_CURSOR_SIZE = 12;
+const TRACK_HEIGHT = 12;
+const KNOB_SIZE = 14;
+
+// 키보드 스텝 — 화살표 1, Shift/Page는 hue 15° / 나머지 10
+const HUE_PAGE_STEP = 15;
+const PAGE_STEP = 10;
+
+// 코너 마스크 — 32×32 rx=8 소스에 slice 8이면 코너 타일 1:1이라 크기 무관 rx 왜곡 없음
+const SATURATION_CORNER_MASK = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Crect width='32' height='32' rx='8' fill='%23000'/%3E%3C/svg%3E") 8 fill stretch`;
+
+const HUE_TRACK_GRADIENT =
+ 'linear-gradient(to right, #f00, #ff0 16.67%, #0f0 33.33%, #0ff 50%, #00f 66.67%, #f0f 83.33%, #f00)';
+
+interface ColorTrackProps {
+ color: ColorObject;
+ onChange: (color: ColorObject) => void;
+ onChangeComplete?: (color: ColorObject) => void;
+}
+
+const clampRatio = (value: number): number =>
+ value < 0 ? 0 : value > 1 ? 1 : value;
+
+const useLatest = (value: T) => {
+ const ref = useRef(value);
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref;
+};
+
+// 트랙 드래그 세션 — 캡처 기반, 시작 시 1회 실측.
+// 커서 렌더는 % 포지셔닝이라 측정에 의존하지 않음
+const usePointerSession = (
+ emit: (ratioX: number, ratioY: number, final: boolean) => void,
+) => {
+ const emitRef = useLatest(emit);
+ const activePointerRef = useRef(null);
+ const targetRef = useRef(null);
+ const rectRef = useRef(null);
+ const lastRatioRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const blurCleanupRef = useRef<(() => void) | null>(null);
+
+ // 좌표를 0~1 비율로 갱신 — 세션 시작 시 rect 유효성이 보장됨
+ const updateRatio = (clientX: number, clientY: number) => {
+ const rect = rectRef.current;
+ if (!rect) return false;
+ lastRatioRef.current = {
+ x: clampRatio((clientX - rect.left) / rect.width),
+ y: clampRatio((clientY - rect.top) / rect.height),
+ };
+ return true;
+ };
+
+ const emitLast = (final: boolean) => {
+ const { x, y } = lastRatioRef.current;
+ emitRef.current(x, y, final);
+ };
+
+ // 세션 해제 — 어떤 종료 경로로 와도 1회만 동작
+ const teardown = () => {
+ const pointerId = activePointerRef.current;
+ if (pointerId === null) return;
+ activePointerRef.current = null;
+ rectRef.current = null;
+ blurCleanupRef.current?.();
+ blurCleanupRef.current = null;
+ const target = targetRef.current;
+ targetRef.current = null;
+ if (target?.hasPointerCapture(pointerId)) {
+ target.releasePointerCapture(pointerId);
+ }
+ };
+
+ // 종료 커밋 — 소유권을 먼저 해제해 complete 콜백이 동기로
+ // blur/캡처 상실을 유발해도 재진입 중복 커밋이 불가능
+ const finish = (clientX?: number, clientY?: number) => {
+ if (activePointerRef.current === null) return;
+ if (clientX !== undefined && clientY !== undefined) {
+ updateRatio(clientX, clientY);
+ }
+ teardown();
+ emitLast(true);
+ };
+
+ const onPointerDown = (event: React.PointerEvent) => {
+ if (event.button !== 0 || !event.isPrimary) return;
+ if (activePointerRef.current !== null) return;
+ const target = event.currentTarget;
+ const rect = target.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ activePointerRef.current = event.pointerId;
+ targetRef.current = target;
+ rectRef.current = rect;
+ target.setPointerCapture(event.pointerId);
+ const onWindowBlur = () => finish();
+ window.addEventListener('blur', onWindowBlur);
+ blurCleanupRef.current = () =>
+ window.removeEventListener('blur', onWindowBlur);
+ updateRatio(event.clientX, event.clientY);
+ emitLast(false);
+ };
+
+ const onPointerMove = (event: React.PointerEvent) => {
+ if (event.pointerId !== activePointerRef.current) return;
+ updateRatio(event.clientX, event.clientY);
+ emitLast(false);
+ };
+
+ const onPointerUp = (event: React.PointerEvent) => {
+ if (event.pointerId !== activePointerRef.current) return;
+ finish(event.clientX, event.clientY);
+ };
+
+ const onPointerCancel = (event: React.PointerEvent) => {
+ if (event.pointerId !== activePointerRef.current) return;
+ finish();
+ };
+
+ // 명시적 release 후에는 activePointerRef가 이미 null이라 no-op
+ const onLostPointerCapture = () => {
+ finish();
+ };
+
+ // 드래그 중 언마운트(Esc로 팝업 닫힘 등)돼도 마지막 값을 커밋 — 저장 계약 유지
+ const finishRef = useLatest(finish);
+ useEffect(() => () => finishRef.current(), [finishRef]);
+
+ return {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onPointerCancel,
+ onLostPointerCapture,
+ };
+};
+
+const knobShadow = '0 0 4px rgba(0, 0, 0, 0.7)';
+
+// 색 표면은 무테 — 원색 트랙·체커·필드가 스스로 경계를 정의, 밝은 링은 검정 영역에서 도드라짐
+const trackClassName =
+ 'relative w-full cursor-pointer touch-none select-none rounded-full ' +
+ 'outline-none focus-visible:shadow-focus-ring';
+
+interface SaturationAreaProps extends ColorTrackProps {
+ height?: number;
+}
+
+export const SaturationArea = ({
+ color,
+ height = SATURATION_HEIGHT,
+ onChange,
+ onChangeComplete,
+}: SaturationAreaProps) => {
+ const session = usePointerSession((x, y, final) => {
+ const next = hsvToColorObject({
+ ...color.hsv,
+ s: x * 100,
+ v: 100 - y * 100,
+ });
+ onChange(next);
+ if (final) onChangeComplete?.(next);
+ });
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ const step = event.shiftKey ? PAGE_STEP : 1;
+ let { s, v } = color.hsv;
+ switch (event.key) {
+ case 'ArrowLeft':
+ s -= step;
+ break;
+ case 'ArrowRight':
+ s += step;
+ break;
+ case 'ArrowUp':
+ v += step;
+ break;
+ case 'ArrowDown':
+ v -= step;
+ break;
+ case 'PageUp':
+ v += PAGE_STEP;
+ break;
+ case 'PageDown':
+ v -= PAGE_STEP;
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ const next = hsvToColorObject({ ...color.hsv, s, v });
+ onChange(next);
+ onChangeComplete?.(next);
+ };
+
+ return (
+
+ {/* 라운딩은 클립이 아니라 9-slice 마스크 — 클립은 페인트 연산별 AA 중첩으로
+ 코너 곡률에서 밝은 바탕색이 호로 누설됨 (radius 변경 시 마스크 rx도 함께) */}
+
+
+
+ );
+};
+
+export const HueSlider = ({
+ color,
+ onChange,
+ onChangeComplete,
+}: ColorTrackProps) => {
+ const session = usePointerSession((x, _y, final) => {
+ const next = hsvToColorObject({ ...color.hsv, h: x * 360 });
+ onChange(next);
+ if (final) onChangeComplete?.(next);
+ });
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ const step = event.shiftKey ? HUE_PAGE_STEP : 1;
+ let h = color.hsv.h;
+ switch (event.key) {
+ case 'ArrowLeft':
+ case 'ArrowDown':
+ h -= step;
+ break;
+ case 'ArrowRight':
+ case 'ArrowUp':
+ h += step;
+ break;
+ case 'PageUp':
+ h += HUE_PAGE_STEP;
+ break;
+ case 'PageDown':
+ h -= HUE_PAGE_STEP;
+ break;
+ case 'Home':
+ h = 0;
+ break;
+ case 'End':
+ h = 360;
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ const next = hsvToColorObject({ ...color.hsv, h });
+ onChange(next);
+ onChangeComplete?.(next);
+ };
+
+ return (
+
+ );
+};
+
+export const AlphaSlider = ({
+ color,
+ onChange,
+ onChangeComplete,
+}: ColorTrackProps) => {
+ const session = usePointerSession((x, _y, final) => {
+ const next = hsvToColorObject({ ...color.hsv, a: x });
+ onChange(next);
+ if (final) onChangeComplete?.(next);
+ });
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ const step = (event.shiftKey ? PAGE_STEP : 1) / 100;
+ let a = color.hsv.a;
+ switch (event.key) {
+ case 'ArrowLeft':
+ case 'ArrowDown':
+ a -= step;
+ break;
+ case 'ArrowRight':
+ case 'ArrowUp':
+ a += step;
+ break;
+ case 'PageUp':
+ a += PAGE_STEP / 100;
+ break;
+ case 'PageDown':
+ a -= PAGE_STEP / 100;
+ break;
+ case 'Home':
+ a = 0;
+ break;
+ case 'End':
+ a = 1;
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ const next = hsvToColorObject({ ...color.hsv, a });
+ onChange(next);
+ onChangeComplete?.(next);
+ };
+
+ const rgb = `${color.rgb.r} ${color.rgb.g} ${color.rgb.b}`;
+
+ return (
+
+ );
+};
diff --git a/src/renderer/components/main/Modal/content/settings/CounterTabContent.tsx b/src/renderer/components/main/Modal/content/settings/CounterTabContent.tsx
index b5a6268d..b93aa183 100644
--- a/src/renderer/components/main/Modal/content/settings/CounterTabContent.tsx
+++ b/src/renderer/components/main/Modal/content/settings/CounterTabContent.tsx
@@ -16,6 +16,7 @@ import type {
CounterTabState,
CounterPreviewData,
} from '@hooks/Modal/useUnifiedKeySettingState';
+import { ColorSwatchSurface } from '@components/main/Modal/content/pickers/ColorSwatch';
// ============================================================================
// 타입 정의
@@ -166,10 +167,10 @@ const CounterTabContent = forwardRef<
} text-fg text-label`;
// 컬러 프리뷰 박스
- const renderColorSquare = (style: React.CSSProperties) => (
- (
+
);
@@ -358,7 +359,7 @@ const CounterTabContent = forwardRef<
)}
onClick={() => handleColorToggle('fillIdle')}
>
- {renderColorSquare({ backgroundColor: state.fillIdle })}
+ {renderColorSquare(state.fillIdle)}
{t('counterSetting.idle')}
@@ -371,7 +372,7 @@ const CounterTabContent = forwardRef<
)}
onClick={() => handleColorToggle('fillActive')}
>
- {renderColorSquare({ backgroundColor: state.fillActive })}
+ {renderColorSquare(state.fillActive)}
{t('counterSetting.active')}
@@ -393,7 +394,7 @@ const CounterTabContent = forwardRef<
)}
onClick={() => handleColorToggle('strokeIdle')}
>
- {renderColorSquare({ backgroundColor: state.strokeIdle })}
+ {renderColorSquare(state.strokeIdle)}
{t('counterSetting.idle')}
@@ -406,7 +407,7 @@ const CounterTabContent = forwardRef<
)}
onClick={() => handleColorToggle('strokeActive')}
>
- {renderColorSquare({ backgroundColor: state.strokeActive })}
+ {renderColorSquare(state.strokeActive)}
{t('counterSetting.active')}
diff --git a/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx b/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx
index 073e0842..90c41f29 100644
--- a/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx
+++ b/src/renderer/components/main/Modal/content/settings/NoteTabContent.tsx
@@ -16,6 +16,7 @@ import {
type NoteTabState,
type NotePreviewData,
} from '@hooks/Modal/useUnifiedKeySettingState';
+import { ColorSwatchSurface } from '@components/main/Modal/content/pickers/ColorSwatch';
// ============================================================================
// 타입 정의
@@ -61,25 +62,6 @@ const NoteTabContent = forwardRef
(
}
}, [state.glowEnabled, state.showGlowPicker, setState]);
- // 색상 미리보기 스타일
- const renderColorPreview = () => {
- if (state.colorMode === COLOR_MODES.gradient) {
- return {
- background: `linear-gradient(to bottom, ${state.noteColor}, ${state.gradientBottom})`,
- };
- }
- return { backgroundColor: state.noteColor };
- };
-
- const renderGlowColorPreview = () => {
- if (state.glowColorMode === COLOR_MODES.gradient) {
- return {
- background: `linear-gradient(to bottom, ${state.glowColor}, ${state.glowGradientBottom})`,
- };
- }
- return { backgroundColor: state.glowColor };
- };
-
// 색상 라벨
const colorLabel =
state.colorMode === COLOR_MODES.gradient
@@ -330,9 +312,22 @@ const NoteTabContent = forwardRef(
setState((prev) => ({ ...prev, showPicker: !prev.showPicker }))
}
>
-
{colorLabel}
@@ -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