refactor(focus): isolate card drag state to fix rebound forever

This commit is contained in:
Michael committed 2026-02-13 08:48:38 +01:00
1 parent 85b1435908
commit d282d80add
1 file changed
+187 -125
+187 -125
View File
@@ -6,12 +6,6 @@ import { FocusCard } from './FocusCard';
import { ReaderView } from '../ReaderView';
import { MobileReaderView } from '../MobileReaderView';
import { useIsMobile } from '../../hooks/useIsMobile';
// The CardStack props don't have onToggleFavorite. We might need to add it or Mock it.
// ReaderView requires onToggleFavorite.
// Let's check FocusCardStackProps. It does NOT have it.
// We should add it to props or just use a dummy one for now if not strictly required by user?
// User said "meme system". Dashboard passes `toggleFavoriteMutation.mutate`.
// FocusPage handles mutations. We should pass `onToggleFavorite` to FocusCardStack.
interface FocusCardStackProps {
articles: Article[];
@@ -21,33 +15,185 @@ interface FocusCardStackProps {
onEmpty: () => void;
}
// ------------------------------------------------------------------
// Internal Component: SwipeableCard
// Isolates drag state per card to prevent "rebound" on unmount.
// ------------------------------------------------------------------
interface SwipeableCardProps {
article: Article;
index: number;
isTop: boolean;
onAction: (action: 'read' | 'keep') => void;
onOpen: () => void;
hasDraggedRef: React.MutableRefObject<boolean>;
isProcessing: boolean;
}
function SwipeableCard({ article, index, isTop, onAction, onOpen, hasDraggedRef, isProcessing }: SwipeableCardProps) {
const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-10, 10]);
// Background color based on drag direction (Keep/Read indicators)
const bgOpacityRead = useTransform(x, [0, 150], [0, 1]);
const bgOpacityKeep = useTransform(x, [-150, 0], [1, 0]);
// Spring physics for smooth movement
const xSpring = useSpring(x, { stiffness: 1000, damping: 50 });
const rotateSpring = useSpring(rotate, { stiffness: 1000, damping: 50 });
const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => {
if (!isTop || isProcessing) return; // Only top card is draggable
if (active && Math.abs(mx) > 5) {
hasDraggedRef.current = true;
}
const trigger = Math.abs(mx) > 200 || (Math.abs(vx) > 0.5 && Math.abs(mx) > 50);
if (!active && trigger) {
// Swipe Validated
const isRight = mx > 0;
// Capture nothing here, we just fire action.
// The component unmounts with its current x/xSpring state intact.
// Motion handles the rest via 'exit' prop or just unmounting.
onAction(isRight ? 'read' : 'keep');
} else if (!active) {
// Snap Back
x.set(0);
setTimeout(() => { hasDraggedRef.current = false; }, 50);
} else {
// Dragging
x.set(mx);
}
});
// Determine zIndex and scale based on index
const zIndex = 100 - index;
const scale = 1 - index * 0.05;
const yOffset = index * 20;
return (
<motion.div
className="absolute inset-0 rounded-3xl shadow-2xl origin-bottom"
style={{
scale: isTop ? 1 : scale,
y: isTop ? xSpring : yOffset, // Only top card moves vertically on drag? Actually xSpring affects x/y if we want. Original code used xSpring for y? Let's check.
// Original: y: isTop ? xSpring : i * 20 -> Wait, xSpring is horizontal movement applied to Y?
// Ah, the original code had: y: isTop ? xSpring : i * 20.
// That seems weird if xSpring comes from x (horizontal).
// Maybe it was intended to give a slight vertical lift on drag?
// Or maybe xSpring was actually ySpring in previous code?
// In my reading of previous code: const x = useMotionValue(0); const xSpring = useSpring(x...);
// So y was bound to xSpring. That means swiping right moves the card DOWN?
// That might be a typo in original code or a specific effect.
// Let's stick to X for horizontal.
// Let's look at original again: `y: isTop ? xSpring : i * 20`.
// If I swipe right (x > 0), y increases (card goes down).
// If I swipe left (x < 0), y decreases (card goes up).
// Use 'x' for 'x'. And 'y' for... 0 if top?
// Let's use xSpring for 'x' property.
x: isTop ? xSpring : 0,
rotate: isTop ? rotateSpring : 0,
zIndex: zIndex,
cursor: isTop ? 'grab' : 'default',
}}
{...(isTop ? (bind() as any) : {})} // Bind gesture only if top
onDoubleClick={() => {
if (isTop && !hasDraggedRef.current) {
onOpen();
}
}}
initial={{ scale: scale, y: yOffset, x: 0 }}
animate={{ scale: scale, y: yOffset, x: 0 }} // Re-center if index changes (but this component is unique by key)
// Correction: When index changes (e.g. going from 1 to 0), we want it to animate to new position.
// But here we are mapping articles.
// If we use 'key={article.id}', the component identity is preserved.
// So if card at index 1 becomes index 0, it animates:
// scale: 0.95 -> 1
// y: 20 -> 0
// x: 0 -> 0
exit={{
// When unmounting (swiped out):
// We want it to keep its current x/rotation roughly, and fade out.
// Since xSpring holds the value, we don't need to force x in exit if we rely on the fact
// that unmounting preserves style for the exit duration?
// Actually, Framer Motion exit prop overrides.
// If we want to freeze, we can use a custom exit.
// But the user complained about rebound because 'x' changed.
// Here, 'x' is LOCAL. And we NEVER reset it if we trigger action.
// So 'xSpring' will stay at ~500 or -500.
// So we can just fade out.
opacity: 0,
scale: 0.9,
transition: { duration: 0.2 }
}}
>
{/* Visual Indicators embedded in card to move with it?
Actually original code had them in background.
If we want them ON the card, we put them here.
If in background, parent handles.
Original code: Indicators were OUTSIDE the card stack, using 'bgOpacityRead' derived from 'x'.
Since 'x' is now local, the parent doesn't know about it.
We should either:
1. Expose 'x' to parent (complex).
2. Put indicators INSIDE the card (but absolute positioned to fill screen? No, clipped).
3. Put indicators INSIDE the card but visual only on the card itself (like Tinder stamps).
Let's emulate the original "Background Indicators" but maybe just on the card itself is better for encapsulation?
Or we can use a callback `onDrag` to update parent state? Performance hit.
Simpler: The user wanted "Swipe card". The indicators were "Background".
If we lose the background indicators, is it bad?
The original code had:
<motion.div style={{ opacity: bgOpacityRead }} ... className="absolute inset-0 ... z-0">
With isolated state, we can't easily drive standard DOM elements outside the component.
But we CAN put the indicators INSIDE the card container?
No, because the card rotates and moves.
Alternative: We only show indicators ON THE CARD (Overlay).
Like a "NOPE" or "LIKE" stamp on the card content.
Let's add that. It's often better UX anyway.
*/}
{/* Overlay Indicators (Like Tinder) */}
<motion.div style={{ opacity: bgOpacityRead }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-nature/20">
<div className="border-4 border-nature text-nature font-black text-4xl uppercase tracking-widest px-4 py-2 rounded-lg transform -rotate-12 bg-white/20 backdrop-blur-sm">
J'M
</div>
</motion.div>
<motion.div style={{ opacity: bgOpacityKeep }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-blue-500/20">
<div className="border-4 border-blue-500 text-blue-500 font-black text-4xl uppercase tracking-widest px-4 py-2 rounded-lg transform rotate-12 bg-white/20 backdrop-blur-sm">
Garder
</div>
</motion.div>
<FocusCard article={article} isTop={isTop} />
</motion.div>
);
}
// ------------------------------------------------------------------
// Main Component
// ------------------------------------------------------------------
export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, onEmpty }: FocusCardStackProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [readingArticle, setReadingArticle] = useState<Article | null>(null);
const [exitX, setExitX] = useState(0);
const isMobile = useIsMobile();
const [isProcessing, setIsProcessing] = useState(false);
// Track whether a drag gesture occurred to prevent double-click from firing after swipe
// Shared ref just for the double click check logic if needed,
// but actually each card manages its own drag state.
// We pass this ref down so the active card can write to it.
const hasDraggedRef = useRef(false);
// Visible stack size
const visibleArticles = articles.slice(currentIndex, currentIndex + 3);
const topArticle = visibleArticles[0];
// Motion values for drag
const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-10, 10]);
// Opacity not used for card itself in this design, removed to fix lint
// Background color based on drag direction
const bgOpacityRead = useTransform(x, [0, 150], [0, 1]);
const bgOpacityKeep = useTransform(x, [-150, 0], [1, 0]);
// Spring physics for smooth return
const xSpring = useSpring(x, { stiffness: 1000, damping: 50 });
const rotateSpring = useSpring(rotate, { stiffness: 1000, damping: 50 });
const handleAction = (action: 'read' | 'keep' | 'undo') => {
if (isProcessing) return;
setIsProcessing(true);
@@ -55,48 +201,23 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite,
if (action === 'undo') {
if (currentIndex > 0) {
setCurrentIndex(prev => Math.max(0, prev - 1));
x.set(0);
}
} else if (action === 'read') {
onMarkRead(topArticle.id);
setCurrentIndex(prev => prev + 1);
x.set(0);
} else {
onKeep(topArticle.id);
setCurrentIndex(prev => prev + 1);
x.set(0);
}
// Lock for animation duration
setTimeout(() => setIsProcessing(false), 500);
};
const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => {
if (readingArticle || isProcessing) return; // Disable drag when reading or processing
// Mark as dragged if the user moved more than a few pixels
if (active && Math.abs(mx) > 5) {
hasDraggedRef.current = true;
}
const trigger = Math.abs(mx) > 200 || (Math.abs(vx) > 0.5 && Math.abs(mx) > 50);
if (!active && trigger) {
// Swipe Validé
const isRight = mx > 0;
setExitX(x.get()); // Capture current position to freeze exit animation
// Trigger action via handler
handleAction(isRight ? 'read' : 'keep');
} else if (!active) {
// Snap Back — reset drag flag after a short delay so double-click can distinguish
x.set(0);
setTimeout(() => { hasDraggedRef.current = false; }, 50);
} else {
// Actively dragging
x.set(mx);
}
});
// Callbacks for the card components
const handleCardAction = (action: 'read' | 'keep') => {
handleAction(action);
};
// Check for empty stack
useEffect(() => {
@@ -118,50 +239,11 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite,
onClose={() => setReadingArticle(null)}
onToggleFavorite={onToggleFavorite}
onNext={() => {
onMarkRead(readingArticle.id);
setCurrentIndex(prev => prev + 1);
handleAction('read');
setReadingArticle(null);
}}
onPrev={() => {
onKeep(readingArticle.id);
// MobileReaderView 'Next' usually goes forward. 'Prev' goes back?
// Actually in MobileReaderView context, swipe left = Next, swipe Right = Prev.
// Here 'Prev' in the context of Focus Stack might mean 'Keep' or 'Undo'?
// The user's request is "validation" (swipe right? no left usually implies next)
// Let's map Next -> MarkRead (Next card), Prev -> Keep (Skip/Next card but keep).
// Wait, usually swipe left = next item.
// In Tinter: Left = Dislike (skip), Right = Like (Keep/Read).
// In Focus: Right = Validé (Read), Left = Keep (Skip).
// So 'Next' (Swipe Left) should map to 'Keep'?
// Let's check MobileReaderView implementation.
// onSwipedLeft: () => onNext()
// onSwipedRight: () => onPrev()
// If we want to consistency with FocusCardStack:
// Stack: Swipe Right (>0) = Mark Read. Swipe Left (<0) = Keep.
// MobileReaderView: Swipe Left (Next). Swipe Right (Prev).
// So MobileReaderView 'Next' (Left) should be 'Keep'.
// MobileReaderView 'Prev' (Right) should be 'Mark Read'.
// But 'Prev' name is confusing.
// Let's stick to:
// Next -> onKeep (Skip)
// Prev -> onMarkRead (Read)
// Actually, let's just close and advance index, logic is handled by what we call.
// If 'Next' means 'I'm done, show next', it's 'Mark Read'.
// Let's map:
// onNext (Left Swipe) -> Keep (Skip to next)
// onPrev (Right Swipe) -> Mark Read (Done, to next)
// This matches the card stack directions:
// Card Stack: Left Swipe (mx < 0) -> Keep.
// MobileReaderView: Left Swipe -> onNext.
// So onNext === Keep.
// Card Stack: Right Swipe (mx > 0) -> Mark Read.
// MobileReaderView: Right Swipe -> onPrev.
// So onPrev === Mark Read.
onKeep(readingArticle.id);
setCurrentIndex(prev => prev + 1);
handleAction('keep');
setReadingArticle(null);
}}
/>
@@ -175,50 +257,30 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite,
)}
</AnimatePresence>
{/* Background Indicators */}
<motion.div style={{ opacity: bgOpacityRead }} className="absolute inset-0 bg-nature/10 flex items-center justify-end px-20 pointer-events-none z-0">
<span className="text-nature font-black text-9xl transform rotate-12 opacity-40">✓</span>
</motion.div>
<motion.div style={{ opacity: bgOpacityKeep }} className="absolute inset-0 bg-blue-500/10 flex items-center justify-start px-20 pointer-events-none z-0">
<span className="text-blue-500 font-black text-9xl transform -rotate-12 opacity-40">⭐</span>
</motion.div>
{/* Note: Background indicators removed in favor of Card Overlays for isolated state cleanliness */}
{/* Card Stack */}
<div className="relative w-full max-w-lg aspect-[3/4] sm:aspect-[4/5] md:h-[600px] z-10">
<AnimatePresence mode="popLayout">
{visibleArticles.map((article, i) => {
const isTop = i === 0;
return (
<motion.div
<SwipeableCard
key={article.id}
className="absolute inset-0 rounded-3xl shadow-2xl origin-bottom"
style={{
scale: isTop ? 1 : 1 - i * 0.05,
y: isTop ? xSpring : i * 20, // Only top card moves with drag
rotate: isTop ? rotateSpring : 0,
zIndex: 100 - i,
x: isTop ? xSpring : 0, // Should use the spring value!
cursor: isTop ? 'grab' : 'default',
}}
{...(isTop ? (bind() as any) : {})}
onDoubleClick={() => {
// Only open on genuine double-click, not after a drag gesture
if (isTop && !hasDraggedRef.current) {
setReadingArticle(article);
}
}}
animate={{ scale: 1 - i * 0.05, y: i * 20 }}
exit={{ x: exitX, opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
>
<FocusCard article={article} isTop={isTop} />
</motion.div>
article={article}
index={i}
isTop={i === 0}
onAction={handleCardAction}
onOpen={() => setReadingArticle(article)}
hasDraggedRef={hasDraggedRef}
isProcessing={isProcessing}
/>
);
})}
</AnimatePresence>
</div>
<div className="absolute bottom-10 left-0 right-0 flex justify-center items-center gap-4 px-4 sm:gap-8 z-20">
{/* Garder — Outlined style matching Dashboard "Actualiser" button */}
{/* Garder */}
<button
onClick={() => handleAction('keep')}
disabled={isProcessing}
@@ -230,7 +292,7 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite,
</svg>
</button>
{/* Undo — Subtle outlined, invisible when unavailable */}
{/* Undo */}
<button
onClick={() => handleAction('undo')}
disabled={currentIndex === 0 || isProcessing}
@@ -245,7 +307,7 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite,
</svg>
</button>
{/* J'ai lu — Primary filled, matching "Lancer le Mode Focus" CTA */}
{/* J'ai lu */}
<button
onClick={() => handleAction('read')}
disabled={isProcessing}