Clicking a spell name in a PF2e creature's stat block now opens a popover (desktop) or bottom sheet (mobile) showing full spell details: description, traits, rank, range, target, area, duration, defense, action cost icons, and heightening rules. All data is sourced from the embedded Foundry VTT spell items already in the bestiary cache. - Add SpellReference type replacing bare string spell arrays - Extract full spell data in pf2e-bestiary-adapter (description, traits, traditions, range, target, area, duration, defense, action cost, heightening, overlays) - Strip inline heightening text from descriptions to avoid duplication - Bold save outcome labels (Critical Success/Failure) in descriptions - Bump DB_VERSION to 6 for cache invalidation - Add useSwipeToDismissDown hook for mobile bottom sheet - Portal popover to document.body to escape transformed ancestors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
142 lines
4.1 KiB
TypeScript
142 lines
4.1 KiB
TypeScript
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 },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Vertical (down-only) variant for dismissing bottom sheets via swipe-down.
|
|
* Mirrors `useSwipeToDismiss` but locks to vertical direction and tracks
|
|
* the sheet height instead of width.
|
|
*/
|
|
export function useSwipeToDismissDown(onDismiss: () => void) {
|
|
const [swipe, setSwipe] = useState<SwipeState>({
|
|
offsetX: 0,
|
|
isSwiping: false,
|
|
});
|
|
const startX = useRef(0);
|
|
const startY = useRef(0);
|
|
const startTime = useRef(0);
|
|
const sheetHeight = 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;
|
|
sheetHeight.current = el.getBoundingClientRect().height;
|
|
}, []);
|
|
|
|
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(dy) > Math.abs(dx) ? "vertical" : "horizontal";
|
|
}
|
|
|
|
if (directionLocked.current === "horizontal") return;
|
|
|
|
const clampedY = Math.max(0, dy);
|
|
// `offsetX` is reused as the vertical offset to keep SwipeState shared.
|
|
setSwipe({ offsetX: clampedY, isSwiping: true });
|
|
}, []);
|
|
|
|
const onTouchEnd = useCallback(() => {
|
|
if (directionLocked.current !== "vertical") {
|
|
setSwipe({ offsetX: 0, isSwiping: false });
|
|
return;
|
|
}
|
|
|
|
const elapsed = (Date.now() - startTime.current) / 1000;
|
|
const velocity = swipe.offsetX / elapsed / sheetHeight.current;
|
|
const ratio =
|
|
sheetHeight.current > 0 ? swipe.offsetX / sheetHeight.current : 0;
|
|
|
|
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
|
onDismiss();
|
|
}
|
|
|
|
setSwipe({ offsetX: 0, isSwiping: false });
|
|
}, [swipe.offsetX, onDismiss]);
|
|
|
|
return {
|
|
offsetY: swipe.offsetX,
|
|
isSwiping: swipe.isSwiping,
|
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
|
};
|
|
}
|