Merge branch 'v2'
This commit is contained in:
commit
3316a01ea8
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
bun.lockb
|
171
README.md
171
README.md
@ -1,97 +1,108 @@
|
|||||||
# @elexis/router
|
# @elexis/router
|
||||||
Single page app router extension for [ElexisJS](https://github.com/defaultkavy/elexis)
|
一个基于 ElexisJS 的布局网页路由工具。
|
||||||
|
|
||||||
## Installation
|
## 初步认识 $Router 以及 $Route
|
||||||
```
|
这个工具基于两个基本的概念模块来构建:解析模块以及蓝图模块。我们先来看看如何利用此工具实现一个简单的网页路径布局:
|
||||||
npm i @elexis/router
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
```ts
|
|
||||||
import 'elexis';
|
|
||||||
import '@elexis/router';
|
|
||||||
import { Router, Route } from '@elexis/router'
|
|
||||||
|
|
||||||
// create new Router with base path '/',
|
|
||||||
const router = new Router('/');
|
|
||||||
|
|
||||||
// append router view element
|
|
||||||
const $app = $('app').content([
|
|
||||||
router.$view
|
|
||||||
])
|
|
||||||
|
|
||||||
const home_page_route = new Route('/', () => {
|
|
||||||
// which this callback function return will be appended
|
|
||||||
// into router view element when path match
|
|
||||||
return $('h1').content('This is a Homepage!')
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add home_page_route into router
|
|
||||||
router.addRoute(home_page_route);
|
|
||||||
// Router starting listen location path change
|
|
||||||
router.listen();
|
|
||||||
```
|
|
||||||
> Without `Router.listen()`, Router will do nothing when page loaded.
|
|
||||||
|
|
||||||
## Global $ Methods
|
|
||||||
```ts
|
```ts
|
||||||
import 'elexis';
|
import 'elexis';
|
||||||
import '@elexis/router';
|
import '@elexis/router';
|
||||||
|
|
||||||
$.open('/about') // open /about page without load page
|
$(document.body).content([
|
||||||
$.back() // back
|
// Router base on '/' path
|
||||||
$.replace('/hello') // replace current page history state with url
|
$('router').base('/').map([
|
||||||
```
|
// Root page
|
||||||
|
$('route').path('/').builder(() => [
|
||||||
|
$('h1').content('Hello, World!'),
|
||||||
|
$('a').content('Home').href('/home')
|
||||||
|
]),
|
||||||
|
|
||||||
## Events
|
// Home page
|
||||||
|
$('route').path('/home').builder(() => [
|
||||||
### Router event: load
|
$('h1').content('Hello, Home!'),
|
||||||
```ts
|
|
||||||
const gallery_route = new Route('/gallery', ({loaded}) => {
|
|
||||||
|
|
||||||
async function $ImageList() {
|
|
||||||
// fetch images and return elements
|
|
||||||
|
|
||||||
// after all image loaded, using loaded function.
|
|
||||||
// this will trigger load event on anccestor router
|
|
||||||
loaded();
|
|
||||||
}
|
|
||||||
return $('div').content([
|
|
||||||
$ImageList()
|
|
||||||
])
|
])
|
||||||
})
|
])
|
||||||
|
])
|
||||||
router.on('load', () => {...})
|
|
||||||
```
|
```
|
||||||
|
### 关于 $Router
|
||||||
|
这是一个用以解析路径并将正确内容显示在页面中的工具,由多个 `$Router` 组件构建的网页可以实现复杂的路径导航。
|
||||||
|
|
||||||
### RouteRecord event: open
|
在上面的例子中,我们直接在 `document.body` 组件中置入一个 `$Router` 组件,并将它的基本路径设置为 `/`。这意味着我们将所有开头为 `/` 的路径都交给这个 `$Router` 进行解析。
|
||||||
|
|
||||||
|
最后,我们使用 `map()` 函数来规划不同子路径所指向的页面蓝图,也就是 `$Route` 组件。
|
||||||
|
|
||||||
|
### 关于 $Route
|
||||||
|
在传入 `$Router.map()` 方法的参数当中,`$Route` 组件并不会被使用在真实的 DOM 当中。它更像是一个蓝图的概念,你可以为这个组件添加多个属性,并使用 `builder()` 函数来规划这个页面会出现的内容。
|
||||||
|
|
||||||
|
在 Router 解析地址后,它会创建一个指向该地址的 `$Route` 组件,并将蓝图上的属性复制到新组件上,以及构建传入 `builder()` 函数的页面内容。
|
||||||
|
|
||||||
|
## 如何实现单页应用路由(Single Page App Routing)
|
||||||
|
只要使用了 `$Router` 进行路径规划,你的网页就已经具备了单页应用路由的功能。使用 `$.open('PATH')` 就能在不跳转页面的情况下打开目标页面了。
|
||||||
```ts
|
```ts
|
||||||
const viewer_route = new Route('/viewer', ({record}) => {
|
$('button').content('Open Home Page').on('click', () => $.open('/home'))
|
||||||
const page_open_count$ = $.state(0);
|
|
||||||
|
|
||||||
// this event will be fire everytime this page opened
|
|
||||||
record.on('open', () => {
|
|
||||||
page_open_count$.set( page_open_count$.value + 1 );
|
|
||||||
})
|
|
||||||
|
|
||||||
return $('div').content(page_open_count$)
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
在浏览器的预设中,`<a>` 组件的链接是会以跳转页面的形式打开链接的。你可以单独对每一个 `<a>` 设置触发事件来避免跳转,或者你可以直接使用一行代码将所有 `<a>` 组件的链接都预设为路由器控制:
|
||||||
### Router event: notfound
|
|
||||||
```ts
|
```ts
|
||||||
// Route will remove all child of view when path is not exist.
|
// 将这一行代码写在程序的入口文件中
|
||||||
// Using preventDefault function to prevent this action.
|
$.anchorHandler = ($a) => $.open($a.href());
|
||||||
router.on('notfound', ({preventDefault}) => {
|
|
||||||
preventDefault(); // prevent remove all child of view
|
|
||||||
... // do something
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### `static` Router event: pathchange
|
## 路径参数
|
||||||
|
在规划路径时,你可以在路径中的某一段设置变量,并且可以直接在构建内容时获取该变量对应在路径中的值:
|
||||||
```ts
|
```ts
|
||||||
// This event fired on location change happened
|
$(document.body).content([
|
||||||
Router.on('pathchange', () => {
|
$('router').base('/').map([
|
||||||
... // do something
|
// Root page
|
||||||
})
|
$('route').path('/').builder(() => [
|
||||||
|
$('h1').content('Hello, World!'),
|
||||||
|
$('a').content('Elexis').href('/Elexis/greating')
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Greating page
|
||||||
|
$('route').path('/:name/greating').builder(({params}) => [
|
||||||
|
$('h1').content(`Hello, ${params.name}!`),
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## 路由嵌套
|
||||||
|
多个路由器组成的路由嵌套可实现更复杂且精确的网页内容展示,这些路由组件可根据网址变化并在遵循路径规划的规则下改变显示内容。以下代码能够展示路由嵌套所带来的可拓展性:
|
||||||
|
```ts
|
||||||
|
$(document.body).content([
|
||||||
|
$('router').base('/').map([
|
||||||
|
$('route').path('/').builder(() => [
|
||||||
|
$('h1').content('Welcome!'),
|
||||||
|
// navigation
|
||||||
|
$('ul').content([
|
||||||
|
$('li').content($('a').content('Intro').href('/')),
|
||||||
|
$('li').content($('a').content('About Me').href('/about')),
|
||||||
|
$('li').content($('a').content('Contact').href('/contact'))
|
||||||
|
]),
|
||||||
|
// nested router
|
||||||
|
$('router').base('/').map([
|
||||||
|
$('route').path('/').builder(() => $('h2').content('Intro')),
|
||||||
|
$('route').path('/about').builder(() => $('h2').content('About')),
|
||||||
|
$('route').path('/contact').builder(() => [
|
||||||
|
$('h2').content('Contact'),
|
||||||
|
// navigation
|
||||||
|
$('ul').content([
|
||||||
|
$('li').content($('a').content('Email').href('#email')),
|
||||||
|
$('li').content($('a').content('Phone').href('#phone')),
|
||||||
|
]),
|
||||||
|
// nested router
|
||||||
|
$('router').base('/contact').map([
|
||||||
|
$('route').path(['/', '#email']).builder(() => $('p').content('elexis@example.com')),
|
||||||
|
$('route').path('#phone').builder(() => $('p').content('012-456789')),
|
||||||
|
])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## 多路径指向单一页面
|
||||||
|
将类型 `string[]` 导入 `path()` 函数中,能够实现多个路径指向同一个页面的结果。
|
||||||
|
```ts
|
||||||
|
$('route').path(['/', '/intro']).builder(() => $('h1').content('Intro'));
|
||||||
```
|
```
|
29
index.ts
29
index.ts
@ -1,22 +1,25 @@
|
|||||||
import 'elexis';
|
import 'elexis';
|
||||||
import { Router } from './lib/Router';
|
import { $Router } from './lib/$Router';
|
||||||
|
import { $Route } from './lib/$Route';
|
||||||
declare module 'elexis' {
|
declare module 'elexis' {
|
||||||
export namespace $ {
|
export namespace $ {
|
||||||
export const routers: Set<Router>;
|
export interface TagNameElementMap {
|
||||||
export function open(path: string | URL | undefined): typeof Router;
|
'router': typeof $Router;
|
||||||
export function replace(path: string | URL | undefined): typeof Router;
|
'route': typeof $Route;
|
||||||
export function back(): typeof Router;
|
}
|
||||||
|
export function open(path: string | URL | undefined): typeof $Router;
|
||||||
|
export function replace(path: string | URL | undefined): typeof $Router;
|
||||||
|
export function back(): typeof $Router;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$.registerTagName('router', $Router);
|
||||||
|
$.registerTagName('route', $Route);
|
||||||
|
|
||||||
Object.assign($, {
|
Object.assign($, {
|
||||||
routers: Router.routers,
|
open(path: string | URL | undefined) { return $Router.open(path) },
|
||||||
open(path: string | URL | undefined) { return Router.open(path) },
|
replace(path: string | URL | undefined) { return $Router.replace(path) },
|
||||||
replace(path: string | URL | undefined) { return Router.replace(path) },
|
back() { return $Router.back() }
|
||||||
back() { return Router.back() }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
addEventListener('popstate', Router.popstate); // Start listening
|
export * from './lib/$Route';
|
||||||
|
export * from './lib/$Router';
|
||||||
export * from './lib/Route';
|
|
||||||
export * from './lib/Router';
|
|
||||||
|
49
lib/$Route.ts
Normal file
49
lib/$Route.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { $Container, $ContainerContentType, $ContainerOptions, $EventManager } from "elexis";
|
||||||
|
|
||||||
|
export interface $RouteOptions extends $ContainerOptions {}
|
||||||
|
export class $Route<Path = PathParams<''>> extends $Container {
|
||||||
|
#path: $RoutePathType = '';
|
||||||
|
#builder?: (record: $RouteRecord<Path>) => OrMatrix<$ContainerContentType>;
|
||||||
|
events = new $EventManager<$RouteEventMap>().register('opened', 'closed')
|
||||||
|
readonly rendered: boolean = false;
|
||||||
|
constructor(options?: $RouteOptions) {
|
||||||
|
super('route', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
path(): $RoutePathType;
|
||||||
|
path<P extends $RoutePathType>(pathname: P): $Route<P extends string ? PathParams<P> : ''>;
|
||||||
|
path(pathname?: $RoutePathType): $RoutePathType | $Route<any> { return $.fluent(this, arguments, () => this.#path, () => this.#path = pathname ?? this.#path ) }
|
||||||
|
|
||||||
|
builder(builder: (record: $RouteRecord<Path>) => OrMatrix<$ContainerContentType>) {
|
||||||
|
this.#builder = builder;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(options: {params: Path}) {
|
||||||
|
if (this.#builder) this.content(this.#builder({
|
||||||
|
$route: this,
|
||||||
|
params: options.params
|
||||||
|
}));
|
||||||
|
(this as Mutable<$Route<Path>>).rendered = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build(options: {params: Path}) {
|
||||||
|
return new $Route({dom: this.dom.cloneNode() as HTMLElement}).self(($route) => {
|
||||||
|
if (this.#builder) $route.builder(this.#builder as any).render({params: options.params as any})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface $RouteRecord<Path> {
|
||||||
|
$route: $Route<Path>;
|
||||||
|
params: Path
|
||||||
|
}
|
||||||
|
interface $RouteEventMap {
|
||||||
|
opened: [];
|
||||||
|
closed: []
|
||||||
|
}
|
||||||
|
export type $RoutePathType = string | string[];
|
||||||
|
type PathParams<Path> = Path extends `${infer Segment}/${infer Rest}`
|
||||||
|
? Segment extends `${string}:${infer Param}` ? Record<Param, string> & PathParams<Rest> : PathParams<Rest>
|
||||||
|
: Path extends `${string}:${infer Param}` ? Record<Param,string> : {}
|
181
lib/$Router.ts
Normal file
181
lib/$Router.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { $EventManager } from "elexis";
|
||||||
|
import { $Route, $RoutePathType } from "./$Route";
|
||||||
|
import { $View, $ViewOptions } from "@elexis/view";
|
||||||
|
|
||||||
|
export interface $RouterOptions extends $ViewOptions {}
|
||||||
|
export class $Router extends $View {
|
||||||
|
#base: string = '';
|
||||||
|
routes = new Map<$RoutePathType, $Route>();
|
||||||
|
static routers = new Set<$Router>();
|
||||||
|
static events = new $EventManager<$RouterEventMap>().register('stateChange')
|
||||||
|
static navigationDirection: $RouterNavigationDirection
|
||||||
|
static historyIndex = 0;
|
||||||
|
static url = new URL(location.href);
|
||||||
|
private static scrollHistoryKey = `$ROUTER_SCROLL_HISTORY`;
|
||||||
|
constructor(options?: $RouterOptions) {
|
||||||
|
super({tagname: 'router', ...options});
|
||||||
|
$Router.routers.add(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
base(): string;
|
||||||
|
base(pathname: string): this;
|
||||||
|
base(pathname?: string) { return $.fluent(this, arguments, () => this.#base, () => { this.#base = pathname ?? this.#base }) }
|
||||||
|
|
||||||
|
map(routes: OrMatrix<$Route<any>>) {
|
||||||
|
routes = $.orArrayResolve(routes);
|
||||||
|
for (const route of routes) {
|
||||||
|
if (route instanceof Array) this.map(route);
|
||||||
|
else {
|
||||||
|
this.routes.set(route.path(), route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.resolve();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected resolve(): Promise<$RouterResolveResult> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!location.pathname.startsWith(this.#base)) return resolve($RouterResolveResult.NotMatchBase);
|
||||||
|
const locationPath = location.pathname.replace(this.#base, '/').replace('//', '/') // /a/b
|
||||||
|
const locationParts = locationPath.split('/').map(path => `/${path}`);
|
||||||
|
const find = () => {
|
||||||
|
const matchedRoutes: {deep: number, $route: $Route, params: {[key: string]: string}, pathId: string}[] = []
|
||||||
|
for (const [routePathResolve, $route] of this.routes) {
|
||||||
|
const routePathList = $.orArrayResolve(routePathResolve);
|
||||||
|
for (const routePath of routePathList) {
|
||||||
|
let deep = 0, params = {};
|
||||||
|
if (routePath.startsWith('#')) {
|
||||||
|
// multiple route path will set the first path as pathId
|
||||||
|
if (routePath === location.hash) return {deep, $route, params, pathId: routePathList[0]};
|
||||||
|
else continue;
|
||||||
|
}
|
||||||
|
const routeParts = routePath.split('/').map(path => `/${path}`)
|
||||||
|
if (locationParts.length < routeParts.length) continue;
|
||||||
|
for (let i = 0; i < routeParts.length; i ++) {
|
||||||
|
if (routeParts[i].startsWith('/:')) { deep++; Object.assign(params, {[routeParts[i].replace('/:', '')]: locationParts[i].replace('/', '')}); continue; }
|
||||||
|
else if (routeParts[i] === locationParts[i]) { deep++; continue; }
|
||||||
|
else { break; }
|
||||||
|
}
|
||||||
|
// route path with params will set the locationPath as pathId
|
||||||
|
matchedRoutes.push({deep, $route, params, pathId: Object.keys(params).length === 0 ? routePathList[0] : locationPath})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matchedRoutes.sort((a, b) => b.deep - a.deep).at(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const $routeData = find();
|
||||||
|
if (!$routeData) return resolve($RouterResolveResult.NotFound);
|
||||||
|
const {$route, params, pathId} = $routeData;
|
||||||
|
if (pathId === this.contentId) return resolve($RouterResolveResult.OK); // current route
|
||||||
|
this.events.once('rendered', ({nextContent, previousContent}) => {
|
||||||
|
if (previousContent instanceof $Route) previousContent.events.fire('closed');
|
||||||
|
if (nextContent instanceof $Route) nextContent.events.fire('opened');
|
||||||
|
resolve($RouterResolveResult.OK);
|
||||||
|
});
|
||||||
|
if (!this.viewCache.get(pathId)) {
|
||||||
|
const $buildedRoute = $route.build({params});
|
||||||
|
this.setView(pathId, $buildedRoute);
|
||||||
|
}
|
||||||
|
this.switchView(pathId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static init() {
|
||||||
|
if (!history.state || 'index' in history.state === false) {
|
||||||
|
const state: $RouterState = { index: $Router.historyIndex }
|
||||||
|
history.replaceState(state, '')
|
||||||
|
} else {
|
||||||
|
$Router.historyIndex = history.state.index
|
||||||
|
}
|
||||||
|
$Router.navigationDirection = $RouterNavigationDirection.Forward;
|
||||||
|
$Router.resolve();
|
||||||
|
window.addEventListener('popstate', () => $Router.popstate());
|
||||||
|
window.addEventListener('scroll', () => { this.setScrollHistory(this.historyIndex, location.href, document.documentElement.scrollTop) })
|
||||||
|
history.scrollRestoration = 'manual';
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static open(url: string | URL | undefined) {
|
||||||
|
if (url === undefined) return this;
|
||||||
|
url = new URL(url);
|
||||||
|
if (url.href === this.url.href) return this;
|
||||||
|
this.historyIndex++;
|
||||||
|
history.pushState($Router.historyState, '', url);
|
||||||
|
this.stateChange($RouterNavigationDirection.Forward);
|
||||||
|
$Router.resolve();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static back() {
|
||||||
|
this.historyIndex--;
|
||||||
|
history.back()
|
||||||
|
this.stateChange($RouterNavigationDirection.Back);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static replace(url: string | URL | undefined) {
|
||||||
|
if (url === undefined) return this;
|
||||||
|
if (typeof url === 'string' && !url.startsWith(location.origin)) url = location.origin + url;
|
||||||
|
history.replaceState($Router.historyState, '', url);
|
||||||
|
this.stateChange($RouterNavigationDirection.Replace);
|
||||||
|
$Router.resolve();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static popstate() {
|
||||||
|
const direction: $RouterNavigationDirection
|
||||||
|
= history.state.index > $Router.historyIndex
|
||||||
|
? $RouterNavigationDirection.Forward
|
||||||
|
: history.state.index < $Router.historyIndex
|
||||||
|
? $RouterNavigationDirection.Back
|
||||||
|
: $RouterNavigationDirection.Replace
|
||||||
|
|
||||||
|
$Router.historyIndex = history.state.index;
|
||||||
|
$Router.stateChange(direction);
|
||||||
|
$Router.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static async resolve() {
|
||||||
|
await Promise.all([...$Router.routers.values()].map($router => $router.resolve()));
|
||||||
|
this.scrollRestoration();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static get historyState() { return { index: $Router.historyIndex, } }
|
||||||
|
|
||||||
|
protected static stateChange(direction: $RouterNavigationDirection) {
|
||||||
|
const beforeURL = this.url;
|
||||||
|
const afterURL = new URL(location.href);
|
||||||
|
this.url = afterURL;
|
||||||
|
$Router.events.fire('stateChange', {beforeURL, afterURL, direction})
|
||||||
|
$Router.navigationDirection = direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static setScrollHistory(index: number, url: string, value: number) {
|
||||||
|
const record = this.getScrollHistory()
|
||||||
|
if (!record) return sessionStorage.setItem(this.scrollHistoryKey, JSON.stringify({[index]: {url, value}}));
|
||||||
|
record[index] = {url, value};
|
||||||
|
sessionStorage.setItem(this.scrollHistoryKey, JSON.stringify(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static getScrollHistory() {
|
||||||
|
const data = sessionStorage.getItem(this.scrollHistoryKey);
|
||||||
|
if (!data) return undefined;
|
||||||
|
else return JSON.parse(data) as $RouterScrollHistoryData;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static scrollRestoration() {
|
||||||
|
const record = this.getScrollHistory();
|
||||||
|
if (!record) return;
|
||||||
|
document.documentElement.scrollTop = record[this.historyIndex]?.value ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum $RouterResolveResult { OK, NotFound, NotMatchBase }
|
||||||
|
export enum $RouterNavigationDirection { Forward, Back, Replace }
|
||||||
|
interface $RouterState { index: number }
|
||||||
|
export interface $RouterEventMap {
|
||||||
|
'stateChange': [{beforeURL: URL, afterURL: URL, direction: $RouterNavigationDirection}]
|
||||||
|
}
|
||||||
|
interface $RouterScrollHistoryData {[index: number]: {url: string, value: number}}
|
||||||
|
|
||||||
|
$Router.init();
|
48
lib/Route.ts
48
lib/Route.ts
@ -1,48 +0,0 @@
|
|||||||
import { $EventMethod, $Node, $EventManager } from "elexis";
|
|
||||||
import { $Util } from "elexis/lib/$Util";
|
|
||||||
|
|
||||||
export class Route<Path extends string | PathResolverFn> {
|
|
||||||
path: string | PathResolverFn;
|
|
||||||
builder: (req: RouteRequest<Path>) => RouteContent;
|
|
||||||
constructor(path: Path, builder: ((req: RouteRequest<Path>) => RouteContent) | RouteContent) {
|
|
||||||
this.path = path;
|
|
||||||
this.builder = builder instanceof Function ? builder : (req: RouteRequest<Path>) => builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PathParams<Path> = Path extends `${infer Segment}/${infer Rest}`
|
|
||||||
? Segment extends `${string}:${infer Param}` ? Record<Param, string> & PathParams<Rest> : PathParams<Rest>
|
|
||||||
: Path extends `${string}:${infer Param}` ? Record<Param,string> : {}
|
|
||||||
|
|
||||||
export type PathResolverFn = (path: string) => undefined | string;
|
|
||||||
|
|
||||||
type PathParamResolver<P extends PathResolverFn | string> = P extends PathResolverFn
|
|
||||||
? undefined : PathParams<P>
|
|
||||||
|
|
||||||
// type PathResolverRecord<P extends PathResolverFn> = {
|
|
||||||
// [key in keyof ReturnType<P>]: ReturnType<P>[key]
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
export interface RouteRecord extends $EventMethod<RouteRecordEventMap> {};
|
|
||||||
export class RouteRecord {
|
|
||||||
id: string;
|
|
||||||
readonly content?: $Node;
|
|
||||||
events = new $EventManager<RouteRecordEventMap>().register('open', 'load')
|
|
||||||
constructor(id: string) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$Util.mixin(RouteRecord, $EventMethod)
|
|
||||||
export interface RouteRecordEventMap {
|
|
||||||
'open': [{path: string, record: RouteRecord}];
|
|
||||||
'load': [{path: string, record: RouteRecord}];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RouteRequest<Path extends PathResolverFn | string> {
|
|
||||||
params: PathParamResolver<Path>,
|
|
||||||
record: RouteRecord,
|
|
||||||
loaded: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RouteContent = $Node | string | void;
|
|
274
lib/Router.ts
274
lib/Router.ts
@ -1,274 +0,0 @@
|
|||||||
import { $EventMethod, $View, $EventManager, $Text } from "elexis";
|
|
||||||
import { $Util } from "elexis/lib/$Util";
|
|
||||||
import { PathResolverFn, Route, RouteRecord } from "./Route";
|
|
||||||
export interface Router extends $EventMethod<RouterEventMap> {};
|
|
||||||
export class Router {
|
|
||||||
static routers = new Set<Router>();
|
|
||||||
static index: number = 0;
|
|
||||||
static events = new $EventManager<RouterGlobalEventMap>().register('pathchange', 'notfound', 'load');
|
|
||||||
static currentPath: URL = new URL(location.href);
|
|
||||||
static navigationDirection: NavigationDirection;
|
|
||||||
static readonly SCROLL_HISTORY_KEY = '$router_scroll_history';
|
|
||||||
static preventSetScrollHistory = false;
|
|
||||||
routeMap = new Map<string | PathResolverFn, Route<any>>();
|
|
||||||
recordMap = new Map<string, RouteRecord>();
|
|
||||||
$view: $View;
|
|
||||||
events = new $EventManager<RouterEventMap>().register('notfound', 'load');
|
|
||||||
basePath: string;
|
|
||||||
constructor(basePath: string, view?: $View) {
|
|
||||||
this.basePath = basePath;
|
|
||||||
this.$view = view ?? new $View();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**Add route to Router. @example Router.addRoute(new Route('/', 'Hello World')) */
|
|
||||||
addRoute(routes: OrArray<Route<any>>) {
|
|
||||||
routes = $.orArrayResolve(routes);
|
|
||||||
for (const route of routes) this.routeMap.set(route.path, route);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**Start listen to the path change */
|
|
||||||
listen() {
|
|
||||||
if (!history.state || 'index' in history.state === false) {
|
|
||||||
const routeData: RouteData = {index: Router.index, data: {}}
|
|
||||||
history.replaceState(routeData, '')
|
|
||||||
} else {
|
|
||||||
Router.index = history.state.index
|
|
||||||
}
|
|
||||||
Router.routers.add(this);
|
|
||||||
Router.navigationDirection = NavigationDirection.Forward;
|
|
||||||
this.resolvePath();
|
|
||||||
Router.events.fire('pathchange', {prevURL: undefined, nextURL: Router.currentPath, navigation: NavigationDirection.Forward});
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**Open URL */
|
|
||||||
static open(url: string | URL | undefined) {
|
|
||||||
if (url === undefined) return this;
|
|
||||||
url = new URL(url);
|
|
||||||
if (url.origin !== location.origin) return this;
|
|
||||||
if (url.href === location.href) return this;
|
|
||||||
const prevPath = Router.currentPath;
|
|
||||||
this.index += 1;
|
|
||||||
const routeData: RouteData = { index: this.index, data: {} };
|
|
||||||
history.pushState(routeData, '', url);
|
|
||||||
Router.navigationDirection = NavigationDirection.Forward;
|
|
||||||
Router.currentPath = new URL(location.href);
|
|
||||||
Router.routers.forEach(router => router.resolvePath())
|
|
||||||
Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: NavigationDirection.Forward});
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**Back to previous page */
|
|
||||||
static back() {
|
|
||||||
const prevPath = Router.currentPath;
|
|
||||||
history.back();
|
|
||||||
Router.currentPath = new URL(location.href);
|
|
||||||
Router.navigationDirection = NavigationDirection.Back;
|
|
||||||
Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: NavigationDirection.Back});
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
static replace(url: string | URL | undefined) {
|
|
||||||
if (url === undefined) return this;
|
|
||||||
if (typeof url === 'string' && !url.startsWith(location.origin)) url = location.origin + url;
|
|
||||||
url = new URL(url);
|
|
||||||
if (url.origin !== location.origin) return this;
|
|
||||||
if (url.href === location.href) return this;
|
|
||||||
const prevPath = Router.currentPath;
|
|
||||||
history.replaceState({index: Router.index}, '', url)
|
|
||||||
Router.currentPath = new URL(location.href);
|
|
||||||
Router.navigationDirection = NavigationDirection.Forward;
|
|
||||||
Router.routers.forEach(router => router.resolvePath(url.pathname));
|
|
||||||
Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: NavigationDirection.Forward});
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStateData(key: string, value: any) {
|
|
||||||
if (history.state.data === undefined) history.state.data = {};
|
|
||||||
history.state.data[key] = value;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
static popstate = (() => {
|
|
||||||
let dir: NavigationDirection = NavigationDirection.Forward;
|
|
||||||
// Forward
|
|
||||||
if (history.state.index > Router.index) dir = NavigationDirection.Forward;
|
|
||||||
// Back
|
|
||||||
else if (history.state.index < Router.index) dir = NavigationDirection.Back;
|
|
||||||
const prevPath = Router.currentPath;
|
|
||||||
Router.index = history.state.index;
|
|
||||||
Router.navigationDirection = dir;
|
|
||||||
Router.routers.forEach(router => router.resolvePath());
|
|
||||||
Router.currentPath = new URL(location.href);
|
|
||||||
Router.events.fire('pathchange', {prevURL: prevPath, nextURL: Router.currentPath, navigation: dir});
|
|
||||||
})
|
|
||||||
|
|
||||||
private resolvePath(path = location.pathname) {
|
|
||||||
if (!path.startsWith(this.basePath)) return;
|
|
||||||
path = path.replace(this.basePath, '/').replace('//', '/');
|
|
||||||
let found = false;
|
|
||||||
const openCachedView = (pathId: string) => {
|
|
||||||
const record = this.recordMap.get(pathId);
|
|
||||||
if (record) {
|
|
||||||
found = true;
|
|
||||||
if (record.content && !this.$view.contains(record.content)) {
|
|
||||||
Router.preventSetScrollHistory = true;
|
|
||||||
this.$view.events.once('afterSwitch', () => {
|
|
||||||
Router.preventSetScrollHistory = false;
|
|
||||||
Router.recoveryScrollPosition()
|
|
||||||
});
|
|
||||||
this.$view.switchView(pathId);
|
|
||||||
}
|
|
||||||
record.events.fire('open', {path, record});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const createView = (pathId: string, route: Route<any>, data: any) => {
|
|
||||||
const record = new RouteRecord(pathId);
|
|
||||||
let content = route.builder({
|
|
||||||
params: data,
|
|
||||||
record: record,
|
|
||||||
loaded: () => {
|
|
||||||
record.events.fire('load', {path: pathId, record});
|
|
||||||
this.events.fire('load', {path: pathId});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (typeof content === 'string') return new $Text(content);
|
|
||||||
if (content === undefined) return;
|
|
||||||
(record as Mutable<RouteRecord>).content = content;
|
|
||||||
this.recordMap.set(pathId, record);
|
|
||||||
// switch view
|
|
||||||
Router.preventSetScrollHistory = true;
|
|
||||||
this.$view.events.once('afterSwitch', () => {
|
|
||||||
Router.preventSetScrollHistory = false;
|
|
||||||
Router.recoveryScrollPosition()
|
|
||||||
});
|
|
||||||
this.$view.setView(pathId, content).switchView(pathId);
|
|
||||||
//
|
|
||||||
record.events.fire('open', {path, record});
|
|
||||||
found = true;
|
|
||||||
}
|
|
||||||
for (const [pathResolver, route] of this.routeMap.entries()) {
|
|
||||||
// PathResolverFn
|
|
||||||
if (pathResolver instanceof Function) {
|
|
||||||
const routeId = pathResolver(path)
|
|
||||||
if (routeId) { if (!openCachedView(routeId)) createView(routeId, route, undefined) }
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// string
|
|
||||||
const [_routeParts, _pathParts] = [pathResolver.split('/').map(p => `/${p}`), path.split('/').map(p => `/${p}`)];
|
|
||||||
_routeParts.shift(); _pathParts.shift();
|
|
||||||
const data = {};
|
|
||||||
let pathString = '';
|
|
||||||
for (let i = 0; i < _pathParts.length; i++) {
|
|
||||||
const [routePart, pathPart] = [_routeParts.at(i), _pathParts.at(i)];
|
|
||||||
if (!routePart || !pathPart) continue;
|
|
||||||
if (routePart === pathPart) {
|
|
||||||
pathString += pathPart;
|
|
||||||
if (routePart === _routeParts.at(-1)) {
|
|
||||||
if (!openCachedView(pathString)) createView(pathString, route, data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (routePart.includes(':')) {
|
|
||||||
const [prefix, param] = routePart.split(':');
|
|
||||||
if (!pathPart.startsWith(prefix)) continue;
|
|
||||||
Object.assign(data, {[param]: pathPart.replace(prefix, '')})
|
|
||||||
pathString += pathPart;
|
|
||||||
if (routePart === _routeParts.at(-1)) {
|
|
||||||
if (!openCachedView(pathString)) createView(pathString, route, data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!found) {
|
|
||||||
let preventDefaultState = false;
|
|
||||||
const preventDefault = () => preventDefaultState = true;
|
|
||||||
this.events.fire('notfound', {path, preventDefault});
|
|
||||||
if (!preventDefaultState) this.$view.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static recoveryScrollPosition() {
|
|
||||||
const history = this.getScrollHistory(this.index, location.href);
|
|
||||||
if (!history) {
|
|
||||||
document.documentElement.scrollTop = 0;
|
|
||||||
this.setScrollHistory(this.index, location.href, 0);
|
|
||||||
}
|
|
||||||
else document.documentElement.scrollTop = history.scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getScrollHistory(pageIndex: number, href: string) {
|
|
||||||
const data = this.scrollHistoryData;
|
|
||||||
if (!data || !data[pageIndex]) return null;
|
|
||||||
return data[pageIndex].href === href ? data[pageIndex] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static setScrollHistory(pageIndex: number, href: string, scroll: number) {
|
|
||||||
if (Router.preventSetScrollHistory) return;
|
|
||||||
let history = this.scrollHistoryData;
|
|
||||||
if (!history) {
|
|
||||||
history = {[pageIndex]: {href, scroll}};
|
|
||||||
sessionStorage.setItem(this.SCROLL_HISTORY_KEY, JSON.stringify(history));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const data = history[pageIndex];
|
|
||||||
if (data && data.href !== href) {
|
|
||||||
let i = 0;
|
|
||||||
while (history[pageIndex + i]) {
|
|
||||||
delete history[pageIndex + i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
history[pageIndex] = {href, scroll}
|
|
||||||
sessionStorage.setItem(this.SCROLL_HISTORY_KEY, JSON.stringify(history));
|
|
||||||
}
|
|
||||||
return history[pageIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
static get scrollHistoryData() {
|
|
||||||
const json = sessionStorage.getItem(this.SCROLL_HISTORY_KEY)
|
|
||||||
if (!json) return null;
|
|
||||||
return JSON.parse(json) as {[key: number]: RouteScrollHistoryData};
|
|
||||||
}
|
|
||||||
|
|
||||||
static on<K extends keyof RouterGlobalEventMap>(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.on(type, callback); return this }
|
|
||||||
static off<K extends keyof RouterGlobalEventMap>(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.off(type, callback); return this }
|
|
||||||
static once<K extends keyof RouterGlobalEventMap>(type: K, callback: (...args: RouterGlobalEventMap[K]) => any) { this.events.once(type, callback); return this }
|
|
||||||
}
|
|
||||||
|
|
||||||
$Util.mixin(Router, $EventMethod);
|
|
||||||
|
|
||||||
interface RouterEventMap {
|
|
||||||
notfound: [{path: string, preventDefault: () => any}];
|
|
||||||
load: [{path: string}];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RouterGlobalEventMap {
|
|
||||||
pathchange: [{prevURL?: URL, nextURL: URL, navigation: NavigationDirection}];
|
|
||||||
}
|
|
||||||
|
|
||||||
type RouteData = {
|
|
||||||
index: number;
|
|
||||||
data: {[key: string]: any};
|
|
||||||
}
|
|
||||||
|
|
||||||
type RouteScrollHistoryData = {
|
|
||||||
href: string;
|
|
||||||
scroll: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum NavigationDirection {
|
|
||||||
Forward,
|
|
||||||
Back
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('scroll', (e) => {
|
|
||||||
console.debug(document.documentElement.scrollTop)
|
|
||||||
Router.setScrollHistory(Router.index, location.href, document.documentElement.scrollTop);
|
|
||||||
})
|
|
||||||
history.scrollRestoration = 'manual';
|
|
@ -20,6 +20,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"elexis": "^0.1.0"
|
"@elexis/view": "../view",
|
||||||
|
"elexis": "../../elexis"
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user