SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
10.5 KB · 296 lines typescript
Raw Blame History
1"use client";2import * as React from "react";34/** Tailwind `md` breakpoint — below it the app renders its mobile shell. */5export const MOBILE_QUERY = "(max-width: 767.98px)";6export const TABLET_QUERY = "(min-width: 768px) and (max-width: 1023.98px)";7export const COARSE_QUERY = "(pointer: coarse)";8export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";910function subscribe(query: string) {11  return (cb: () => void) => {12    const mq = window.matchMedia(query);13    mq.addEventListener("change", cb);14    return () => mq.removeEventListener("change", cb);15  };16}1718/** SSR-safe media query. Returns `fallback` on the server and on the first client render. */19export function useMediaQuery(query: string, fallback = false): boolean {20  return React.useSyncExternalStore(21    subscribe(query),22    () => window.matchMedia(query).matches,23    () => fallback,24  );25}2627export function useIsMobile(): boolean {28  return useMediaQuery(MOBILE_QUERY);29}30export function useIsTablet(): boolean {31  return useMediaQuery(TABLET_QUERY);32}33export function useIsCoarsePointer(): boolean {34  return useMediaQuery(COARSE_QUERY);35}36export function usePrefersReducedMotion(): boolean {37  return useMediaQuery(REDUCED_MOTION_QUERY);38}3940/** True after hydration — use to gate `resolvedTheme`, `window`, etc. */41export function useMounted(): boolean {42  return React.useSyncExternalStore(43    () => () => {},44    () => true,45    () => false,46  );47}4849/**50 * Tracks the on-screen keyboard through `visualViewport` and publishes the hidden51 * height as the CSS variable `--kb` on <html>. Layouts use `.h-app`52 * (`calc(100dvh - var(--kb))`) so the composer always stays above the keyboard.53 * Returns the current inset in px.54 */55export function useKeyboardInset(): number {56  const [inset, setInset] = React.useState(0);57  React.useEffect(() => {58    const vv = window.visualViewport;59    if (!vv) return;60    let raf = 0;61    const update = () => {62      cancelAnimationFrame(raf);63      raf = requestAnimationFrame(() => {64        // Difference between the layout viewport and the visual viewport = keyboard (iOS/Android).65        const hidden = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));66        // Ignore tiny deltas from browser chrome animations.67        const kb = hidden > 60 ? hidden : 0;68        document.documentElement.style.setProperty("--kb", `${kb}px`);69        document.documentElement.toggleAttribute("data-keyboard", kb > 0);70        setInset(kb);71        // iOS scrolls the page when an input focuses; snap it back so fixed elements line up.72        if (kb > 0 && window.scrollY !== 0) window.scrollTo(0, 0);73      });74    };75    vv.addEventListener("resize", update);76    vv.addEventListener("scroll", update);77    update();78    return () => {79      vv.removeEventListener("resize", update);80      vv.removeEventListener("scroll", update);81      cancelAnimationFrame(raf);82      document.documentElement.style.setProperty("--kb", "0px");83      document.documentElement.removeAttribute("data-keyboard");84    };85  }, []);86  return inset;87}8889export interface LongPressOptions {90  /** ms before the press fires (default 420). */91  delay?: number;92  /** Movement tolerance in px before the press is cancelled (default 10). */93  tolerance?: number;94  onLongPress: (e: { x: number; y: number; target: EventTarget | null }) => void;95  /** Optional plain-click handler (fires only when no long press happened). */96  onClick?: (e: React.PointerEvent) => void;97  disabled?: boolean;98}99100/**101 * Long-press gesture for touch surfaces. Returns pointer handlers to spread on the element.102 * Also suppresses the native context menu so iOS doesn't show its own callout.103 */104export function useLongPress({ delay = 420, tolerance = 10, onLongPress, onClick, disabled }: LongPressOptions) {105  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);106  const start = React.useRef<{ x: number; y: number } | null>(null);107  const fired = React.useRef(false);108109  const clear = React.useCallback(() => {110    if (timer.current) clearTimeout(timer.current);111    timer.current = null;112    start.current = null;113  }, []);114115  React.useEffect(() => clear, [clear]);116117  if (disabled) return {};118  return {119    onPointerDown: (e: React.PointerEvent) => {120      if (e.pointerType === "mouse" && e.button !== 0) return;121      fired.current = false;122      start.current = { x: e.clientX, y: e.clientY };123      const target = e.target;124      timer.current = setTimeout(() => {125        fired.current = true;126        if (navigator.vibrate) navigator.vibrate(8);127        onLongPress({ x: start.current?.x ?? e.clientX, y: start.current?.y ?? e.clientY, target });128        clear();129      }, delay);130    },131    onPointerMove: (e: React.PointerEvent) => {132      if (!start.current) return;133      if (Math.abs(e.clientX - start.current.x) > tolerance || Math.abs(e.clientY - start.current.y) > tolerance) clear();134    },135    onPointerUp: (e: React.PointerEvent) => {136      const wasPending = Boolean(timer.current);137      clear();138      if (wasPending && !fired.current) onClick?.(e);139    },140    onPointerCancel: clear,141    onPointerLeave: clear,142    onContextMenu: (e: React.MouseEvent) => {143      // Touch long-press triggers contextmenu on Android; we already handle it.144      if (fired.current || timer.current) e.preventDefault();145    },146  };147}148149export interface SwipeOptions {150  onSwipeLeft?: () => void;151  onSwipeRight?: () => void;152  onSwipeDown?: () => void;153  onSwipeUp?: () => void;154  /** px of travel needed (default 56). */155  threshold?: number;156  /** Only start a horizontal swipe when the touch begins within `edge` px of the left edge. */157  edge?: number;158  disabled?: boolean;159  /** Ignore swipes whose start target (or ancestor) matches this selector, e.g. horizontally scrollable areas. */160  ignoreSelector?: string;161}162163/**164 * Attaches touch swipe recognition to a DOM element (via ref). Direction is locked after the165 * first 12px so vertical scrolling is never hijacked.166 */167export function useSwipe<T extends HTMLElement>(ref: React.RefObject<T | null>, { onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold = 56, edge, disabled, ignoreSelector }: SwipeOptions) {168  React.useEffect(() => {169    const el = ref.current;170    if (!el || disabled) return;171    let sx = 0, sy = 0, dx = 0, dy = 0, active = false, axis: "x" | "y" | null = null;172    const onStart = (e: TouchEvent) => {173      if (e.touches.length !== 1) return;174      const t = e.touches[0];175      if (edge !== undefined && t.clientX > edge) return;176      if (ignoreSelector && (e.target as Element | null)?.closest?.(ignoreSelector)) return;177      sx = t.clientX; sy = t.clientY; dx = 0; dy = 0; active = true; axis = null;178    };179    const onMove = (e: TouchEvent) => {180      if (!active) return;181      const t = e.touches[0];182      dx = t.clientX - sx; dy = t.clientY - sy;183      if (!axis && (Math.abs(dx) > 12 || Math.abs(dy) > 12)) axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";184    };185    const onEnd = () => {186      if (!active) return;187      active = false;188      if (axis === "x" && Math.abs(dx) >= threshold) (dx < 0 ? onSwipeLeft : onSwipeRight)?.();189      else if (axis === "y" && Math.abs(dy) >= threshold) (dy < 0 ? onSwipeUp : onSwipeDown)?.();190    };191    el.addEventListener("touchstart", onStart, { passive: true });192    el.addEventListener("touchmove", onMove, { passive: true });193    el.addEventListener("touchend", onEnd);194    el.addEventListener("touchcancel", onEnd);195    return () => {196      el.removeEventListener("touchstart", onStart);197      el.removeEventListener("touchmove", onMove);198      el.removeEventListener("touchend", onEnd);199      el.removeEventListener("touchcancel", onEnd);200    };201  }, [ref, onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold, edge, disabled, ignoreSelector]);202}203204/** Persisted boolean/string/JSON state in localStorage (SSR-safe: initial render uses `initial`). */205export function useLocalStorage<T>(key: string, initial: T): [T, (v: T | ((prev: T) => T)) => void] {206  const [value, setValue] = React.useState<T>(initial);207  const loaded = React.useRef(false);208  React.useEffect(() => {209    try {210      const raw = window.localStorage.getItem(key);211      // eslint-disable-next-line react-hooks/set-state-in-effect212      if (raw !== null) setValue(JSON.parse(raw) as T);213    } catch {214      /* ignore */215    }216    loaded.current = true;217  }, [key]);218  const set = React.useCallback(219    (v: T | ((prev: T) => T)) => {220      setValue((prev) => {221        const next = typeof v === "function" ? (v as (p: T) => T)(prev) : v;222        try {223          window.localStorage.setItem(key, JSON.stringify(next));224        } catch {225          /* quota */226        }227        return next;228      });229    },230    [key],231  );232  return [value, set];233}234235/** Debounced value. */236export function useDebounced<T>(value: T, ms = 150): T {237  const [v, setV] = React.useState(value);238  React.useEffect(() => {239    const t = setTimeout(() => setV(value), ms);240    return () => clearTimeout(t);241  }, [value, ms]);242  return v;243}244245/** Copy-to-clipboard with a transient `copied` flag. */246export function useCopy(resetMs = 1400): [boolean, (text: string) => Promise<void>] {247  const [copied, setCopied] = React.useState(false);248  const copy = React.useCallback(249    async (text: string) => {250      try {251        await navigator.clipboard.writeText(text);252        setCopied(true);253        setTimeout(() => setCopied(false), resetMs);254      } catch {255        /* ignore */256      }257    },258    [resetMs],259  );260  return [copied, copy];261}262263/** Whether the document is displayed as an installed PWA. */264export function useStandalone(): boolean {265  return useMediaQuery("(display-mode: standalone)");266}267268/** Tracks the active index of a `.snap-row` carousel and lets you scroll to a panel. */269export function useSnapCarousel<T extends HTMLElement>(count: number) {270  const ref = React.useRef<T | null>(null);271  const [index, setIndex] = React.useState(0);272  React.useEffect(() => {273    const el = ref.current;274    if (!el) return;275    let raf = 0;276    const onScroll = () => {277      cancelAnimationFrame(raf);278      raf = requestAnimationFrame(() => {279        const w = el.clientWidth || 1;280        setIndex(Math.max(0, Math.min(count - 1, Math.round(el.scrollLeft / w))));281      });282    };283    el.addEventListener("scroll", onScroll, { passive: true });284    return () => {285      el.removeEventListener("scroll", onScroll);286      cancelAnimationFrame(raf);287    };288  }, [count]);289  const scrollTo = React.useCallback((i: number, smooth = true) => {290    const el = ref.current;291    if (!el) return;292    el.scrollTo({ left: i * el.clientWidth, behavior: smooth ? "smooth" : "auto" });293  }, []);294  return { ref, index, scrollTo };295}296