tutu/src/platform/StackedRouter.tsx

434 lines
12 KiB
TypeScript
Raw Normal View History

2024-11-16 20:04:55 +08:00
import { StaticRouter, type RouterProps } from "@solidjs/router";
import {
Component,
createContext,
createRenderEffect,
createUniqueId,
Index,
2024-11-17 17:37:42 +08:00
onMount,
2024-11-16 20:04:55 +08:00
Show,
untrack,
useContext,
type Accessor,
} from "solid-js";
import { createStore, unwrap } from "solid-js/store";
import "./StackedRouter.css";
import { animateSlideInFromRight, animateSlideOutToRight } from "./anim";
2024-11-16 20:04:55 +08:00
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme";
import { makeEventListener } from "@solid-primitives/event-listener";
2024-11-16 20:04:55 +08:00
export type StackedRouterProps = Omit<RouterProps, "url">;
export type StackFrame = {
path: string;
rootId: string;
state: unknown;
2024-11-17 17:37:42 +08:00
animateOpen?: (element: HTMLElement) => Animation;
animateClose?: (element: HTMLElement) => Animation;
2024-11-16 20:04:55 +08:00
};
export type NewFrameOptions<T> = (T extends undefined
? {
state?: T;
}
: { state: T }) & {
/**
* The new frame should replace the current frame.
*/
2024-11-16 20:04:55 +08:00
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.
*/
2024-11-17 17:37:42 +08:00
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.
*/
2024-11-17 17:37:42 +08:00
animateClose?: StackFrame["animateClose"];
2024-11-16 20:04:55 +08:00
};
export type FramePusher<T, K extends keyof T = keyof T> = T[K] extends
| undefined
| any
? (path: K, state?: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>
: (path: K, state: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>;
export type Navigator<PushGuide = Record<string, any>> = {
frames: readonly StackFrame[];
push: FramePusher<PushGuide>;
pop: (depth?: number) => void;
};
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
export function useMaybeNavigator() {
return useContext(NavigatorContext);
}
2024-11-16 20:04:55 +08:00
/**
* 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();
2024-11-16 20:04:55 +08:00
if (!navigator) {
throw new TypeError("not in available scope of StackedRouter");
}
return navigator;
}
export type CurrentFrame = {
index: number;
frame: Readonly<StackFrame>;
};
const CurrentFrameContext =
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
export function useMaybeCurrentFrame() {
return useContext(CurrentFrameContext);
}
2024-11-16 20:04:55 +08:00
export function useCurrentFrame() {
const frame = useMaybeCurrentFrame();
2024-11-16 20:04:55 +08:00
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;
};
}
2024-11-16 20:04:55 +08:00
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) {
2024-11-17 17:37:42 +08:00
return animateSlideInFromRight(element, {
easing: ANIM_CURVE_DECELERATION,
});
} else {
2024-11-17 17:37:42 +08:00
return element.animate(
{
opacity: [0.5, 1],
},
{ easing: ANIM_CURVE_STD, duration: 220 },
);
}
}
2024-11-17 17:37:42 +08:00
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;
2024-11-17 17:37:42 +08:00
});
}
2024-11-16 20:04:55 +08:00
/**
* The router that stacks the pages.
*/
const StackedRouter: Component<StackedRouterProps> = (oprops) => {
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
untrack(() => {
const frame = {
path,
state: opts?.state,
rootId: createUniqueId(),
2024-11-17 17:37:42 +08:00
animateOpen: opts?.animateOpen,
animateClose: opts?.animateClose,
2024-11-16 20:04:55 +08:00
};
2024-11-16 20:04:55 +08:00
mutStack(opts?.replace ? stack.length - 1 : stack.length, frame);
if (opts?.replace) {
2024-11-17 17:37:42 +08:00
window.history.replaceState(serializableStack(stack), "", path);
} else {
2024-11-17 17:37:42 +08:00
window.history.pushState(serializableStack(stack), "", path);
}
2024-11-16 20:04:55 +08:00
return frame;
});
const onlyPopFrame = (depth: number) => {
mutStack((o) => o.toSpliced(o.length - depth, depth));
window.history.go(-depth);
};
2024-11-16 20:04:55 +08:00
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];
2024-11-17 17:37:42 +08:00
const element = document.getElementById(
lastFrame.rootId,
)! as HTMLDialogElement;
const createAnimation = lastFrame.animateClose ?? animateClose;
2024-11-16 20:04:55 +08:00
requestAnimationFrame(() => {
element.classList.add("animating");
2024-11-17 17:37:42 +08:00
const animation = createAnimation(element);
animation.addEventListener("finish", () => {
element.classList.remove("animating");
2024-11-17 17:37:42 +08:00
onlyPopFrame(depth);
});
2024-11-16 20:04:55 +08:00
});
} else {
onlyPopFrame(depth);
2024-11-16 20:04:55 +08:00
}
});
createRenderEffect(() => {
if (stack.length === 0) {
pushFrame(window.location.pathname);
2024-11-16 20:04:55 +08:00
}
});
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);
}
});
});
2024-11-16 20:04:55 +08:00
const onBeforeDialogMount = (element: HTMLDialogElement) => {
2024-11-17 17:37:42 +08:00
onMount(() => {
const lastFr = untrack(() => stack[stack.length - 1]);
const createAnimation = lastFr.animateOpen ?? animateOpen;
2024-11-16 20:04:55 +08:00
requestAnimationFrame(() => {
element.showModal();
2024-11-17 17:37:42 +08:00
element.classList.add("animating");
const animation = createAnimation(element);
animation.addEventListener("finish", () =>
element.classList.remove("animating"),
);
2024-11-16 20:04:55 +08:00
});
});
};
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();
};
2024-11-16 20:04:55 +08:00
return (
<NavigatorContext.Provider
value={{
push: pushFrame,
pop: popFrame,
frames: stack,
}}
>
<Index each={stack}>
{(frame, index) => {
const currentFrame = () => {
return {
index,
frame: frame(),
};
};
return (
<CurrentFrameContext.Provider value={currentFrame}>
<Show
when={index !== 0}
fallback={
<div
class="StackedPage"
id={frame().rootId}
role="presentation"
>
<StaticRouter url={frame().path} {...oprops} />
</div>
}
>
<dialog
ref={onBeforeDialogMount}
class="StackedPage"
onCancel={[popFrame, 1]}
onClick={[onDialogClick, popFrame]}
on:touchstart={onDialogTouchStart}
on:touchmove={onDialogTouchMove}
on:touchend={onDialogTouchEnd}
on:touchcancel={onDialogTouchCancel}
2024-11-16 20:04:55 +08:00
id={frame().rootId}
>
<StaticRouter url={frame().path} {...oprops} />
</dialog>
</Show>
</CurrentFrameContext.Provider>
);
}}
</Index>
</NavigatorContext.Provider>
);
};
export default StackedRouter;