feat: Introduce custom global context menu for text inputs, refine card touch interactions, and apply global user-select and scrollbar styles.
This commit is contained in:
@@ -6,6 +6,7 @@ import { LobbyManager } from './modules/lobby/LobbyManager';
|
||||
import { DeckTester } from './modules/tester/DeckTester';
|
||||
import { Pack } from './services/PackGeneratorService';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { GlobalContextMenu } from './components/GlobalContextMenu';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
|
||||
@@ -68,6 +69,7 @@ export const App: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<GlobalContextMenu />
|
||||
<div className="h-screen flex flex-col bg-slate-900 text-slate-100 font-sans overflow-hidden">
|
||||
<header className="bg-slate-800 border-b border-slate-700 p-4 shrink-0 z-50 shadow-lg">
|
||||
<div className="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
|
||||
183
src/client/src/components/GlobalContextMenu.tsx
Normal file
183
src/client/src/components/GlobalContextMenu.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Copy, Scissors, Clipboard } from 'lucide-react';
|
||||
|
||||
interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const GlobalContextMenu: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState<MenuPosition>({ x: 0, y: 0 });
|
||||
const [targetElement, setTargetElement] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Check if target is an input or textarea
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
const inputTarget = target as HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
// Only allow text-based inputs (ignore range, checkbox, etc.)
|
||||
if (target.tagName === 'INPUT') {
|
||||
const type = (target as HTMLInputElement).type;
|
||||
if (!['text', 'password', 'email', 'number', 'search', 'tel', 'url'].includes(type)) {
|
||||
e.preventDefault();
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setTargetElement(inputTarget);
|
||||
|
||||
// Position menu within viewport
|
||||
const menuWidth = 150;
|
||||
const menuHeight = 120; // approx
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
|
||||
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 10;
|
||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 10;
|
||||
|
||||
setPosition({ x, y });
|
||||
setVisible(true);
|
||||
} else {
|
||||
// Disable context menu for everything else
|
||||
e.preventDefault();
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
// Close menu on any click outside
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Use capture to ensure we intercept early
|
||||
document.addEventListener('contextmenu', handleContextMenu);
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('scroll', () => setVisible(false)); // Close on scroll
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu);
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!targetElement) return;
|
||||
const text = targetElement.value.substring(targetElement.selectionStart || 0, targetElement.selectionEnd || 0);
|
||||
if (text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
setVisible(false);
|
||||
targetElement.focus();
|
||||
};
|
||||
|
||||
const handleCut = async () => {
|
||||
if (!targetElement) return;
|
||||
const start = targetElement.selectionStart || 0;
|
||||
const end = targetElement.selectionEnd || 0;
|
||||
const text = targetElement.value.substring(start, end);
|
||||
|
||||
if (text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
// Update value
|
||||
const newVal = targetElement.value.slice(0, start) + targetElement.value.slice(end);
|
||||
|
||||
// React state update hack: Trigger native value setter and event
|
||||
// This ensures React controlled components update their state
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(targetElement, newVal);
|
||||
} else {
|
||||
targetElement.value = newVal;
|
||||
}
|
||||
|
||||
const event = new Event('input', { bubbles: true });
|
||||
targetElement.dispatchEvent(event);
|
||||
}
|
||||
setVisible(false);
|
||||
targetElement.focus();
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
if (!targetElement) return;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (!text) return;
|
||||
|
||||
const start = targetElement.selectionStart || 0;
|
||||
const end = targetElement.selectionEnd || 0;
|
||||
|
||||
const currentVal = targetElement.value;
|
||||
const newVal = currentVal.slice(0, start) + text + currentVal.slice(end);
|
||||
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)?.set;
|
||||
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(targetElement, newVal);
|
||||
} else {
|
||||
targetElement.value = newVal;
|
||||
}
|
||||
|
||||
const event = new Event('input', { bubbles: true });
|
||||
targetElement.dispatchEvent(event);
|
||||
|
||||
// Move cursor
|
||||
// Timeout needed for React to process input event first
|
||||
setTimeout(() => {
|
||||
targetElement.setSelectionRange(start + text.length, start + text.length);
|
||||
}, 0);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to read clipboard', err);
|
||||
}
|
||||
setVisible(false);
|
||||
targetElement.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[10000] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl py-1 w-36 overflow-hidden animate-in fade-in zoom-in duration-75"
|
||||
style={{ top: position.y, left: position.x }}
|
||||
>
|
||||
<button
|
||||
onClick={handleCut}
|
||||
className="w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 hover:text-white flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||
disabled={!targetElement?.value || targetElement?.selectionStart === targetElement?.selectionEnd}
|
||||
>
|
||||
<Scissors className="w-4 h-4" /> Cut
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 hover:text-white flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||
disabled={!targetElement?.value || targetElement?.selectionStart === targetElement?.selectionEnd}
|
||||
>
|
||||
<Copy className="w-4 h-4" /> Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePaste}
|
||||
className="w-full text-left px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 hover:text-white flex items-center gap-2 transition-colors border-t border-slate-700 mt-1 pt-2"
|
||||
>
|
||||
<Clipboard className="w-4 h-4" /> Paste
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,8 @@ import './styles/main.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
|
||||
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2, Search, X, PlayCircle, Plus, Minus } from 'lucide-react';
|
||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2, Search, X, PlayCircle, Plus, Minus, ChevronDown, MoreHorizontal } from 'lucide-react';
|
||||
import { ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||
import { PackCard } from '../../components/PackCard';
|
||||
@@ -766,43 +766,71 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto justify-end overflow-x-auto pb-1 sm:pb-0">
|
||||
{/* Play Button */}
|
||||
<div className="flex gap-2 w-full sm:w-auto justify-end">
|
||||
{/* Actions Menu */}
|
||||
{packs.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePlayOnline}
|
||||
className={`px-4 py-2 font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in whitespace-nowrap transition-colors
|
||||
${packs.length < 12
|
||||
? 'bg-slate-700 text-slate-400 cursor-not-allowed hover:bg-slate-600'
|
||||
: 'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartSoloTest}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
||||
title="Test a randomized deck from these packs right now"
|
||||
>
|
||||
<PlayCircle className="w-4 h-4 text-emerald-400" /> <span className="hidden sm:inline">Test Solo</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
||||
title="Export as CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" /> <span className="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyCsv}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
||||
title="Copy CSV to Clipboard"
|
||||
>
|
||||
{copySuccess ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{copySuccess ? 'Copied!' : 'Copy'}</span>
|
||||
</button>
|
||||
<div className="relative group z-50">
|
||||
<button className="px-4 py-2 bg-gradient-to-r from-slate-700 to-slate-800 hover:from-slate-600 hover:to-slate-700 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 transition-all ring-1 ring-white/10">
|
||||
<MoreHorizontal className="w-4 h-4 text-emerald-400" /> <span className="hidden sm:inline">Actions</span> <ChevronDown className="w-4 h-4 text-slate-400 group-hover:rotate-180 transition-transform" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-slate-800 border border-slate-700 rounded-xl shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 transform origin-top-right p-2 flex flex-col gap-2 z-[9999]">
|
||||
|
||||
{/* Play Online */}
|
||||
<button
|
||||
onClick={handlePlayOnline}
|
||||
className={`w-full text-left px-3 py-3 rounded-lg flex items-center gap-3 transition-all shadow-md ${packs.length < 12
|
||||
? 'bg-slate-700 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white shadow-purple-900/20'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-5 h-5 shrink-0" />
|
||||
<div>
|
||||
<span className="block text-sm font-bold leading-tight">Play Online</span>
|
||||
<span className={`block text-[10px] leading-tight mt-0.5 ${packs.length < 12 ? 'text-slate-500' : 'text-purple-100'}`}>
|
||||
Start a multiplayer draft
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-slate-700/50 mx-1" />
|
||||
|
||||
{/* Test Solo */}
|
||||
<button
|
||||
onClick={handleStartSoloTest}
|
||||
disabled={loading}
|
||||
className="w-full text-left px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg flex items-center gap-3 transition-colors shadow-sm"
|
||||
>
|
||||
<PlayCircle className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
<div>
|
||||
<span className="block text-sm font-bold">Test Solo</span>
|
||||
<span className="block text-[10px] text-slate-400 leading-none mt-0.5">Draft against bots</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Export */}
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
className="w-full text-left px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg flex items-center gap-3 transition-colors shadow-sm"
|
||||
>
|
||||
<Download className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
<span className="text-sm font-bold">Export CSV</span>
|
||||
</button>
|
||||
|
||||
{/* Copy */}
|
||||
<button
|
||||
onClick={handleCopyCsv}
|
||||
className="w-full text-left px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg flex items-center gap-3 transition-colors shadow-sm"
|
||||
>
|
||||
{copySuccess ? <Check className="w-4 h-4 text-emerald-400 shrink-0" /> : <Copy className="w-4 h-4 text-slate-400 shrink-0" />}
|
||||
<span className="text-sm font-bold">{copySuccess ? 'Copied!' : 'Copy List'}</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Size Slider */}
|
||||
<div className="flex items-center gap-2 bg-slate-800 rounded-lg px-2 py-1 border border-slate-700 h-9 mr-2 flex">
|
||||
@@ -830,26 +858,28 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, avail
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{packs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-slate-700 rounded-2xl bg-slate-800/30 text-slate-500">
|
||||
<Box className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>No packs generated.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-6 pb-20"
|
||||
style={{
|
||||
gridTemplateColumns: cardWidth <= 150
|
||||
? `repeat(auto-fill, minmax(${viewMode === 'list' ? '320px' : '550px'}, 1fr))`
|
||||
: '1fr'
|
||||
}}
|
||||
>
|
||||
{packs.map((pack) => (
|
||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={cardWidth} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
packs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed border-slate-700 rounded-2xl bg-slate-800/30 text-slate-500">
|
||||
<Box className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p>No packs generated.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-6 pb-20"
|
||||
style={{
|
||||
gridTemplateColumns: cardWidth <= 150
|
||||
? `repeat(auto-fill, minmax(${viewMode === 'list' ? '320px' : '550px'}, 1fr))`
|
||||
: '1fr'
|
||||
}}
|
||||
>
|
||||
{packs.map((pack) => (
|
||||
<PackCard key={pack.id} pack={pack} viewMode={viewMode} cardWidth={cardWidth} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
|
||||
</div >
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ const DraggableCardWrapper = ({ children, card, source, disabled }: any) => {
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="touch-none">
|
||||
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="relative z-0">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -61,7 +61,7 @@ const DraggableLandWrapper = ({ children, land }: any) => {
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="touch-none">
|
||||
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className="relative z-0">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -345,7 +345,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
// --- DnD Handlers ---
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { distance: 10 } })
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [draggedCard, setDraggedCard] = useState<any>(null);
|
||||
@@ -591,9 +596,12 @@ export const DeckBuilderView: React.FC<DeckBuilderViewProps> = ({ initialPool, a
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedCard ? (
|
||||
<div className={`w-36 rounded-xl shadow-2xl opacity-90 rotate-3 cursor-grabbing overflow-hidden ring-2 ring-emerald-500 bg-slate-900 aspect-[2.5/3.5]`}>
|
||||
<div
|
||||
style={{ width: `${cardWidth}px` }}
|
||||
className={`rounded-xl shadow-2xl opacity-90 rotate-3 cursor-grabbing overflow-hidden ring-2 ring-emerald-500 bg-slate-900 aspect-[2.5/3.5]`}
|
||||
>
|
||||
<img src={draggedCard.image || draggedCard.image_uris?.normal} alt={draggedCard.name} className="w-full h-full object-cover" draggable={false} />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -615,7 +623,7 @@ const DeckCardItem = ({ card, useArtCrop, isFoil, onCardClick, onHover }: any) =
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchMove={onTouchMove}
|
||||
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform touch-none"
|
||||
className="relative group bg-slate-900 rounded-lg shrink-0 cursor-pointer hover:scale-105 transition-transform"
|
||||
>
|
||||
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 ${isFoil ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
|
||||
{isFoil && <FoilOverlay />}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { LogOut, Columns, LayoutTemplate } from 'lucide-react';
|
||||
import { Modal } from '../../components/Modal';
|
||||
import { FoilOverlay } from '../../components/CardPreview';
|
||||
import { FoilOverlay, FloatingPreview } from '../../components/CardPreview';
|
||||
import { useCardTouch } from '../../utils/interaction';
|
||||
import { DndContext, DragOverlay, useSensor, useSensors, MouseSensor, TouchSensor, DragStartEvent, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
@@ -22,7 +22,7 @@ const PoolDroppable = ({ children, className, style }: any) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={`${className} ${isOver ? 'ring-4 ring-emerald-500/50 bg-emerald-900/20' : ''}`} style={style}>
|
||||
<div ref={setNodeRef} className={`${className} ${isOver ? 'ring-4 ring-emerald-500/50 bg-emerald-900/20' : ''}`} style={{ ...style, touchAction: 'none' }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -128,7 +128,12 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { distance: 10 } })
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [draggedCard, setDraggedCard] = useState<any>(null);
|
||||
@@ -408,20 +413,36 @@ export const DraftView: React.FC<DraftViewProps> = ({ draftState, currentPlayerI
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedCard ? (
|
||||
<div className="w-32 h-44 opacity-90 rotate-3 cursor-grabbing shadow-2xl">
|
||||
<div
|
||||
className="opacity-90 rotate-3 cursor-grabbing shadow-2xl rounded-xl"
|
||||
style={{ width: `${14 * cardScale}rem`, aspectRatio: '2.5/3.5' }}
|
||||
>
|
||||
<img src={draggedCard.image} alt={draggedCard.name} className="w-full h-full object-cover rounded-xl" draggable={false} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Mobile Full Screen Preview (triggered by 2-finger long press) */}
|
||||
{
|
||||
hoveredCard && (
|
||||
<div className="lg:hidden">
|
||||
<FloatingPreview card={hoveredCard} x={0} y={0} isMobile={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => {
|
||||
// Disable tap-to-pick on touch devices, rely on Drag and Drop
|
||||
if (window.matchMedia('(pointer: coarse)').matches) return;
|
||||
handlePick(card.id);
|
||||
}, card);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: card.id,
|
||||
@@ -433,19 +454,33 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
|
||||
opacity: isDragging ? 0 : 1, // Hide original when dragging
|
||||
} : undefined;
|
||||
|
||||
// Merge listeners to avoid overriding dnd-kit's TouchSensor
|
||||
const mergedListeners = {
|
||||
...listeners,
|
||||
onTouchStart: (e: any) => {
|
||||
listeners?.onTouchStart?.(e);
|
||||
onTouchStart(e);
|
||||
},
|
||||
onTouchEnd: (e: any) => {
|
||||
listeners?.onTouchEnd?.(e);
|
||||
onTouchEnd(e);
|
||||
},
|
||||
onTouchMove: (e: any) => {
|
||||
listeners?.onTouchMove?.(e);
|
||||
onTouchMove();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, width: `${14 * cardScale}rem` }}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer touch-none"
|
||||
{...mergedListeners}
|
||||
className="group relative transition-all duration-300 hover:scale-110 hover:-translate-y-4 hover:z-50 cursor-pointer"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchMove={onTouchMove}
|
||||
>
|
||||
{/* Foil Glow Effect */}
|
||||
{isFoil && <div className="absolute inset-0 -m-1 rounded-xl bg-purple-500 blur-md opacity-20 group-hover:opacity-60 transition-opacity duration-300 animate-pulse"></div>}
|
||||
@@ -465,7 +500,9 @@ const DraftCardItem = ({ rawCard, cardScale, handlePick, setHoveredCard }: any)
|
||||
};
|
||||
|
||||
const PoolCardItem = ({ card, setHoveredCard, vertical = false }: any) => {
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => { }, card);
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onClick } = useCardTouch(setHoveredCard, () => {
|
||||
if (window.matchMedia('(pointer: coarse)').matches) return;
|
||||
}, card);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -61,3 +61,41 @@
|
||||
50% { background-position: 100% 50%, 100% 100%; }
|
||||
100% { background-position: 0% 50%, 0% 0%; }
|
||||
}
|
||||
|
||||
/* Global interaction resets */
|
||||
body {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
user-drag: none;
|
||||
}
|
||||
|
||||
/* Allow selection in inputs and textareas */
|
||||
input, textarea {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0f172a; /* slate-900 */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #334155; /* slate-700 */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569; /* slate-600 */
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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
|
||||
* Hook to handle touch interactions for cards.
|
||||
* - Tap: Click (can be disabled by caller)
|
||||
* - 1-Finger Long Press: Drag (handled externally by dnd-kit usually, so we ignore here)
|
||||
* - 2-Finger Long Press: Preview (onHover)
|
||||
*/
|
||||
export function useCardTouch(
|
||||
onHover: (card: any | null) => void,
|
||||
@@ -13,40 +13,48 @@ export function useCardTouch(
|
||||
) {
|
||||
const timerRef = useRef<any>(null);
|
||||
const isLongPress = useRef(false);
|
||||
const touchStartCount = useRef(0);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartCount.current = e.touches.length;
|
||||
isLongPress.current = false;
|
||||
timerRef.current = setTimeout(() => {
|
||||
isLongPress.current = true;
|
||||
onHover(cardPayload);
|
||||
}, 400); // 400ms threshold
|
||||
|
||||
// Only Start "Preview" Timer if 2 fingers
|
||||
if (e.touches.length === 2) {
|
||||
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 it was a 2-finger long press, clear hover on release
|
||||
if (isLongPress.current) {
|
||||
if (e.cancelable) e.preventDefault();
|
||||
onHover(null); // Clear preview on release, mimicking "hover out"
|
||||
onHover(null);
|
||||
isLongPress.current = false;
|
||||
return;
|
||||
}
|
||||
}, [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
|
||||
isLongPress.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
// If it was a long press, block click
|
||||
if (isLongPress.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
// Simple click
|
||||
onClick();
|
||||
}, [onClick]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user