Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { ProjectEditorPage } from "@/routes/ProjectEditorPage";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { Header } from "@/components/Header";
import { UnsavedChangesDialog } from "@/components/UnsavedChangesDialog";
import { SwipeBackIndicator } from "@/components/SwipeBackIndicator";
import { useTrackpadBack } from "@/hooks/useTrackpadBack";

export type View =
| { mode: "list" }
Expand All @@ -25,6 +27,11 @@ function AppShell() {
}
}

const { progress, isTriggered, isSwiping } = useTrackpadBack({
onBack: handleBackClick,
enabled: isEditing && !showUnsavedDialog,
});

function handleConfirmDiscard() {
setShowUnsavedDialog(false);
setIsDirty(false);
Expand Down Expand Up @@ -57,6 +64,12 @@ function AppShell() {
onConfirm={handleConfirmDiscard}
/>

<SwipeBackIndicator
progress={progress}
isTriggered={isTriggered}
isSwiping={isSwiping}
/>

<Toaster />
</div>
);
Expand Down
94 changes: 94 additions & 0 deletions src/components/SwipeBackIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { ArrowLeft } from "lucide-react";
import { cn } from "@/lib/utils";

interface SwipeBackIndicatorProps {
progress: number;
isTriggered: boolean;
isSwiping: boolean;
}

export function SwipeBackIndicator({
progress,
isTriggered,
isSwiping,
}: SwipeBackIndicatorProps) {
if (!isSwiping && progress === 0) {
return null;
}

// Smooth slide out from left: from -52px (offscreen) to +12px into the screen
const translateX = progress * 64 - 52;
const opacity = Math.min(1, progress * 1.8);
const radius = 21;
const circumference = 2 * Math.PI * radius; // ~131.95
const strokeDashoffset = circumference * (1 - Math.min(1, progress));

return (
<div
className="pointer-events-none fixed inset-y-0 left-0 z-50 flex items-center pl-3 select-none"
aria-hidden="true"
>
<div
className="relative flex size-12 items-center justify-center drop-shadow-lg transition-[transform,opacity] duration-150 ease-out"
style={{
transform: `translateX(${translateX}px) scale(${isTriggered ? 1.12 : 1})`,
opacity,
}}
>
{/* Unified SVG coordinate space centered at (24, 24) */}
<svg
className="absolute inset-0 size-full"
viewBox="0 0 48 48"
fill="none"
>
{/* Background circle */}
<circle
cx="24"
cy="24"
r={radius}
className={cn(
"transition-colors duration-150",
isTriggered ? "fill-primary" : "fill-card"
)}
/>

{/* Track ring */}
<circle
cx="24"
cy="24"
r={radius}
strokeWidth="2.5"
className={cn(
"transition-colors duration-150",
isTriggered ? "stroke-primary-foreground/20" : "stroke-border"
)}
/>

{/* Progress ring - rotated around exact center (24, 24) in SVG space */}
<circle
cx="24"
cy="24"
r={radius}
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
transform="rotate(-90 24 24)"
className={cn(
"transition-[stroke-dashoffset,stroke] duration-75",
isTriggered ? "stroke-primary-foreground" : "stroke-primary"
)}
/>
</svg>

{/* Direction arrow centered */}
<ArrowLeft
className={cn(
"relative z-10 size-5 transition-colors duration-150",
isTriggered ? "text-primary-foreground" : "text-foreground"
)}
/>
</div>
</div>
);
}
219 changes: 219 additions & 0 deletions src/hooks/useTrackpadBack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { useEffect, useRef, useState } from "react";

export interface UseTrackpadBackOptions {
/** Callback fired when swipe back completes successfully */
onBack: () => void;
/** Whether the gesture detection is active */
enabled?: boolean;
/** Cumulative deltaX threshold to trigger back navigation (default: 135) */
threshold?: number;
}

export interface UseTrackpadBackResult {
/** Gesture progress from 0 (start) to 1 (threshold reached) */
progress: number;
/** True when progress >= 1 and navigation is triggered */
isTriggered: boolean;
/** True while the user is actively swiping */
isSwiping: boolean;
}

function isInsideHorizontallyScrollable(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
let curr: HTMLElement | null = target;
while (curr && curr !== document.body && curr !== document.documentElement) {
const style = window.getComputedStyle(curr);
const overflowX = style.overflowX;
if (
(overflowX === "auto" || overflowX === "scroll") &&
curr.scrollWidth > curr.clientWidth &&
curr.scrollLeft > 0
) {
return true;
}
curr = curr.parentElement;
}
return false;
}

export function useTrackpadBack({
onBack,
enabled = true,
threshold = 135,
}: UseTrackpadBackOptions): UseTrackpadBackResult {
const [progress, setProgress] = useState(0);
const [isTriggered, setIsTriggered] = useState(false);
const [isSwiping, setIsSwiping] = useState(false);

const onBackRef = useRef(onBack);
onBackRef.current = onBack;

const accumulatedRef = useRef(0);
const isGestureActiveRef = useRef(false);
const hasTriggeredRef = useRef(false);
const isCoolingDownRef = useRef(false);

const resetTimeoutRef = useRef<number | null>(null);
const cooldownTimeoutRef = useRef<number | null>(null);
const commitTimeoutRef = useRef<number | null>(null);

useEffect(() => {
// Reset all internal state and timers whenever enabled changes
accumulatedRef.current = 0;
isGestureActiveRef.current = false;
hasTriggeredRef.current = false;
isCoolingDownRef.current = false;

if (resetTimeoutRef.current) window.clearTimeout(resetTimeoutRef.current);
if (cooldownTimeoutRef.current) window.clearTimeout(cooldownTimeoutRef.current);
if (commitTimeoutRef.current) window.clearTimeout(commitTimeoutRef.current);

if (!enabled) {
setProgress(0);
setIsTriggered(false);
setIsSwiping(false);
return;
}

const resetState = () => {
accumulatedRef.current = 0;
isGestureActiveRef.current = false;
hasTriggeredRef.current = false;
setProgress(0);
setIsTriggered(false);
setIsSwiping(false);
};

const handleWheel = (e: WheelEvent) => {
const isHorizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);

// ALWAYS prevent default on horizontal wheel events outside horizontally scrollable elements.
// This completely stops WKWebView/browser from rubber-banding or trying to shift the UI sideways!
if (isHorizontal && !isInsideHorizontallyScrollable(e.target)) {
if (e.cancelable) {
e.preventDefault();
}
}

// If we are currently cooling down after a successful trigger, absorb residual trackpad momentum
if (isCoolingDownRef.current) {
return;
}

// Swipe right on macOS trackpad produces negative deltaX
const swipeDelta = -e.deltaX;

// Start gesture if not active
if (!isGestureActiveRef.current) {
// Must be a deliberate horizontal swipe to the right
if (swipeDelta <= 2) return;
if (!isHorizontal) return;
if (document.querySelector('[role="dialog"]')) return;
if (isInsideHorizontallyScrollable(e.target)) return;

isGestureActiveRef.current = true;
accumulatedRef.current = 0;
hasTriggeredRef.current = false;
setIsSwiping(true);
}

// While gesture is active, accumulate swipe movement
if (isGestureActiveRef.current && !hasTriggeredRef.current) {
accumulatedRef.current = Math.max(0, accumulatedRef.current + swipeDelta);

const currentProgress = Math.min(1, Math.max(0, accumulatedRef.current / threshold));
setProgress(currentProgress);

// THRESHOLD REACHED: Complete the circle and give a crisp 110ms confirmation before navigating
if (accumulatedRef.current >= threshold) {
hasTriggeredRef.current = true;
setIsTriggered(true);
setProgress(1);

// Brief pause (110ms) so user visually sees the 100% filled circle and pulse
commitTimeoutRef.current = window.setTimeout(() => {
onBackRef.current();

// Enter cooldown to absorb residual trackpad momentum
isCoolingDownRef.current = true;
if (cooldownTimeoutRef.current) {
window.clearTimeout(cooldownTimeoutRef.current);
}
cooldownTimeoutRef.current = window.setTimeout(() => {
isCoolingDownRef.current = false;
hasTriggeredRef.current = false;
}, 400);

resetState();
}, 110);

return;
}

// If not triggered yet, reset if user stops or reverses gesture
if (resetTimeoutRef.current) {
window.clearTimeout(resetTimeoutRef.current);
}
resetTimeoutRef.current = window.setTimeout(resetState, 120);
}
};

// Support browser back button on 5-button mice
const handleAuxClick = (e: MouseEvent) => {
if (e.button === 3) {
if (document.querySelector('[role="dialog"]')) return;
e.preventDefault();
onBackRef.current();
}
};

// Support Cmd + [ (macOS) and Alt + ArrowLeft (cross-platform)
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target as HTMLElement)?.isContentEditable
) {
return;
}

if (document.querySelector('[role="dialog"]')) return;

const isMac = /Mac|iPhone|iPod|iPad/i.test(navigator.userAgent);
const isBackShortcut =
(isMac && e.metaKey && e.key === "[") ||
e.key === "BrowserBack" ||
(e.altKey && e.key === "ArrowLeft");

if (isBackShortcut) {
e.preventDefault();
onBackRef.current();
}
};

window.addEventListener("wheel", handleWheel, { passive: false });
window.addEventListener("auxclick", handleAuxClick);
window.addEventListener("keydown", handleKeyDown);

return () => {
window.removeEventListener("wheel", handleWheel);
window.removeEventListener("auxclick", handleAuxClick);
window.removeEventListener("keydown", handleKeyDown);

if (resetTimeoutRef.current) window.clearTimeout(resetTimeoutRef.current);
if (cooldownTimeoutRef.current) window.clearTimeout(cooldownTimeoutRef.current);
if (commitTimeoutRef.current) window.clearTimeout(commitTimeoutRef.current);

isCoolingDownRef.current = false;
hasTriggeredRef.current = false;
isGestureActiveRef.current = false;
accumulatedRef.current = 0;
};
}, [enabled, threshold]);

return {
progress,
isTriggered,
isSwiping,
};
}
6 changes: 6 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
@import "tailwindcss";

html,
body,
#root {
overscroll-behavior-x: none;
}

@custom-variant dark (&:is(.dark *));

:root {
Expand Down
Loading