import { StaticRouter, type RouterProps } from "@solidjs/router"; import { Component, createContext, createRenderEffect, createUniqueId, Index, onMount, Show, untrack, useContext, type Accessor, } from "solid-js"; import { createStore, unwrap } from "solid-js/store"; import "./StackedRouter.css"; import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; import { makeEventListener } from "@solid-primitives/event-listener"; export type StackedRouterProps = Omit; export type StackFrame = { path: string; rootId: string; state: unknown; animateOpen?: (element: HTMLElement) => Animation; animateClose?: (element: HTMLElement) => Animation; }; export type NewFrameOptions = (T extends undefined ? { state?: T; } : { state: T }) & { /** * The new frame should replace the current frame. */ replace?: boolean; /** * The animatedOpen phase of the life cycle. * * You can use this hook to animate the opening * of the frame. In this phase, the frame content is created * and is mounted to the document. * * You must return an {@link Animation}. This function must be * without side effects. This phase is ended after the {@link Animation} * finished. */ animateOpen?: StackFrame["animateOpen"]; /** * The animatedClose phase of the life cycle. * * You can use this hook to animate the closing of the frame. * In this phase, the frame content is still mounted in the * document and will be unmounted after this phase. * * You must return an {@link Animation}. This function must be * without side effects. This phase is ended after the * {@link Animation} finished. */ animateClose?: StackFrame["animateClose"]; }; export type FramePusher = T[K] extends | undefined | any ? (path: K, state?: Readonly>) => Readonly : (path: K, state: Readonly>) => Readonly; export type Navigator> = { frames: readonly StackFrame[]; push: FramePusher; pop: (depth?: number) => void; }; const NavigatorContext = /* @__PURE__ */ createContext(); export function useMaybeNavigator() { return useContext(NavigatorContext); } /** * Get the navigator of the {@link StackedRouter}. * * This function returns a {@link Navigator} without available * push guide. Push guide is a record type contains available * path and its state. If you need push guide, you may want to * define your own function (like `useAppNavigator`) and cast the * navigator to the type you need. */ export function useNavigator() { const navigator = useMaybeNavigator(); if (!navigator) { throw new TypeError("not in available scope of StackedRouter"); } return navigator; } export type CurrentFrame = { index: number; frame: Readonly; }; const CurrentFrameContext = /* @__PURE__ */ createContext>>(); export function useMaybeCurrentFrame() { return useContext(CurrentFrameContext); } export function useCurrentFrame() { const frame = useMaybeCurrentFrame(); if (!frame) { throw new TypeError("not in available scope of StackedRouter"); } return frame; } /** * Return an accessor of is current frame is suspended. * * A suspended frame is the one not on the top. "Suspended" * is the description of a certain situtation, not in the life cycle * of a frame. */ export function useMaybeIsFrameSuspended() { const { frames } = useMaybeNavigator() || {}; if (typeof frames === "undefined") { return () => false; } const thisFrame = useCurrentFrame(); return () => { const idx = thisFrame().index; return frames.length - 1 > idx; }; } function onDialogClick( onClose: () => void, event: MouseEvent & { currentTarget: HTMLDialogElement }, ) { if (event.target !== event.currentTarget) return; const rect = event.currentTarget.getBoundingClientRect(); const isNotInDialog = event.clientY < rect.top || event.clientY > rect.bottom || event.clientX < rect.left || event.clientX > rect.right; if (isNotInDialog) { onClose(); } } function animateClose(element: HTMLElement) { if (window.innerWidth <= 560) { return animateSlideOutToRight(element, { easing: ANIM_CURVE_DECELERATION }); } else { return element.animate( { opacity: [0.5, 0], }, { easing: ANIM_CURVE_STD, duration: 220 }, ); } } function animateOpen(element: HTMLElement) { if (window.innerWidth <= 560) { return animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION, }); } else { return element.animate( { opacity: [0.5, 1], }, { easing: ANIM_CURVE_STD, duration: 220 }, ); } } function serializableStack(stack: readonly StackFrame[]) { const frames = unwrap(stack); return frames.map((fr) => { return fr.animateClose || fr.animateOpen ? { path: fr.path, rootId: fr.rootId, state: fr.state, } : fr; }); } /** * The router that stacks the pages. */ const StackedRouter: Component = (oprops) => { const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" }); const pushFrame = (path: string, opts?: Readonly>) => untrack(() => { const frame = { path, state: opts?.state, rootId: createUniqueId(), animateOpen: opts?.animateOpen, animateClose: opts?.animateClose, }; mutStack(opts?.replace ? stack.length - 1 : stack.length, frame); if (opts?.replace) { window.history.replaceState(serializableStack(stack), "", path); } else { window.history.pushState(serializableStack(stack), "", path); } return frame; }); const onlyPopFrame = (depth: number) => { mutStack((o) => o.toSpliced(o.length - depth, depth)); window.history.go(-depth); }; const popFrame = (depth: number = 1) => untrack(() => { if (import.meta.env.DEV) { if (depth < 0) { console.warn("the depth to pop should not < 0, now is", depth); } } if (stack.length > 1) { const lastFrame = stack[stack.length - 1]; const element = document.getElementById( lastFrame.rootId, )! as HTMLDialogElement; const createAnimation = lastFrame.animateClose ?? animateClose; requestAnimationFrame(() => { element.classList.add("animating"); const animation = createAnimation(element); animation.addEventListener("finish", () => { element.classList.remove("animating"); onlyPopFrame(depth); }); }); } else { onlyPopFrame(depth); } }); createRenderEffect(() => { if (stack.length === 0) { mutStack(0, { path: window.location.pathname, rootId: createUniqueId(), }); } }); createRenderEffect(() => { makeEventListener(window, "popstate", (event) => { if (!event.state) return; if (stack.length === 0) { mutStack(event.state); } else if (stack.length > event.state.length) { popFrame(stack.length - event.state.length); } }); }); const onBeforeDialogMount = (element: HTMLDialogElement) => { onMount(() => { const lastFr = untrack(() => stack[stack.length - 1]); const createAnimation = lastFr.animateOpen ?? animateOpen; requestAnimationFrame(() => { element.showModal(); element.classList.add("animating"); const animation = createAnimation(element); animation.addEventListener("finish", () => element.classList.remove("animating"), ); }); }); }; let reenterableAnimation: Animation | undefined; let origX = 0, origWidth = 0; const resetAnimation = () => { reenterableAnimation = undefined; }; const onDialogTouchStart = ( event: TouchEvent & { currentTarget: HTMLDialogElement }, ) => { if (event.touches.length !== 1) { return; } const [fig0] = event.touches; const { x, width } = event.currentTarget.getBoundingClientRect(); if (fig0.clientX < x - 22 || fig0.clientX > x + 22) { return; } origX = x; origWidth = width; event.preventDefault(); event.stopPropagation(); const lastFr = stack[stack.length - 1]; const createAnimation = lastFr.animateClose ?? animateClose; reenterableAnimation = createAnimation(event.currentTarget); reenterableAnimation.pause(); reenterableAnimation.addEventListener("finish", resetAnimation); reenterableAnimation.addEventListener("cancel", resetAnimation); }; const onDialogTouchMove = ( event: TouchEvent & { currentTarget: HTMLDialogElement }, ) => { if (event.touches.length !== 1) { if (reenterableAnimation) { reenterableAnimation.reverse(); reenterableAnimation.play(); } } if (!reenterableAnimation) return; event.preventDefault(); event.stopPropagation(); const [fig0] = event.touches; const ofsX = fig0.clientX - origX; const pc = ofsX / origWidth / window.devicePixelRatio; const { activeDuration, delay } = reenterableAnimation.effect!.getComputedTiming(); const totalTime = (delay || 0) + Number(activeDuration); reenterableAnimation.currentTime = totalTime * pc; }; const onDialogTouchEnd = (event: TouchEvent) => { if (!reenterableAnimation) return; event.preventDefault(); event.stopPropagation(); const { activeDuration, delay } = reenterableAnimation.effect!.getComputedTiming(); const totalTime = (delay || 0) + Number(activeDuration); if (Number(reenterableAnimation.currentTime) / totalTime > 0.1) { reenterableAnimation.addEventListener("finish", () => { onlyPopFrame(1); }); reenterableAnimation.play(); } else { reenterableAnimation.cancel(); } }; const onDialogTouchCancel = (event: TouchEvent) => { if (!reenterableAnimation) return; event.preventDefault(); event.stopPropagation(); reenterableAnimation.cancel(); }; return ( {(frame, index) => { const currentFrame = () => { return { index, frame: frame(), }; }; return ( } > ); }} ); }; export default StackedRouter;