Files
UHD_Media_Tool/apps/web/app/lib/router-compat.tsx
T

186 lines
6.6 KiB
TypeScript

import {
type AnyRouter,
Outlet,
useRouter,
useRouterState,
useParams as useTanstackParams,
} from "@tanstack/react-router";
import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from "react";
import { createContext, forwardRef, useCallback, useContext } from "react";
// Keep URL search values as strings while the console's existing links move to TanStack Router.
export function parseUrlSearch(value: string): Record<string, string | string[]> {
const result: Record<string, string | string[]> = Object.create(null);
for (const [key, part] of new URLSearchParams(value)) {
const previous = result[key];
result[key] = previous === undefined ? part : [...(Array.isArray(previous) ? previous : [previous]), part];
}
return result;
}
export function stringifyUrlSearch(search: Record<string, unknown>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(search)) {
if (value == null) continue;
for (const part of Array.isArray(value) ? value : [value]) params.append(key, String(part));
}
const value = params.toString();
return value ? `?${value}` : "";
}
export type Location = {
pathname: string;
search: string;
hash: string;
state: unknown;
key: string;
};
const TestLocation = createContext<Location | null>(null);
let navigationSequence = 0;
declare module "@tanstack/react-router" {
interface HistoryState {
__umtNavigationKey?: string;
}
}
export function useLocation(): Location {
const testLocation = useContext(TestLocation);
// The test provider is fixed for a render tree; production always uses RouterProvider.
if (testLocation) return testLocation;
// biome-ignore lint/correctness/useHookAtTopLevel: provider branch is stable for the component lifetime
const location = useRouterState({ select: (state) => state.location });
return {
pathname: location.pathname,
search: location.searchStr,
hash: location.hash ? (location.hash.startsWith("#") ? location.hash : `#${location.hash}`) : "",
state: location.state,
key: location.state.__TSR_key ?? location.href,
};
}
type To = string | { pathname?: string; search?: string; hash?: string };
type NavigationOptions = { replace?: boolean; state?: unknown; preventScrollReset?: boolean };
export function navigateTo(router: AnyRouter, target: URL, options: NavigationOptions = {}) {
return router.navigate({
to: target.pathname,
search: parseUrlSearch(target.search),
hash: target.hash.slice(1),
replace: options.replace,
resetScroll: !options.preventScrollReset,
// 显式导航即使地址和业务 state 相同,也要更新 key,让设置搜索能再次定位同一字段。
state: {
...((options.state ?? {}) as Record<string, unknown>),
__umtNavigationKey: `${Date.now()}:${++navigationSequence}`,
},
});
}
export function resolveTo(to: To, current: Pick<Location, "pathname" | "search" | "hash">): string {
if (typeof to === "string") {
if (to.startsWith("?") || to.startsWith("#"))
return `${current.pathname}${to.startsWith("#") ? current.search : ""}${to}`;
return to;
}
return `${to.pathname ?? current.pathname}${to.search ?? ""}${to.hash ?? ""}`;
}
export function useNavigate() {
const router = useRouter({ warn: false });
const { pathname, search, hash } = useLocation();
return useCallback(
(to: To | number, options: NavigationOptions = {}) => {
if (typeof to === "number") {
router?.history.go(to);
return;
}
const target = new URL(resolveTo(to, { pathname, search, hash }), window.location.origin);
if (target.origin !== window.location.origin) {
window.location.assign(target.href);
return;
}
if (!router) return;
void navigateTo(router, target, options);
},
[router, pathname, search, hash],
);
}
type SearchInput = URLSearchParams | Record<string, string>;
export function useSearchParams(): [
URLSearchParams,
(next: SearchInput | ((current: URLSearchParams) => SearchInput), options?: NavigationOptions) => void,
] {
const location = useLocation();
const navigate = useNavigate();
const set = useCallback(
(next: SearchInput | ((current: URLSearchParams) => SearchInput), options?: NavigationOptions) => {
const value = typeof next === "function" ? next(new URLSearchParams(location.search)) : next;
const params = value instanceof URLSearchParams ? value : new URLSearchParams(value);
navigate({ search: params.toString() ? `?${params}` : "" }, options);
},
[location.search, navigate],
);
return [new URLSearchParams(location.search), set];
}
export function useParams<T extends Record<string, string | undefined> = Record<string, string | undefined>>(): T {
const testLocation = useContext(TestLocation);
if (testLocation) return {} as T;
// biome-ignore lint/correctness/useHookAtTopLevel: provider branch is stable for the component lifetime
return useTanstackParams({ strict: false } as never) as T;
}
export type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
to: To;
replace?: boolean;
state?: unknown;
preventScrollReset?: boolean;
};
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
{ to, replace, state, preventScrollReset, onClick, target, children, ...props },
ref,
) {
const location = useLocation();
const navigate = useNavigate();
const href = resolveTo(to, location);
const click = (event: MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
target === "_blank" ||
props.download
)
return;
const url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin) return;
event.preventDefault();
navigate(href, { replace, state, preventScrollReset });
};
return (
<a {...props} ref={ref} href={href} target={target} onClick={click}>
{children}
</a>
);
});
export function redirect(to: string): Response {
return new Response(null, { status: 302, headers: { Location: to } });
}
export { Outlet };
// Existing component tests mount only a memory navigation context.
export function MemoryRouter({ children, initialEntries }: { children?: ReactNode; initialEntries?: string[] }) {
const url = new URL(initialEntries?.[0] ?? "/", "http://localhost");
const location: Location = { pathname: url.pathname, search: url.search, hash: url.hash, state: null, key: "test" };
return <TestLocation value={location}>{children}</TestLocation>;
}