From 7d6ce3995c2327b32710c44c2e51e11197a6d796 Mon Sep 17 00:00:00 2001 From: dnviti Date: Thu, 18 Dec 2025 00:29:43 +0100 Subject: [PATCH] feat: Introduce custom global context menu for text inputs, refine card touch interactions, and apply global user-select and scrollbar styles. --- src/client/src/App.tsx | 2 + .../src/components/GlobalContextMenu.tsx | 183 ++++++++++++++++++ src/client/src/main.tsx | 2 + src/client/src/modules/cube/CubeManager.tsx | 142 ++++++++------ .../src/modules/draft/DeckBuilderView.tsx | 20 +- src/client/src/modules/draft/DraftView.tsx | 61 ++++-- src/client/src/styles/main.css | 38 ++++ src/client/src/utils/interaction.ts | 36 ++-- 8 files changed, 396 insertions(+), 88 deletions(-) create mode 100644 src/client/src/components/GlobalContextMenu.tsx diff --git a/src/client/src/App.tsx b/src/client/src/App.tsx index b8fc2eb..702ce90 100644 --- a/src/client/src/App.tsx +++ b/src/client/src/App.tsx @@ -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 ( +
diff --git a/src/client/src/components/GlobalContextMenu.tsx b/src/client/src/components/GlobalContextMenu.tsx new file mode 100644 index 0000000..f93f423 --- /dev/null +++ b/src/client/src/components/GlobalContextMenu.tsx @@ -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({ x: 0, y: 0 }); + const [targetElement, setTargetElement] = useState(null); + const menuRef = useRef(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 ( +
+ + + +
+ ); +}; diff --git a/src/client/src/main.tsx b/src/client/src/main.tsx index eb5d7e2..702a2da 100644 --- a/src/client/src/main.tsx +++ b/src/client/src/main.tsx @@ -5,6 +5,8 @@ import './styles/main.css'; const rootElement = document.getElementById('root'); + + if (rootElement) { const root = createRoot(rootElement); root.render( diff --git a/src/client/src/modules/cube/CubeManager.tsx b/src/client/src/modules/cube/CubeManager.tsx index 54722cf..e9549cc 100644 --- a/src/client/src/modules/cube/CubeManager.tsx +++ b/src/client/src/modules/cube/CubeManager.tsx @@ -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 = ({ packs, setPacks, avail
-
- {/* Play Button */} +
+ {/* Actions Menu */} {packs.length > 0 && ( <> - - - - +
+ + + {/* Dropdown */} +
+ + {/* Play Online */} + + +
+ + {/* Test Solo */} + + + {/* Export */} + + + {/* Copy */} + + +
+
+ {/* Size Slider */}
@@ -830,26 +858,28 @@ export const CubeManager: React.FC = ({ packs, setPacks, avail
- {packs.length === 0 ? ( -
- -

No packs generated.

-
- ) : ( -
- {packs.map((pack) => ( - - ))} -
- )} -
+ { + packs.length === 0 ? ( +
+ +

No packs generated.

+
+ ) : ( +
+ {packs.map((pack) => ( + + ))} +
+ ) + } +
); diff --git a/src/client/src/modules/draft/DeckBuilderView.tsx b/src/client/src/modules/draft/DeckBuilderView.tsx index 375eb94..e664bf1 100644 --- a/src/client/src/modules/draft/DeckBuilderView.tsx +++ b/src/client/src/modules/draft/DeckBuilderView.tsx @@ -39,7 +39,7 @@ const DraggableCardWrapper = ({ children, card, source, disabled }: any) => { } : undefined; return ( -
+
{children}
); @@ -61,7 +61,7 @@ const DraggableLandWrapper = ({ children, land }: any) => { } : undefined; return ( -
+
{children}
); @@ -345,7 +345,12 @@ export const DeckBuilderView: React.FC = ({ 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(null); @@ -591,9 +596,12 @@ export const DeckBuilderView: React.FC = ({ initialPool, a )}
- + {draggedCard ? ( -
+
{draggedCard.name}
) : 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" >
{isFoil && } diff --git a/src/client/src/modules/draft/DraftView.tsx b/src/client/src/modules/draft/DraftView.tsx index fcc7bd3..9163936 100644 --- a/src/client/src/modules/draft/DraftView.tsx +++ b/src/client/src/modules/draft/DraftView.tsx @@ -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 ( -
+
{children}
); @@ -128,7 +128,12 @@ export const DraftView: React.FC = ({ 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(null); @@ -408,20 +413,36 @@ export const DraftView: React.FC = ({ draftState, currentPlayerI {/* Drag Overlay */} {draggedCard ? ( -
+
{draggedCard.name}
) : null} -
+ + {/* Mobile Full Screen Preview (triggered by 2-finger long press) */} + { + hoveredCard && ( +
+ +
+ ) + } +
); }; 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 (
setHoveredCard(card)} onMouseLeave={() => setHoveredCard(null)} - onTouchStart={onTouchStart} - onTouchEnd={onTouchEnd} - onTouchMove={onTouchMove} > {/* Foil Glow Effect */} {isFoil &&
} @@ -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 (
void, @@ -13,40 +13,48 @@ export function useCardTouch( ) { const timerRef = useRef(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]);