fix(app): 배경 공간 드래그 스크롤 클릭 충돌 수정

This commit is contained in:
2026-03-13 16:22:11 +09:00
parent 88bb4f40b8
commit a1424a4794
4 changed files with 215 additions and 11 deletions

View File

@@ -2,6 +2,7 @@ import type { SceneTheme } from '@/entities/scene';
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import { useDragScroll } from '@/shared/lib/useDragScroll';
interface SceneSelectCarouselProps {
scenes: SceneTheme[];
@@ -16,8 +17,17 @@ export const SceneSelectCarousel = ({
sceneAssetMap,
onSelect,
}: SceneSelectCarouselProps) => {
const { containerRef, events, isDragging, shouldSuppressClick } = useDragScroll();
return (
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<div
ref={containerRef}
{...events}
className={cn(
"-mx-1 overflow-x-auto px-1 pb-1 scrollbar-none",
isDragging ? "cursor-grabbing" : "cursor-grab"
)}
>
<div className="flex min-w-full gap-2.5">
{scenes.map((scene) => {
const selected = scene.id === selectedSceneId;
@@ -26,12 +36,17 @@ export const SceneSelectCarousel = ({
<button
key={scene.id}
type="button"
onClick={() => onSelect(scene.id)}
onClick={() => {
if (!shouldSuppressClick) {
onSelect(scene.id);
}
}}
className={cn(
'group relative h-24 min-w-[138px] overflow-hidden rounded-xl border text-left sm:min-w-[148px]',
selected
? 'border-sky-200/38 shadow-[0_0_0_1px_rgba(186,230,253,0.2),0_0_10px_rgba(56,189,248,0.12)]'
: 'border-white/16 hover:border-white/24',
isDragging && 'pointer-events-none'
)}
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
aria-label={`${scene.name} ${copy.common.select}`}

View File

@@ -0,0 +1,155 @@
import {
useCallback,
useEffect,
useRef,
useState,
type DragEvent as ReactDragEvent,
type PointerEvent as ReactPointerEvent,
} from 'react';
interface UseDragScrollOptions {
direction?: 'horizontal' | 'vertical' | 'both';
speed?: number;
}
export const useDragScroll = ({ direction = 'horizontal', speed = 1.5 }: UseDragScrollOptions = {}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [shouldSuppressClick, setShouldSuppressClick] = useState(false);
const pointerIdRef = useRef<number | null>(null);
const dragActiveRef = useRef(false);
const suppressClickTimeoutRef = useRef<number | null>(null);
const startPos = useRef({ x: 0, y: 0 });
const scrollPos = useRef({ left: 0, top: 0 });
const clearSuppressClickTimeout = useCallback(() => {
if (suppressClickTimeoutRef.current !== null) {
window.clearTimeout(suppressClickTimeoutRef.current);
suppressClickTimeoutRef.current = null;
}
}, []);
const handleWindowPointerUpRef = useRef<(event: PointerEvent) => void>(() => {});
const handleWindowPointerCancelRef = useRef<(event: PointerEvent) => void>(() => {});
const removeWindowListeners = useCallback(() => {
window.removeEventListener('pointerup', handleWindowPointerUpRef.current);
window.removeEventListener('pointercancel', handleWindowPointerCancelRef.current);
}, []);
useEffect(() => {
return () => {
clearSuppressClickTimeout();
removeWindowListeners();
};
}, [clearSuppressClickTimeout, removeWindowListeners]);
const finishInteraction = useCallback((keepClickSuppressed: boolean) => {
pointerIdRef.current = null;
dragActiveRef.current = false;
setIsDragging(false);
removeWindowListeners();
clearSuppressClickTimeout();
if (!keepClickSuppressed) {
setShouldSuppressClick(false);
return;
}
setShouldSuppressClick(true);
suppressClickTimeoutRef.current = window.setTimeout(() => {
setShouldSuppressClick(false);
suppressClickTimeoutRef.current = null;
}, 80);
}, [clearSuppressClickTimeout, removeWindowListeners]);
const handleWindowPointerUp = useCallback((event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) {
return;
}
finishInteraction(dragActiveRef.current);
}, [finishInteraction]);
const handleWindowPointerCancel = useCallback((event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) {
return;
}
finishInteraction(dragActiveRef.current);
}, [finishInteraction]);
useEffect(() => {
handleWindowPointerUpRef.current = handleWindowPointerUp;
handleWindowPointerCancelRef.current = handleWindowPointerCancel;
}, [handleWindowPointerCancel, handleWindowPointerUp]);
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
if (!containerRef.current) {
return;
}
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
clearSuppressClickTimeout();
setShouldSuppressClick(false);
pointerIdRef.current = e.pointerId;
dragActiveRef.current = false;
startPos.current = {
x: e.clientX,
y: e.clientY,
};
scrollPos.current = {
left: containerRef.current.scrollLeft,
top: containerRef.current.scrollTop,
};
removeWindowListeners();
window.addEventListener('pointerup', handleWindowPointerUpRef.current);
window.addEventListener('pointercancel', handleWindowPointerCancelRef.current);
}, [clearSuppressClickTimeout, removeWindowListeners]);
const onPointerMove = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
if (!containerRef.current || pointerIdRef.current !== e.pointerId) {
return;
}
const walkX = e.clientX - startPos.current.x;
const walkY = e.clientY - startPos.current.y;
if (!dragActiveRef.current && (Math.abs(walkX) > 5 || Math.abs(walkY) > 5)) {
dragActiveRef.current = true;
setIsDragging(true);
setShouldSuppressClick(true);
}
if (!dragActiveRef.current) {
return;
}
e.preventDefault();
if (direction === 'horizontal' || direction === 'both') {
containerRef.current.scrollLeft = scrollPos.current.left - walkX * speed;
}
if (direction === 'vertical' || direction === 'both') {
containerRef.current.scrollTop = scrollPos.current.top - walkY * speed;
}
}, [direction, speed]);
return {
containerRef,
events: {
onPointerDown,
onPointerMove,
onDragStart: (e: ReactDragEvent<HTMLDivElement>) => e.preventDefault(),
},
isDragging,
shouldSuppressClick,
};
};

View File

@@ -11,6 +11,7 @@ import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
import { useDragScroll } from '@/shared/lib/useDragScroll';
import { Toggle } from '@/shared/ui';
interface ControlCenterSheetWidgetProps {
@@ -73,16 +74,26 @@ export const ControlCenterSheetWidget = ({
return scenes.find((scene) => scene.id === selectedSceneId) ?? scenes[0];
}, [scenes, selectedSceneId]);
const {
containerRef: sceneContainerRef,
events: sceneDragEvents,
isDragging: isSceneDragging,
shouldSuppressClick: shouldSuppressSceneClick,
} = useDragScroll();
return (
<div className="space-y-4">
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
<SectionTitle title={controlCenter.sectionTitles.background} description={selectedScene?.name ?? copy.common.defaultBackground} />
<div
ref={sceneContainerRef}
{...sceneDragEvents}
className={cn(
'-mx-1 flex gap-2.5 overflow-x-auto px-1 pb-1.5 snap-x snap-mandatory scrollbar-none',
'-mx-1 flex gap-2.5 overflow-x-auto px-1 pb-1.5 scrollbar-none',
isSceneDragging ? 'cursor-grabbing' : 'cursor-grab',
reducedMotion ? '' : 'scroll-smooth',
)}
style={{ scrollBehavior: reducedMotion ? 'auto' : 'smooth' }}
style={{ scrollBehavior: isSceneDragging ? 'auto' : (reducedMotion ? 'auto' : 'smooth') }}
>
{scenes.slice(0, 6).map((scene) => {
const selected = scene.id === selectedSceneId;
@@ -92,13 +103,16 @@ export const ControlCenterSheetWidget = ({
key={scene.id}
type="button"
onClick={() => {
onSelectScene(scene.id);
if (!shouldSuppressSceneClick) {
onSelectScene(scene.id);
}
}}
className={cn(
'relative h-24 w-[130px] shrink-0 snap-start overflow-hidden rounded-xl border text-left',
'relative h-24 w-[130px] shrink-0 overflow-hidden rounded-xl border text-left',
interactiveMotionClass,
reducedMotion ? '' : 'hover:-translate-y-0.5',
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
isSceneDragging && 'pointer-events-none'
)}
>
<div

View File

@@ -18,6 +18,7 @@ import { useFocusStats } from '@/features/stats';
import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import { useDragScroll } from '@/shared/lib/useDragScroll';
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
const FREE_MAX_ITEMS = 1;
@@ -108,6 +109,13 @@ export const FocusDashboardWidget = () => {
const microStepInputRef = useRef<HTMLInputElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const {
containerRef: sceneContainerRef,
events: sceneDragEvents,
isDragging: isSceneDragging,
shouldSuppressClick: shouldSuppressSceneClick,
} = useDragScroll();
const selectedScene = useMemo(() => getSceneById(selectedSceneId) ?? SCENE_THEMES[0], [selectedSceneId]);
const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS;
@@ -446,15 +454,27 @@ export const FocusDashboardWidget = () => {
{/* Scene */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80">배경 공간</p>
<div className="flex gap-3 overflow-x-auto pb-2 snap-x hide-scrollbar">
<div
ref={sceneContainerRef}
{...sceneDragEvents}
className={cn(
"flex gap-3 overflow-x-auto pb-2 scrollbar-none",
isSceneDragging ? "cursor-grabbing" : "cursor-grab"
)}
>
{SCENE_THEMES.map(scene => (
<button
key={scene.id}
type="button"
onClick={() => setSelectedSceneId(scene.id)}
onClick={() => {
if (!shouldSuppressSceneClick) {
setSelectedSceneId(scene.id);
}
}}
className={cn(
'group relative h-24 min-w-[120px] rounded-xl border border-white/10 text-left snap-start transition-all overflow-hidden bg-white/5 active:scale-95 cursor-pointer',
selectedSceneId === scene.id && 'border-white/40 shadow-[0_0_20px_rgba(255,255,255,0.1)]'
'group relative h-24 min-w-[120px] rounded-xl border border-white/10 text-left transition-all overflow-hidden bg-white/5 active:scale-95',
selectedSceneId === scene.id && 'border-white/40 shadow-[0_0_20px_rgba(255,255,255,0.1)]',
isSceneDragging && 'pointer-events-none'
)}
style={getSceneStageBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
>
@@ -559,4 +579,4 @@ export const FocusDashboardWidget = () => {
) : null}
</div>
);
};
};