716 lines
19 KiB
TypeScript
716 lines
19 KiB
TypeScript
import {
|
|
type RouterProps,
|
|
type StaticRouterProps,
|
|
createRouter,
|
|
} from "@solidjs/router";
|
|
import {
|
|
Component,
|
|
createContext,
|
|
createMemo,
|
|
createRenderEffect,
|
|
Index,
|
|
onMount,
|
|
Show,
|
|
untrack,
|
|
useContext,
|
|
onCleanup,
|
|
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";
|
|
import { useWindowSize } from "@solid-primitives/resize-observer";
|
|
import { isPointNotInRect } from "./dom";
|
|
|
|
let uniqueCounter = 0;
|
|
|
|
function createUniqueId() {
|
|
return `sr-${uniqueCounter++}`;
|
|
}
|
|
|
|
export type StackedRouterProps = Omit<RouterProps, "url">;
|
|
|
|
export type StackFrame = {
|
|
path: string;
|
|
rootId: string;
|
|
state: unknown;
|
|
|
|
animateOpen?: (element: HTMLElement) => Animation;
|
|
animateClose?: (element: HTMLElement) => Animation;
|
|
};
|
|
|
|
export type NewFrameOptions<T> = (T extends undefined
|
|
? {
|
|
state?: T;
|
|
}
|
|
: { state: T }) & {
|
|
/**
|
|
* The new frame should replace the current frame or all the stack.
|
|
*/
|
|
replace?: boolean | "all";
|
|
/**
|
|
* 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 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>();
|
|
|
|
/**
|
|
* Get the possible navigator of the {@link StackedRouter}.
|
|
*
|
|
* @see useNavigator for the navigator usage.
|
|
*/
|
|
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.
|
|
*
|
|
* @see {@link useMaybeNavigator} if you are not sure you are under a {@link StackedRouter}.
|
|
*/
|
|
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<StackFrame>;
|
|
};
|
|
|
|
const CurrentFrameContext =
|
|
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
|
|
|
|
/**
|
|
* Return the current, if possible.
|
|
*
|
|
* @see {@link useCurrentFrame} asserts the frame exists
|
|
*/
|
|
export function useMaybeCurrentFrame() {
|
|
return useContext(CurrentFrameContext);
|
|
}
|
|
|
|
/**
|
|
* Return the current frame, assert the frame exists.
|
|
*
|
|
* @see {@link useMaybeCurrentFrame} if you are not sure you are under a {@link StackedRouter}.
|
|
*/
|
|
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.
|
|
*
|
|
* If this is not called under a {@link StackedRouter}, it always
|
|
* returns `false`.
|
|
*/
|
|
export function useIsFrameSuspended() {
|
|
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();
|
|
if (isPointNotInRect(rect, event.clientX, event.clientY)) {
|
|
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;
|
|
});
|
|
}
|
|
|
|
function isNotInIOSSwipeToBackArea(x: number) {
|
|
return (
|
|
(x > 22 && x < window.innerWidth - 22) ||
|
|
(x < -22 && x > window.innerWidth + 22)
|
|
);
|
|
}
|
|
|
|
function onEntryTouchStart(event: TouchEvent) {
|
|
if (event.touches.length !== 1) {
|
|
return;
|
|
}
|
|
|
|
const [fig0] = event.touches;
|
|
|
|
if (isNotInIOSSwipeToBackArea(fig0.clientX)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
}
|
|
|
|
/**
|
|
* This function contains the state for swipe to back.
|
|
*
|
|
* @returns the props for dialogs to feature swipe to back.
|
|
*/
|
|
function createManagedSwipeToBack(
|
|
stack: readonly Readonly<StackFrame>[],
|
|
onlyPopFrame: (depth: number) => void,
|
|
) {
|
|
let reenterableAnimation: Animation | undefined;
|
|
let origWidth = 0,
|
|
origFigX = 0,
|
|
origFigY = 0;
|
|
|
|
const resetAnimation = () => {
|
|
reenterableAnimation = undefined;
|
|
};
|
|
|
|
const onDialogTouchStart = (
|
|
event: TouchEvent & { currentTarget: HTMLDialogElement },
|
|
) => {
|
|
if (event.touches.length !== 1) {
|
|
return;
|
|
}
|
|
event.stopPropagation();
|
|
|
|
const [fig0] = event.touches;
|
|
const { width } = event.currentTarget.getBoundingClientRect();
|
|
origWidth = width;
|
|
origFigX = fig0.clientX;
|
|
origFigY = fig0.clientY;
|
|
|
|
if (isNotInIOSSwipeToBackArea(fig0.clientX)) {
|
|
return;
|
|
}
|
|
// Prevent the default swipe to back/forward on iOS
|
|
|
|
event.preventDefault();
|
|
};
|
|
|
|
let animationProgressUpdateReleased = true;
|
|
let nextAnimationProgress = 0;
|
|
|
|
const updateAnimationProgress = () => {
|
|
try {
|
|
if (!reenterableAnimation) return;
|
|
const { activeDuration, delay } =
|
|
reenterableAnimation.effect!.getComputedTiming();
|
|
|
|
const totalTime = (delay || 0) + Number(activeDuration);
|
|
reenterableAnimation.currentTime = totalTime * nextAnimationProgress;
|
|
} finally {
|
|
animationProgressUpdateReleased = true;
|
|
}
|
|
};
|
|
|
|
const onDialogTouchMove = (
|
|
event: TouchEvent & { currentTarget: HTMLDialogElement },
|
|
) => {
|
|
if (event.touches.length !== 1) {
|
|
if (reenterableAnimation) {
|
|
reenterableAnimation.reverse();
|
|
reenterableAnimation.play();
|
|
}
|
|
}
|
|
|
|
const [fig0] = event.touches;
|
|
|
|
const ofsX = fig0.clientX - origFigX;
|
|
|
|
if (!reenterableAnimation) {
|
|
if (!(ofsX > 22) || !(Math.abs(fig0.clientY - origFigY) < 44)) {
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
nextAnimationProgress = ofsX / origWidth / window.devicePixelRatio;
|
|
|
|
if (animationProgressUpdateReleased) {
|
|
animationProgressUpdateReleased = false;
|
|
|
|
requestAnimationFrame(updateAnimationProgress);
|
|
}
|
|
};
|
|
|
|
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 {
|
|
"on:touchstart": onDialogTouchStart,
|
|
"on:touchmove": onDialogTouchMove,
|
|
"on:touchend": onDialogTouchEnd,
|
|
"on:touchcancel": onDialogTouchCancel,
|
|
};
|
|
}
|
|
|
|
function animateUntil(
|
|
stepfn: (onCreated: (animation: Animation) => void) => void,
|
|
) {
|
|
const execStep = () => {
|
|
requestAnimationFrame(() => {
|
|
stepfn((step) => {
|
|
step.addEventListener("finish", () => {
|
|
execStep();
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
execStep();
|
|
}
|
|
|
|
function noOp() {}
|
|
|
|
function StaticRouter(props: StaticRouterProps) {
|
|
const url = () => props.url || "";
|
|
|
|
// TODO: support onBeforeLeave, see
|
|
// https://github.com/solidjs/solid-router/blob/main/src/routers/Router.ts
|
|
|
|
return createRouter({
|
|
get: url,
|
|
set: noOp,
|
|
init(notify) {
|
|
createRenderEffect(() => notify(url()));
|
|
return noOp;
|
|
},
|
|
})(props);
|
|
}
|
|
|
|
/**
|
|
* The cache key of saved stack for hot reload.
|
|
*
|
|
* We could not use symbols because every time the hot reload the `Symbol()`
|
|
* call creates a new symbol.
|
|
*/
|
|
const $StackedRouterSavedStack = "$StackedRouterSavedStack";
|
|
|
|
/**
|
|
* The router that stacks the pages.
|
|
*
|
|
* **Routes** The router accepts the {@link RouterProps} excluding the "url" field.
|
|
* You can seamlessly use the `<Route />` from `@solidjs/router`.
|
|
*
|
|
* Be advised that this component is not a drop-in replacement of that router.
|
|
* These primitives from `@solidjs/router` won't work correctly:
|
|
*
|
|
* - `<A />` component - use ~platform/A instead
|
|
* - `useLocation()` - see {@link useCurrentFrame}
|
|
* - `useNavigate()` - see {@link useNavigator}
|
|
*
|
|
* The other primitives may work, as long as they don't rely on the global location.
|
|
* This component uses `@solidjs/router` {@link StaticRouter} to route.
|
|
*
|
|
* **Injecting Safe Area Insets** The router calculate correct
|
|
* `--safe-area-inset-left` and `--safe-area-inset-right` from the window
|
|
* width and `--safe-area-inset-*` from the :root element. That means
|
|
* the injected insets do not reflects the overrides that are not on the :root.
|
|
*
|
|
* The recalculation is only performed when the window size changed.
|
|
*
|
|
* **Navigation Animation** The router provides default animation for
|
|
* navigation.
|
|
*
|
|
* If the default animation does not met your requirement,
|
|
* this component is also intergated with Web Animation API.
|
|
* You can provide {@link NewFrameOptions.animateOpen} and
|
|
* {@link NewFrameOptions.animateClose} to define custom animation.
|
|
*
|
|
* **Swipe to back** For the subpages (the pages stacked on the entry),
|
|
* swipe to back gesture is provided for user experience.
|
|
*
|
|
* Navigation animations (even the custom ones) will be played during
|
|
* swipe to back, please keep in mind when designing animations.
|
|
*
|
|
* The iOS default gesture is blocked on all pages.
|
|
*/
|
|
const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
|
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
|
|
const windowSize = useWindowSize();
|
|
|
|
if (import.meta.hot) {
|
|
const saveStack = () => {
|
|
import.meta.hot!.data[$StackedRouterSavedStack] = unwrap(stack);
|
|
console.debug("stack saved");
|
|
};
|
|
|
|
import.meta.hot.on("vite:beforeUpdate", saveStack);
|
|
onCleanup(() => import.meta.hot!.off("vite:beforeUpdate", saveStack));
|
|
|
|
const loadStack = () => {
|
|
const savedStack = import.meta.hot!.data[$StackedRouterSavedStack];
|
|
if (savedStack) {
|
|
mutStack(savedStack);
|
|
console.debug("stack loaded");
|
|
}
|
|
delete import.meta.hot!.data[$StackedRouterSavedStack];
|
|
};
|
|
|
|
createRenderEffect(() => {
|
|
loadStack();
|
|
});
|
|
}
|
|
|
|
const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
|
|
untrack(() => {
|
|
const frame = {
|
|
path,
|
|
state: opts?.state,
|
|
rootId: createUniqueId(),
|
|
animateOpen: opts?.animateOpen,
|
|
animateClose: opts?.animateClose,
|
|
};
|
|
|
|
const replace = opts?.replace;
|
|
if (replace === "all" || stack.length === 0) {
|
|
mutStack([frame]);
|
|
} else if (replace) {
|
|
const idx = stack.length - 1;
|
|
mutStack(idx, frame);
|
|
} else {
|
|
mutStack(stack.length, frame);
|
|
}
|
|
|
|
const savedStack = serializableStack(stack);
|
|
|
|
if (replace) {
|
|
window.history.replaceState(savedStack, "", path);
|
|
} else {
|
|
window.history.pushState(savedStack, "", path);
|
|
}
|
|
return frame;
|
|
});
|
|
|
|
const onlyPopFrameOnStack = (depth: number) => {
|
|
mutStack((o) => o.toSpliced(o.length - depth, depth));
|
|
};
|
|
|
|
const onlyPopFrame = (depth: number) => {
|
|
onlyPopFrameOnStack(depth);
|
|
window.history.go(-depth);
|
|
};
|
|
|
|
const animatePopOneFrame = (onCreated: (animation: Animation) => void) => {
|
|
const lastFrame = stack[stack.length - 1];
|
|
const element = document.getElementById(
|
|
lastFrame.rootId,
|
|
)! as HTMLDialogElement;
|
|
const createAnimation = lastFrame.animateClose ?? animateClose;
|
|
element.classList.add("animating");
|
|
|
|
const onNavAnimEnd = () => {
|
|
element.classList.remove("animating");
|
|
};
|
|
|
|
requestAnimationFrame(() => {
|
|
const animation = createAnimation(element);
|
|
animation.addEventListener("finish", onNavAnimEnd);
|
|
animation.addEventListener("cancel", onNavAnimEnd);
|
|
onCreated(animation);
|
|
});
|
|
};
|
|
|
|
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) {
|
|
let count = depth;
|
|
animateUntil((created) => {
|
|
if (count > 0) {
|
|
animatePopOneFrame((a) => {
|
|
a.addEventListener("finish", () => onlyPopFrame(1));
|
|
created(a);
|
|
});
|
|
}
|
|
count--;
|
|
});
|
|
} else {
|
|
onlyPopFrame(1);
|
|
}
|
|
});
|
|
|
|
createRenderEffect(() =>
|
|
untrack(() => {
|
|
if (stack.length === 0) {
|
|
const parts = [window.location.pathname] as string[];
|
|
if (window.location.search) {
|
|
parts.push(window.location.search);
|
|
}
|
|
pushFrame(parts.join(""), {
|
|
replace: "all",
|
|
});
|
|
}
|
|
}),
|
|
);
|
|
|
|
createRenderEffect(() => {
|
|
makeEventListener(window, "popstate", (event) => {
|
|
if (!event.state) return;
|
|
|
|
// TODO: verify the stack in state and handling forwards
|
|
|
|
if (stack.length === 0) {
|
|
mutStack(event.state || []);
|
|
} else if (stack.length > event.state.length) {
|
|
let count = stack.length - event.state.length;
|
|
animateUntil((created) => {
|
|
if (count > 0) {
|
|
animatePopOneFrame((a) => {
|
|
a.addEventListener("finish", () => {
|
|
onlyPopFrameOnStack(1);
|
|
created(a);
|
|
});
|
|
});
|
|
}
|
|
count--;
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
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"),
|
|
);
|
|
});
|
|
});
|
|
};
|
|
|
|
const subInsets = createMemo(() => {
|
|
const SUBPAGE_MAX_WIDTH = 560;
|
|
const { width } = windowSize;
|
|
if (width <= SUBPAGE_MAX_WIDTH) {
|
|
// page width = 100vw, use the inset directly
|
|
return {};
|
|
}
|
|
const computedStyle = window.getComputedStyle(
|
|
document.querySelector(":root")!,
|
|
);
|
|
const oinsetLeft = computedStyle
|
|
.getPropertyValue("--safe-area-inset-left")
|
|
.split("px", 1)[0];
|
|
const oinsetRight = computedStyle
|
|
.getPropertyValue("--safe-area-inset-right")
|
|
.split("px", 1)[0];
|
|
const left = Number(oinsetLeft),
|
|
right = Number(oinsetRight.slice(0, oinsetRight.length - 2));
|
|
const totalWidth = SUBPAGE_MAX_WIDTH + left + right;
|
|
if (width >= totalWidth) {
|
|
return {
|
|
"--safe-area-inset-left": "0px",
|
|
"--safe-area-inset-right": "0px",
|
|
};
|
|
}
|
|
const ofs = (totalWidth - width) / 2;
|
|
return {
|
|
"--safe-area-inset-left": `${Math.max(left - ofs, 0)}px`,
|
|
"--safe-area-inset-right": `${Math.max(right - ofs, 0)}px`,
|
|
};
|
|
});
|
|
|
|
const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame);
|
|
|
|
return (
|
|
<NavigatorContext.Provider
|
|
value={{
|
|
push: pushFrame,
|
|
pop: popFrame,
|
|
frames: stack,
|
|
}}
|
|
>
|
|
<Index each={stack}>
|
|
{(frame, index) => {
|
|
const currentFrame = () => {
|
|
return {
|
|
index,
|
|
get frame() {
|
|
return frame();
|
|
},
|
|
};
|
|
};
|
|
|
|
return (
|
|
<CurrentFrameContext.Provider value={currentFrame}>
|
|
<Show
|
|
when={index !== 0}
|
|
fallback={
|
|
<div
|
|
class="StackedPage"
|
|
id={frame().rootId}
|
|
role="presentation"
|
|
on:touchstart={onEntryTouchStart}
|
|
>
|
|
<StaticRouter url={frame().path} {...oprops} />
|
|
</div>
|
|
}
|
|
>
|
|
<dialog
|
|
ref={onBeforeDialogMount}
|
|
class="StackedPage"
|
|
onCancel={[popFrame, 1]}
|
|
onClick={[onDialogClick, popFrame]}
|
|
{...swipeToBackProps}
|
|
id={frame().rootId}
|
|
style={subInsets()}
|
|
>
|
|
<StaticRouter url={frame().path} {...oprops} />
|
|
</dialog>
|
|
</Show>
|
|
</CurrentFrameContext.Provider>
|
|
);
|
|
}}
|
|
</Index>
|
|
</NavigatorContext.Provider>
|
|
);
|
|
};
|
|
|
|
export default StackedRouter;
|