Skip to content

Commit 0dc7938

Browse files
author
Top-5
committed
Implement custom drag-and-drop for cards with proper positioning and visual feedback
1 parent aeb09bb commit 0dc7938

File tree

2 files changed

+196
-38
lines changed

2 files changed

+196
-38
lines changed

src/App.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,17 @@ h1 {
157157
.card[draggable="true"] {
158158
cursor: grab;
159159
transition: opacity 0.2s;
160+
-webkit-user-drag: element;
160161
}
161162

162163
.card[draggable="true"]:active {
163164
cursor: grabbing;
164165
}
165166

167+
.card-dragging {
168+
opacity: 0 !important;
169+
}
170+
166171
.card-flying {
167172
animation: flyToFoundation 0.4s ease-in-out forwards;
168173
z-index: 1000;

src/App.tsx

Lines changed: 191 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,22 @@ interface DragData {
1010
cardIndex?: number;
1111
}
1212

13+
interface DragState {
14+
isDragging: boolean;
15+
cards: Card[];
16+
startX: number;
17+
startY: number;
18+
currentX: number;
19+
currentY: number;
20+
sourceElement: HTMLElement | null;
21+
}
22+
1323
function App() {
1424
const [gameState, setGameState] = useState<GameState>(createInitialState());
1525
const [time, setTime] = useState(0);
1626
const [dragData, setDragData] = useState<DragData | null>(null);
1727
const [cardImages, setCardImages] = useState<CardImageMap | null>(null);
28+
const [customDrag, setCustomDrag] = useState<DragState | null>(null);
1829

1930
// Load card sprites on mount
2031
useEffect(() => {
@@ -30,6 +41,59 @@ function App() {
3041
return () => clearInterval(timer);
3142
}, [gameState.startTime]);
3243

44+
// Handle custom drag mouse move
45+
useEffect(() => {
46+
if (!customDrag?.isDragging) return;
47+
48+
const handleMouseMove = (e: MouseEvent) => {
49+
setCustomDrag(prev => prev ? {
50+
...prev,
51+
currentX: e.clientX,
52+
currentY: e.clientY
53+
} : null);
54+
};
55+
56+
const handleMouseUp = (e: MouseEvent) => {
57+
if (!customDrag) return;
58+
59+
// Reset cursor
60+
document.body.style.cursor = '';
61+
62+
// Find the drop target
63+
const target = document.elementFromPoint(e.clientX, e.clientY);
64+
const dropZone = target?.closest('.tableau-column, .card-pile.foundation');
65+
66+
if (dropZone && dragData) {
67+
// Determine target type and index
68+
if (dropZone.classList.contains('tableau-column')) {
69+
const columns = Array.from(document.querySelectorAll('.tableau-column'));
70+
const targetIndex = columns.indexOf(dropZone);
71+
if (targetIndex >= 0) {
72+
handleDrop('tableau', targetIndex);
73+
}
74+
} else if (dropZone.classList.contains('foundation')) {
75+
const foundations = Array.from(document.querySelectorAll('.card-pile.foundation'));
76+
const targetIndex = foundations.indexOf(dropZone);
77+
if (targetIndex >= 0) {
78+
handleDrop('foundation', targetIndex);
79+
}
80+
}
81+
}
82+
83+
// Clear custom drag
84+
setCustomDrag(null);
85+
setDragData(null);
86+
};
87+
88+
window.addEventListener('mousemove', handleMouseMove);
89+
window.addEventListener('mouseup', handleMouseUp);
90+
91+
return () => {
92+
window.removeEventListener('mousemove', handleMouseMove);
93+
window.removeEventListener('mouseup', handleMouseUp);
94+
};
95+
}, [customDrag, dragData]);
96+
3397
const handleNewGame = () => {
3498
setGameState(createInitialState());
3599
setTime(0);
@@ -44,6 +108,39 @@ function App() {
44108
setDragData({ source, index, cardIndex });
45109
};
46110

111+
const handleCustomDragStart = (
112+
e: React.MouseEvent,
113+
source: 'waste' | 'tableau',
114+
index: number,
115+
cardIndex?: number,
116+
cards?: Card[]
117+
) => {
118+
e.preventDefault();
119+
120+
if (!cards || cards.length === 0) return;
121+
122+
// Get the position of the clicked card element
123+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
124+
125+
// Calculate offset from card's top-left corner to mouse position
126+
const offsetX = e.clientX - rect.left;
127+
const offsetY = e.clientY - rect.top;
128+
129+
setDragData({ source, index, cardIndex });
130+
setCustomDrag({
131+
isDragging: true,
132+
cards: cards,
133+
startX: offsetX, // Store offset within the card
134+
startY: offsetY,
135+
currentX: e.clientX,
136+
currentY: e.clientY,
137+
sourceElement: e.currentTarget as HTMLElement
138+
});
139+
140+
// Set cursor to grabbing
141+
document.body.style.cursor = 'grabbing';
142+
};
143+
47144
const handleDrop = (target: 'tableau' | 'foundation', targetIndex: number) => {
48145
if (!dragData) return;
49146

@@ -91,7 +188,9 @@ function App() {
91188
draggable?: boolean,
92189
onDragStart?: () => void,
93190
key?: string | number,
94-
onDoubleClick?: () => void
191+
onDoubleClick?: () => void,
192+
column?: Card[], // Optional: full column for stack preview
193+
cardIndex?: number // Optional: card index in column
95194
) => {
96195
if (!cardImages) {
97196
// Loading fallback - show empty card
@@ -104,23 +203,19 @@ function App() {
104203
return <div key={key} className={className} style={style}></div>;
105204
}
106205

107-
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
108-
// Make the drag preview fully visible
109-
if (e.dataTransfer) {
110-
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
111-
dragImage.style.opacity = '1';
112-
document.body.appendChild(dragImage);
113-
e.dataTransfer.setDragImage(dragImage, e.currentTarget.offsetWidth / 2, e.currentTarget.offsetHeight / 2);
114-
setTimeout(() => document.body.removeChild(dragImage), 0);
206+
const handleMouseDown = (e: React.MouseEvent) => {
207+
if (!draggable) return;
208+
209+
// Get the cards to drag (current card and all below it in the column)
210+
let cardsToMove: Card[] = [card];
211+
if (column && cardIndex !== undefined && cardIndex < column.length - 1) {
212+
cardsToMove = column.slice(cardIndex);
115213
}
116-
// Make the original card invisible
117-
e.currentTarget.style.opacity = '0';
118-
if (onDragStart) onDragStart();
119-
};
120214

121-
const handleDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
122-
// Restore the original card visibility
123-
e.currentTarget.style.opacity = '1';
215+
// Start custom drag
216+
handleCustomDragStart(e, 'tableau', 0, cardIndex, cardsToMove);
217+
218+
if (onDragStart) onDragStart();
124219
};
125220

126221
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -137,15 +232,22 @@ function App() {
137232
}
138233
};
139234

235+
// Check if this card is being dragged
236+
const isBeingDragged = customDrag?.isDragging && customDrag.cards.some(c =>
237+
c.suit === card.suit && c.rank === card.rank
238+
);
239+
140240
return (
141241
<div
142242
key={key}
143243
className={className}
144244
data-suit={card.suit}
145-
style={style}
146-
draggable={draggable}
147-
onDragStart={draggable ? handleDragStart : undefined}
148-
onDragEnd={draggable ? handleDragEnd : undefined}
245+
style={{
246+
...style,
247+
opacity: isBeingDragged ? 0 : 1, // Fully hide the original cards during drag
248+
cursor: draggable ? 'grab' : 'default'
249+
}}
250+
onMouseDown={draggable ? handleMouseDown : undefined}
149251
onDoubleClick={draggable ? handleClick : undefined}
150252
>
151253
<img src={imageUrl} alt={`${card.rank} of ${card.suit}`} draggable={false} />
@@ -190,19 +292,22 @@ function App() {
190292
</div>
191293

192294
<div className="card-pile waste">
193-
{gameState.waste.length > 0 && (() => {
194-
const card = gameState.waste[gameState.waste.length - 1]!;
195-
const canAutoMove = canPlaceOnFoundation(card, gameState.foundations[card.suit]);
196-
return renderCard(
197-
card,
198-
'card',
199-
undefined,
200-
true,
201-
() => handleDragStart('waste', 0),
202-
undefined,
203-
canAutoMove ? () => handleAutoMoveToFoundation('waste', 0) : undefined
204-
);
205-
})()}
295+
{gameState.waste.length > 0 && (
296+
// Render all cards in the waste pile (stacked with no offset)
297+
gameState.waste.map((card, cardIdx) => {
298+
const isTopCard = cardIdx === gameState.waste.length - 1;
299+
const canAutoMove = isTopCard && canPlaceOnFoundation(card, gameState.foundations[card.suit]);
300+
return renderCard(
301+
card,
302+
'card',
303+
undefined, // No offset - cards are perfectly stacked
304+
isTopCard, // Only top card is draggable
305+
isTopCard ? () => handleDragStart('waste', 0) : undefined,
306+
cardIdx,
307+
canAutoMove ? () => handleAutoMoveToFoundation('waste', 0) : undefined
308+
);
309+
})
310+
)}
206311
</div>
207312

208313
<div className="spacer"></div>
@@ -214,10 +319,21 @@ function App() {
214319
onDragOver={(e) => e.preventDefault()}
215320
onDrop={() => handleDrop('foundation', idx)}
216321
>
217-
{gameState.foundations[suit].length > 0 ? (() => {
218-
const card = gameState.foundations[suit][gameState.foundations[suit].length - 1]!;
219-
return renderCard(card);
220-
})() : (
322+
{gameState.foundations[suit].length > 0 ? (
323+
// Render all cards in the foundation stack
324+
gameState.foundations[suit].map((card, cardIdx) => {
325+
// Only top card should be draggable from foundation (but we disable dragging in Klondike)
326+
return renderCard(
327+
card,
328+
'card',
329+
{ top: `${Math.min(cardIdx * 2, 20)}px` }, // Slight offset, max 20px
330+
false, // Foundations are not draggable in Klondike
331+
undefined,
332+
cardIdx,
333+
undefined
334+
);
335+
})
336+
) : (
221337
<div className="pile-placeholder">{['♠', '♥', '♦', '♣'][idx]}</div>
222338
)}
223339
</div>
@@ -251,14 +367,51 @@ function App() {
251367
true,
252368
() => handleDragStart('tableau', colIdx, cardIdx),
253369
cardIdx,
254-
canAutoMove ? () => handleAutoMoveToFoundation('tableau', colIdx) : undefined
370+
canAutoMove ? () => handleAutoMoveToFoundation('tableau', colIdx) : undefined,
371+
column, // Pass the full column
372+
cardIdx // Pass the card index
255373
);
256374
})
257375
)}
258376
</div>
259377
))}
260378
</div>
261379
</div>
380+
381+
{/* Custom drag overlay */}
382+
{customDrag && customDrag.isDragging && (
383+
<div
384+
style={{
385+
position: 'fixed',
386+
left: customDrag.currentX - customDrag.startX, // Subtract the offset
387+
top: customDrag.currentY - customDrag.startY,
388+
pointerEvents: 'none',
389+
zIndex: 10000,
390+
cursor: 'grabbing'
391+
}}
392+
>
393+
{customDrag.cards.map((card, idx) => (
394+
<div
395+
key={`${card.suit}-${card.rank}-drag`}
396+
className="card"
397+
style={{
398+
position: 'absolute',
399+
top: `${idx * 25}px`,
400+
left: 0,
401+
width: 'calc(min(100vw, 100vh) * 0.095)',
402+
height: 'calc(min(100vw, 100vh) * 0.134)'
403+
}}
404+
>
405+
<img
406+
src={cardImages?.get(card.suit, card.rank)}
407+
alt={`${card.rank} of ${card.suit}`}
408+
draggable={false}
409+
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
410+
/>
411+
</div>
412+
))}
413+
</div>
414+
)}
262415
</div>
263416
);
264417
}

0 commit comments

Comments
 (0)