File size: 6,405 Bytes
02e775b
 
c92b696
02e775b
c92b696
02e775b
 
 
c92b696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02e775b
c92b696
 
 
 
 
 
 
 
 
 
 
 
 
 
02e775b
 
c92b696
 
 
 
 
02e775b
c92b696
 
 
 
 
 
 
 
02e775b
 
 
c92b696
02e775b
c92b696
 
02e775b
 
 
 
 
c92b696
02e775b
c92b696
 
 
 
 
 
 
 
 
 
 
 
 
02e775b
c92b696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02e775b
 
c92b696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02e775b
c92b696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02e775b
 
 
c92b696
02e775b
c92b696
02e775b
 
c92b696
02e775b
 
c92b696
02e775b
 
 
 
 
 
 
 
 
 
 
c92b696
 
 
 
 
 
 
02e775b
 
 
 
 
 
 
c92b696
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
'use client';

import { useEffect, useRef, useState, type ReactNode, type TouchEvent as ReactTouchEvent } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { motion, useMotionValue, useTransform, animate, AnimatePresence } from 'framer-motion';

/**
 * SwipeBack — iOS-style edge swipe to go back.
 *
 * Behaviour:
 *   - Touch must start within the leftmost ~28px of the screen.
 *   - After the first move, the gesture is classified as horizontal or
 *     vertical. Vertical gestures are released to the browser (page scroll).
 *   - Horizontal drags move the page rightward with the finger (clamped to
 *     a sensible max so it doesn't fly off-screen).
 *   - On release: if dragged past 120px → commit (animate off-screen + call
 *     router.back()). Otherwise snap back to x=0.
 *   - A `suppressNextEntrance` module flag tells the next page's entrance
 *     animation to skip itself (the user already saw the page revealed by
 *     the drag — replaying a slide-in would feel like a glitch).
 *
 * Implementation notes:
 *   - Uses useMotionValue + animate() directly, so the touchmove handler
 *     only updates a single mutable value — no React re-render per frame.
 *   - Touch listeners are bound once per mount (not re-bound on every drag
 *     change), which is a perf fix vs. the previous version.
 */

// ---- Tunable constants ------------------------------------------------------
const EDGE_ZONE_PX = 28;        // Touch must start within this many px of the left edge.
const COMMIT_THRESHOLD_PX = 120; // Drag this far right to actually go back.
const DRAG_MAX_PX = 280;         // Visual cap on how far the page will follow the finger.

// Module-level flag — set to true right before a swipe-back commit so the
// NEXT mount of any entrance-animation component knows to skip itself.
export let suppressNextEntrance = false;
function setSuppressNext(v: boolean) {
  suppressNextEntrance = v;
}

export function SwipeBack({ children }: { children: ReactNode }) {
  const pathname = usePathname();
  const router = useRouter();

  const x = useMotionValue(0);
  const opacity = useTransform(x, [0, DRAG_MAX_PX], [1, 0.7]);
  const scale = useTransform(x, [0, DRAG_MAX_PX], [1, 0.96]);

  const [isDragging, setIsDragging] = useState(false);
  const [showChevron, setShowChevron] = useState(false);

  const touchState = useRef<{
    startX: number;
    startY: number;
    active: boolean;
    horizontal: boolean | null;
  } | null>(null);

  // Reset on path change
  useEffect(() => {
    x.set(0);
    setIsDragging(false);
    setShowChevron(false);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pathname]);

  // Only enable on pages that have a "back" (not homepage)
  const canSwipeBack = pathname !== '/';

  function onTouchStart(e: ReactTouchEvent<HTMLDivElement>) {
    if (!canSwipeBack) return;
    const t = e.touches[0];
    if (!t) return;

    if (t.clientX > EDGE_ZONE_PX) {
      touchState.current = null;
      return;
    }

    touchState.current = {
      startX: t.clientX,
      startY: t.clientY,
      active: true,
      horizontal: null,
    };
  }

  function onTouchMove(e: ReactTouchEvent<HTMLDivElement>) {
    const state = touchState.current;
    if (!state || !state.active) return;

    const t = e.touches[0];
    if (!t) return;
    const dx = t.clientX - state.startX;
    const dy = t.clientY - state.startY;

    if (state.horizontal === null) {
      const absDx = Math.abs(dx);
      const absDy = Math.abs(dy);
      if (absDx < 8 && absDy < 8) return;
      if (absDy > absDx) {
        // Vertical-dominant: release to browser
        state.active = false;
        return;
      }
      state.horizontal = true;
      setIsDragging(true);
    }

    if (!state.horizontal) return;

    if (e.cancelable) e.preventDefault();
    const clamped = Math.max(0, Math.min(dx, DRAG_MAX_PX));
    x.set(clamped);
    setShowChevron(clamped > 15);
  }

  function onTouchEnd() {
    const state = touchState.current;
    touchState.current = null;
    if (!state || !state.active || !state.horizontal) {
      setIsDragging(false);
      setShowChevron(false);
      return;
    }

    const current = x.get();
    if (current >= COMMIT_THRESHOLD_PX) {
      const target = typeof window !== 'undefined' ? window.innerWidth : DRAG_MAX_PX;
      animate(x, target, {
        duration: 0.22,
        ease: [0.4, 0, 1, 1],
        onComplete: () => {
          // Tell the next entrance animation to skip itself
          setSuppressNext(true);
          router.back();
          requestAnimationFrame(() => x.set(0));
          setIsDragging(false);
          setShowChevron(false);
        },
      });
    } else {
      animate(x, 0, {
        duration: 0.24,
        ease: [0.32, 0.72, 0, 1],
        onComplete: () => {
          setIsDragging(false);
          setShowChevron(false);
        },
      });
    }
  }

  return (
    <>
      {/* Ghost back chevron — fades in as the user drags */}
      <AnimatePresence>
        {showChevron && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: Math.min(x.get() / 60, 1) }}
            exit={{ opacity: 0 }}
            className="fixed top-1/2 -translate-y-1/2 z-[9998] pointer-events-none"
            style={{ left: `${Math.min(x.get() * 0.3, 60)}px` }}
          >
            <div className="w-12 h-12 rounded-full bg-black/10 backdrop-blur-md flex items-center justify-center">
              <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
                <path d="M15 18L9 12L15 6" stroke="black" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
              </svg>
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      <motion.div
        className={`page-shell${isDragging ? ' is-dragging' : ''}`}
        style={{ x, opacity, scale, willChange: 'transform, opacity' }}
        onTouchStart={onTouchStart}
        onTouchMove={onTouchMove}
        onTouchEnd={onTouchEnd}
        onTouchCancel={onTouchEnd}
        data-swipe-back="true"
      >
        {children}
      </motion.div>
    </>
  );
}

// Export a helper so template.tsx (entrance animation) can check + clear the flag.
export function consumeSuppressNextEntrance(): boolean {
  const v = suppressNextEntrance;
  setSuppressNext(false);
  return v;
}