mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
refactor(focus): isolate card drag state to fix rebound forever
This commit is contained in:
1 parent
85b1435908
commit
d282d80add
1 file changed
+187
-125
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user