From 79a44173d0743315b18efeab0e1e9dcf246ebd7c Mon Sep 17 00:00:00 2001 From: dnviti Date: Wed, 17 Dec 2025 18:47:48 +0100 Subject: [PATCH] feat: Implement `useCardTouch` hook to standardize card interaction and touch event handling across components. --- src/client/src/components/CardPreview.tsx | 2 +- src/client/src/components/StackView.tsx | 64 +++++++---- .../src/modules/draft/DeckBuilderView.tsx | 64 +++++++---- src/client/src/modules/draft/DraftView.tsx | 105 +++++++++++------- src/client/src/utils/interaction.ts | 59 ++++++++++ 5 files changed, 210 insertions(+), 84 deletions(-) create mode 100644 src/client/src/utils/interaction.ts diff --git a/src/client/src/components/CardPreview.tsx b/src/client/src/components/CardPreview.tsx index 8e5266f..11560f7 100644 --- a/src/client/src/components/CardPreview.tsx +++ b/src/client/src/components/CardPreview.tsx @@ -144,7 +144,7 @@ export const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.React }; const handleTouchStart = (e: React.TouchEvent) => { - if (!hasImage || !isMobile) return; + if (!hasImage || !isMobile || preventPreview) return; const touch = e.touches[0]; const { clientX, clientY } = touch; diff --git a/src/client/src/components/StackView.tsx b/src/client/src/components/StackView.tsx index 1072d5e..866d9f2 100644 --- a/src/client/src/components/StackView.tsx +++ b/src/client/src/components/StackView.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { DraftCard } from '../services/PackGeneratorService'; import { FoilOverlay, CardHoverWrapper } from './CardPreview'; +import { useCardTouch } from '../utils/interaction'; interface StackViewProps { cards: DraftCard[]; @@ -80,30 +81,18 @@ export const StackView: React.FC = ({ cards, cardWidth = 150, on const displayImage = useArtCrop ? card.imageArtCrop : card.image; return ( -
onHover && onHover(card)} - onMouseLeave={() => onHover && onHover(null)} - onClick={() => onCardClick && onCardClick(card)} - > - = 200}> -
- {card.name} - {/* Optional: Shine effect for foils if visible? */} - {card.finish === 'foil' && } -
-
-
- ) + card={card} + cardWidth={cardWidth} + isLast={isLast} + useArtCrop={useArtCrop} + displayImage={displayImage} + onHover={onHover} + onCardClick={onCardClick} + disableHoverPreview={disableHoverPreview} + /> + ); })} @@ -112,3 +101,32 @@ export const StackView: React.FC = ({ cards, cardWidth = 150, on ); }; + +const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHover, onCardClick, disableHoverPreview }: any) => { + const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover || (() => { }), () => onCardClick && onCardClick(card), card); + + return ( +
onHover && onHover(card)} + onMouseLeave={() => onHover && onHover(null)} + onClick={onClick} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} + onTouchMove={onTouchMove} + > + = 200}> +
+ {card.name} + {card.finish === 'foil' && } +
+
+
+ ); +}; diff --git a/src/client/src/modules/draft/DeckBuilderView.tsx b/src/client/src/modules/draft/DeckBuilderView.tsx index 22b5744..b15fd43 100644 --- a/src/client/src/modules/draft/DeckBuilderView.tsx +++ b/src/client/src/modules/draft/DeckBuilderView.tsx @@ -4,6 +4,7 @@ import { Save, Layers, Clock, Columns, LayoutTemplate, List, LayoutGrid } from ' import { StackView } from '../../components/StackView'; import { FoilOverlay } from '../../components/CardPreview'; import { DraftCard } from '../../services/PackGeneratorService'; +import { useCardTouch } from '../../utils/interaction'; interface DeckBuilderViewProps { roomId: string; @@ -34,11 +35,16 @@ const ListItem: React.FC<{ card: DraftCard; onClick?: () => void; onHover?: (c: } }; + const { onTouchStart, onTouchEnd, onTouchMove, onClick: handleTouchClick } = useCardTouch(onHover || (() => { }), onClick || (() => { }), card); + return (
onHover && onHover(card)} onMouseLeave={() => onHover && onHover(null)} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} + onTouchMove={onTouchMove} className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer transition-colors w-full group" > @@ -109,28 +115,18 @@ const CardsDisplay: React.FC<{ {cards.map(c => { const card = normalizeCard(c); const useArtCrop = cardWidth < 200 && !!card.imageArtCrop; - const displayImage = useArtCrop ? card.imageArtCrop : card.image; + const isFoil = card.finish === 'foil'; return ( -
onCardClick(c)} - onMouseEnter={() => onHover(card)} - onMouseLeave={() => onHover(null)} - className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform" - > -
- {isFoil && } - {isFoil &&
FOIL
} - {displayImage ? ( - {card.name} - ) : ( -
{card.name}
- )} -
-
-
+ card={card} + useArtCrop={useArtCrop} + isFoil={isFoil} + onCardClick={onCardClick} + onHover={onHover} + /> ); })}
@@ -341,7 +337,7 @@ export const DeckBuilderView: React.FC = ({ initialPool, a ); return ( -
+
e.preventDefault()}> {/* Global Toolbar - Inlined */}
@@ -491,3 +487,31 @@ export const DeckBuilderView: React.FC = ({ initialPool, a
); }; + +const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) => { + const displayImage = useArtCrop ? card.imageArtCrop : card.image; + const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(onHover, () => onCardClick(card), card); + + return ( +
onHover(card)} + onMouseLeave={() => onHover(null)} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} + onTouchMove={onTouchMove} + className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform" + > +
+ {isFoil && } + {isFoil &&
FOIL
} + {displayImage ? ( + {card.name} + ) : ( +
{card.name}
+ )} +
+
+
+ ); +}; diff --git a/src/client/src/modules/draft/DraftView.tsx b/src/client/src/modules/draft/DraftView.tsx index e99613d..b0b59ce 100644 --- a/src/client/src/modules/draft/DraftView.tsx +++ b/src/client/src/modules/draft/DraftView.tsx @@ -4,6 +4,7 @@ import { socketService } from '../../services/SocketService'; import { LogOut } from 'lucide-react'; import { Modal } from '../../components/Modal'; import { FoilOverlay } from '../../components/CardPreview'; +import { useCardTouch } from '../../utils/interaction'; // Helper to normalize card data for visuals const normalizeCard = (c: any) => ({ @@ -247,34 +248,15 @@ export const DraftView: React.FC = ({ draftState, currentPlayerI

Select a Card

- {activePack.cards.map((rawCard: any) => { - const card = normalizeCard(rawCard); - const isFoil = card.finish === 'foil'; - - return ( -
handlePick(card.id)} - onMouseEnter={() => setHoveredCard(card)} - onMouseLeave={() => setHoveredCard(null)} - > - {/* Foil Glow Effect */} - {isFoil &&
} - -
- {card.name} - {isFoil && } - {isFoil &&
FOIL
} -
-
- ); - })} + {activePack.cards.map((rawCard: any) => ( + + ))}
)} @@ -303,18 +285,7 @@ export const DraftView: React.FC = ({ draftState, currentPlayerI
{pickedCards.map((card: any, idx: number) => ( -
setHoveredCard(card)} - onMouseLeave={() => setHoveredCard(null)} - > - {card.name} -
+ ))}
@@ -331,3 +302,57 @@ export const DraftView: React.FC = ({ draftState, currentPlayerI
); }; + +const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any) => { + const card = normalizeCard(rawCard); + const isFoil = card.finish === 'foil'; + const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => handlePick(card.id), card); + + return ( +
setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} + onTouchMove={onTouchMove} + > + {/* Foil Glow Effect */} + {isFoil &&
} + +
+ {card.name} + {isFoil && } + {isFoil &&
FOIL
} +
+
+ ); +}; + +const PoolCardItem = ({ card, setHoveredCard }: any) => { + const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => { }, card); + + return ( +
setHoveredCard(card)} + onMouseLeave={() => setHoveredCard(null)} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} + onTouchMove={onTouchMove} + onClick={onClick} + > + {card.name} +
+ ) +}; diff --git a/src/client/src/utils/interaction.ts b/src/client/src/utils/interaction.ts new file mode 100644 index 0000000..89784e6 --- /dev/null +++ b/src/client/src/utils/interaction.ts @@ -0,0 +1,59 @@ +import { useRef, useCallback } from 'react'; + +/** + * Hook to handle touch interactions for cards (Long Press for Preview). + * - Tap: Click + * - Long Press: Preview (Hover) + * - Drag/Scroll: Cancel + */ +export function useCardTouch( + onHover: (card: any | null) => void, + onClick: () => void, + cardPayload: any +) { + const timerRef = useRef(null); + const isLongPress = useRef(false); + + const handleTouchStart = useCallback(() => { + isLongPress.current = false; + timerRef.current = setTimeout(() => { + isLongPress.current = true; + onHover(cardPayload); + }, 400); // 400ms threshold + }, [onHover, cardPayload]); + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + if (timerRef.current) clearTimeout(timerRef.current); + + if (isLongPress.current) { + if (e.cancelable) e.preventDefault(); + onHover(null); // Clear preview on release, mimicking "hover out" + } + }, [onHover]); + + const handleTouchMove = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + // If we were already previewing? + // If user moves finger while holding, maybe we should effectively cancel the "click" potential too? + // Usually moving means scrolling. + isLongPress.current = false; // ensure we validly cancel any queued longpress action + } + }, []); + + const handleClick = useCallback((e: React.MouseEvent) => { + if (isLongPress.current) { + e.preventDefault(); + e.stopPropagation(); + return; + } + onClick(); + }, [onClick]); + + return { + onTouchStart: handleTouchStart, + onTouchEnd: handleTouchEnd, + onTouchMove: handleTouchMove, + onClick: handleClick + }; +}