Compare commits
2 Commits
2ce0ff50b9
...
fc43f440aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc43f440aa | ||
|
|
1cf30b3622 |
@@ -150,7 +150,9 @@ export function App() {
|
|||||||
|
|
||||||
const handlePin = useCallback(() => {
|
const handlePin = useCallback(() => {
|
||||||
if (selectedCreatureId) {
|
if (selectedCreatureId) {
|
||||||
setPinnedCreatureId(selectedCreatureId);
|
setPinnedCreatureId((prev) =>
|
||||||
|
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [selectedCreatureId]);
|
}, [selectedCreatureId]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
import { StatBlock } from "./stat-block.js";
|
import { StatBlock } from "./stat-block.js";
|
||||||
@@ -176,6 +177,8 @@ function MobileDrawer({
|
|||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<button
|
<button
|
||||||
@@ -184,7 +187,13 @@ function MobileDrawer({
|
|||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
aria-label="Close stat block"
|
aria-label="Close stat block"
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
|
<div
|
||||||
|
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
||||||
|
style={
|
||||||
|
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||||
|
}
|
||||||
|
{...handlers}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
72
apps/web/src/hooks/use-swipe-to-dismiss.ts
Normal file
72
apps/web/src/hooks/use-swipe-to-dismiss.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const DISMISS_THRESHOLD = 0.35;
|
||||||
|
const VELOCITY_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
interface SwipeState {
|
||||||
|
offsetX: number;
|
||||||
|
isSwiping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSwipeToDismiss(onDismiss: () => void) {
|
||||||
|
const [swipe, setSwipe] = useState<SwipeState>({
|
||||||
|
offsetX: 0,
|
||||||
|
isSwiping: false,
|
||||||
|
});
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const startTime = useRef(0);
|
||||||
|
const panelWidth = useRef(0);
|
||||||
|
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
startX.current = touch.clientX;
|
||||||
|
startY.current = touch.clientY;
|
||||||
|
startTime.current = Date.now();
|
||||||
|
directionLocked.current = null;
|
||||||
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
panelWidth.current = el.getBoundingClientRect().width;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - startX.current;
|
||||||
|
const dy = touch.clientY - startY.current;
|
||||||
|
|
||||||
|
if (!directionLocked.current) {
|
||||||
|
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
|
||||||
|
directionLocked.current =
|
||||||
|
Math.abs(dx) > Math.abs(dy) ? "horizontal" : "vertical";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directionLocked.current === "vertical") return;
|
||||||
|
|
||||||
|
const clampedX = Math.max(0, dx);
|
||||||
|
setSwipe({ offsetX: clampedX, isSwiping: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
if (directionLocked.current !== "horizontal") {
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = (Date.now() - startTime.current) / 1000;
|
||||||
|
const velocity = swipe.offsetX / elapsed / panelWidth.current;
|
||||||
|
const ratio =
|
||||||
|
panelWidth.current > 0 ? swipe.offsetX / panelWidth.current : 0;
|
||||||
|
|
||||||
|
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
}, [swipe.offsetX, onDismiss]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetX: swipe.offsetX,
|
||||||
|
isSwiping: swipe.isSwiping,
|
||||||
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user