@v4fire/client
Version:
V4Fire client core library
915 lines (772 loc) • 23.2 kB
text/typescript
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
/**
* [[include:base/b-router/README.md]]
* @packageDocumentation
*/
import symbolGenerator from 'core/symbol';
import { deprecated } from 'core/functools/deprecation';
import globalRoutes from 'routes';
import type Async from 'core/async';
import iData, { component, prop, system, computed, hook, wait, watch, UnsafeGetter } from 'super/i-data/i-data';
import engine, * as router from 'core/router';
import { fillRouteParams } from 'base/b-router/modules/normalizers';
import { urlsToIgnore } from 'base/b-router/modules/const';
import { HrefTransitionEvent } from 'base/b-router/modules/transition/event';
import type { StaticRoutes, RouteOption, TransitionMethod, UnsafeBRouter } from 'base/b-router/interface';
export * from 'super/i-data/i-data';
export * from 'core/router/const';
export * from 'base/b-router/interface';
export * from 'base/b-router/modules/transition/event';
export const
$$ = symbolGenerator();
/**
* Component to route application pages
*/
export default class bRouter extends iData {
/**
* Type: page parameters
*/
readonly PageParams!: RouteOption;
/**
* Type: page query
*/
readonly PageQuery!: RouteOption;
/**
* Type: page meta
*/
readonly PageMeta!: RouteOption;
public override async!: Async<this>;
/**
* The static schema of application routes.
* By default, this value is taken from `routes/index.ts`.
*
* @example
* ```
* < b-router :routes = { &
* main: {
* path: '/'
* },
*
* notFound: {
* default: true
* }
* } .
* ```
*/
<bRouter>({
type: Object,
required: false,
watch: (ctx, val, old) => {
if (!Object.fastCompare(val, old)) {
ctx.updateCurrentRoute();
}
}
})
readonly routesProp?: StaticRoutes;
/**
* Compiled schema of application routes
* @see [[bRouter.routesProp]]
*/
<bRouter>({
after: 'engine',
init: (o) => o.sync.link(o.compileStaticRoutes)
})
routes!: router.RouteBlueprints;
/**
* An initial route value.
* Usually, you don't need to provide this value manually,
* because it is inferring automatically, but sometimes it can be useful.
*
* @example
* ```
* < b-router :initialRoute = 'main' | :routes = { &
* main: {
* path: '/'
* },
*
* notFound: {
* default: true
* }
* } .
* ```
*/
<bRouter>({
type: [String, Object],
required: false,
watch: 'updateCurrentRoute'
})
readonly initialRoute?: router.InitialRoute;
/**
* Base route path: all route paths are concatenated with this path
*
* @example
* ```
* < b-router :basePath = '/demo' | :routes = { &
* user: {
* /// '/demo/user'
* path: '/user'
* }
* } .
* ```
*/
readonly basePathProp: string = '/';
/** @see [[bRouter.basePathProp]] */
<bRouter>((o) => o.sync.link())
basePath!: string;
/**
* If true, the router will intercept all click events on elements with a `href` attribute to emit a transition.
* An element with `href` can have additional attributes:
*
* * `data-router-method` - type of the used router method to emit the transition;
* * `data-router-go` - value for the router `go` method;
* * `data-router-params`, `data-router-query`, `data-router-meta` - additional parameters for the used router method
* (to provide an object use JSON).
*/
readonly interceptLinks: boolean = true;
/**
* A factory to create router engine.
* By default, this value is taken from `core/router/engines`.
*
* @example
* ```
* < b-router :engine = myCustomEngine
* ```
*/
<bRouter>({
type: Function,
watch: 'updateCurrentRoute',
default: engine
})
readonly engineProp!: () => router.Router;
/**
* An internal router engine.
* For example, it can be the HTML5 history router or a router based on URL hash values.
*
* @see [[bRouter.engine]]
*/
protected engine!: router.Router;
/**
* Raw value of the active route
*/
protected routeStore?: router.Route;
override get unsafe(): UnsafeGetter<UnsafeBRouter<this>> {
return Object.cast(this);
}
/**
* Value of the active route
* @see [[bRouter.routeStore]]
*
* @example
* ```js
* console.log(route?.query)
* ```
*/
override get route(): CanUndef<this['r']['CurrentPage']> {
return this.field.get('routeStore');
}
/**
* @deprecated
* @see [[bRouter.route]]
*/
get page(): CanUndef<this['r']['CurrentPage']> {
return this.route;
}
/**
* Default route value
*
* @example
* ```
* < b-router :initialRoute = 'main' | :routes = { &
* main: {
* path: '/'
* },
*
* notFound: {
* default: true
* }
* } .
* ```
*
* ```js
* router.defaultRoute.name === 'notFound'
* ```
*/
get defaultRoute(): CanUndef<router.RouteBlueprint> {
let route;
for (let keys = Object.keys(this.routes), i = 0; i < keys.length; i++) {
const
el = this.routes[keys[i]];
if (el?.meta.default) {
route = el;
break;
}
}
return route;
}
/**
* Pushes a new route to the history stack.
* The method returns a promise that is resolved when the transition will be completed.
*
* @param route - route name or URL
* @param [opts] - additional options
*
* @example
* ```js
* router.push('main', {query: {foo: 1}});
* router.push('/user/:id', {params: {id: 1}});
* router.push('https://google.com');
* ```
*/
async push(route: Nullable<string>, opts?: router.TransitionOptions): Promise<void> {
await this.emitTransition(route, opts, 'push');
}
/**
* Replaces the current route.
* The method returns a promise that will be resolved when the transition is completed.
*
* @param route - route name or URL
* @param [opts] - additional options
*
* @example
* ```js
* router.replace('main', {query: {foo: 1}});
* router.replace('/user/:id', {params: {id: 1}});
* router.replace('https://google.com');
* ```
*/
async replace(route: Nullable<string>, opts?: router.TransitionOptions): Promise<void> {
await this.emitTransition(route, opts, 'replace');
}
/**
* Switches to a route from the history,
* identified by its relative position to the current route (with the current route being relative index 0).
* The method returns a promise that will be resolved when the transition is completed.
*
* @param pos
*
* @example
* ````js
* this.go(-1) // this.back();
* this.go(1) // this.forward();
* this.go(-2) // this.back(); this.back();
* ```
*/
async go(pos: number): Promise<void> {
const res = this.promisifyOnce('transition');
this.engine.go(pos);
await res;
}
/**
* Switches to the next route from the history.
* The method returns a promise that will be resolved when the transition is completed.
*/
async forward(): Promise<void> {
const res = this.promisifyOnce('transition');
this.engine.forward();
await res;
}
/**
* Switches to the previous route from the history.
* The method returns a promise that will be resolved when the transition is completed.
*/
async back(): Promise<void> {
const res = this.promisifyOnce('transition');
this.engine.back();
await res;
}
/**
* Clears the routes' history.
* Mind, this method can't work properly with `HistoryAPI` based engines.
*
* @param [filter] - filter predicate
*/
clear(filter?: router.HistoryClearFilter): Promise<void> {
return this.engine.clear(filter);
}
/**
* Clears all temporary routes from the history.
* The temporary route is a route that has `tmp` flag within its own properties, like, `params`, `query` or `meta`.
* Mind, this method can't work properly with `HistoryAPI` based engines.
*
* @example
* ```js
* this.push('redeem-money', {
* meta: {
* tmp: true
* }
* });
*
* this.clearTmp();
* ```
*/
clearTmp(): Promise<void> {
return this.engine.clearTmp();
}
/** @see [[router.getRoutePath]] */
getRoutePath(ref: string, opts: router.TransitionOptions = {}): CanUndef<string> {
return router.getRoutePath(ref, this.routes, opts);
}
/** @see [[router.getRoute]] */
getRoute(ref: string): CanUndef<router.RouteAPI> {
const {routes, basePath, defaultRoute} = this;
return router.getRoute(ref, routes, {basePath, defaultRoute});
}
/**
* @deprecated
* @see [[bRouter.getRoute]]
*/
getPageOpts(ref: string): CanUndef<router.RouteBlueprint> {
return this.getRoute(ref);
}
/**
* Emits a new transition to the specified route
*
* @param ref - route name or URL or `null`, if the route is equal to the previous
* @param [opts] - additional transition options
* @param [method] - transition method
*
* @emits `beforeChange(route: Nullable<string>, params:` [[TransitionOptions]]`, method:` [[TransitionMethod]]`)`
*
* @emits `change(route:` [[Route]]`)`
* @emits `hardChange(route:` [[Route]]`)`
* @emits `softChange(route:` [[Route]]`)`
*
* @emits `transition(route:` [[Route]]`, type:` [[TransitionType]]`)`
* @emits `$root.transition(route:` [[Route]]`, type:` [[TransitionType]]`)`
*/
async emitTransition(
ref: Nullable<string>,
opts?: router.TransitionOptions,
method: TransitionMethod = 'push'
): Promise<CanUndef<router.Route>> {
opts = router.getBlankRouteFrom(router.normalizeTransitionOpts(opts));
const
{r, engine} = this,
originRef = ref;
const
currentEngineRoute = engine.route ?? engine.page;
this.emit('beforeChange', ref, opts, method);
let
newRouteInfo: CanUndef<router.RouteAPI>;
const getEngineRoute = () => currentEngineRoute ?
currentEngineRoute.url ?? router.getRouteName(currentEngineRoute) :
undefined;
// Get information about the specified route
if (ref != null) {
newRouteInfo = this.getRoute(engine.id(ref));
// In this case, we don't have the specified ref to a transition,
// so we try to get information from the current route and use it as a blueprint to the new
} else if (currentEngineRoute) {
ref = getEngineRoute()!;
const
route = this.getRoute(ref);
if (route) {
newRouteInfo = Object.mixin(true, route, router.purifyRoute(currentEngineRoute));
}
}
const scroll = {
meta: {
scroll: {
x: pageXOffset,
y: pageYOffset
}
}
};
// To save scroll position before change to a new route
// we need to emit system "replace" transition with padding information about the scroll
if (currentEngineRoute && method !== 'replace') {
const
currentRouteWithScroll = Object.mixin(true, undefined, currentEngineRoute, scroll);
if (!Object.fastCompare(currentEngineRoute, currentRouteWithScroll)) {
await engine.replace(getEngineRoute()!, currentRouteWithScroll);
}
}
// We haven't found any routes that match to the specified ref
if (newRouteInfo == null) {
// The transition was emitted by a user, then we need to save the scroll
if (method !== 'event' && ref != null) {
await engine[method](ref, scroll);
}
return;
}
if ((<router.PurifiedRoute<router.RouteAPI>>newRouteInfo).name == null) {
const
nm = router.getRouteName(currentEngineRoute);
if (nm != null) {
newRouteInfo.name = nm;
}
}
const
currentRoute = this.field.get<router.Route>('routeStore');
const deepMixin = (...args) => Object.mixin(
{
deep: true,
skipUndefs: false,
extendFilter: (el) => !Object.isArray(el)
},
...args
);
// If the target ref is null it means we're navigating to the current route,
// so we need to mix the new state with the current state
if (originRef == null) {
deepMixin(newRouteInfo, router.getBlankRouteFrom(currentRoute), opts);
// Simple normalizing of a route state
} else {
deepMixin(newRouteInfo, opts);
}
const {meta} = newRouteInfo;
// If a route support filling from the root object or query parameters
fillRouteParams(newRouteInfo, this);
// We have two variants of transitions:
// "soft" - between routes were changed only query or meta parameters
// "hard" - first and second routes aren't equal by a name
// Mutations of query and meta parameters of a route shouldn't force re-render of components,
// that why we placed it to a prototype object by using `Object.create`
const nonWatchRouteValues = {
url: newRouteInfo.resolvePath(newRouteInfo.params),
query: newRouteInfo.query,
meta
};
const newRoute = Object.assign(
Object.create(nonWatchRouteValues),
Object.reject(router.convertRouteToPlainObject(newRouteInfo), Object.keys(nonWatchRouteValues))
);
let
hardChange = false;
// Emits the route transition event
const emitTransition = (onlyOwnTransition?: boolean) => {
const type = hardChange ? 'hard' : 'soft';
if (onlyOwnTransition) {
this.emit('transition', newRoute, type);
} else {
this.emit('change', newRoute);
this.emit('transition', newRoute, type);
r.emit('transition', newRoute, type);
}
};
// Checking that a new route is really needed, i.e., it isn't equal to the previous
let newRouteIsReallyNeeded = !Object.fastCompare(
router.getComparableRouteParams(currentRoute),
router.getComparableRouteParams(newRoute)
);
// Nothing changes between routes, but there are provided some meta object
if (!newRouteIsReallyNeeded && currentRoute != null && opts.meta != null) {
newRouteIsReallyNeeded = !Object.fastCompare(
Object.select(currentRoute.meta, opts.meta),
opts.meta
);
}
// The transition is necessary, but now we need to understand should we emit a "soft" or "hard" transition
if (newRouteIsReallyNeeded) {
this.field.set('routeStore', newRoute);
const
plainInfo = router.convertRouteToPlainObject(newRouteInfo);
const canRouteTransformToReplace =
currentRoute &&
method !== 'replace' &&
Object.fastCompare(router.convertRouteToPlainObject(currentRoute), plainInfo);
if (canRouteTransformToReplace) {
method = 'replace';
}
// If the used engine does not support the requested transition method,
// we should use `replace`
if (!Object.isFunction(engine[method])) {
method = 'replace';
}
// This transition is marked as `external`,
// i.e. it refers to another site
if (newRouteInfo.meta.external) {
const u = newRoute.url;
location.href = u !== '' ? u : '/';
return;
}
await engine[method](newRoute.url, plainInfo);
const isSoftTransition = Boolean(r.route && Object.fastCompare(
router.convertRouteToPlainObjectWithoutProto(currentRoute),
router.convertRouteToPlainObjectWithoutProto(newRoute)
));
// In this transition were changed only properties from a prototype,
// that why it can be emitted as a soft transition, i.e. without forcing of the re-rendering of components
if (isSoftTransition) {
this.emit('softChange', newRoute);
// We get a prototype by using `__proto__` link,
// because `Object.getPrototypeOf` returns a non-watchable object.
// This behavior is based on a strategy that every touch to an object property of the watched object
// will create a child watch object.
const
proto = r.route?.__proto__;
if (Object.isDictionary(proto)) {
// Correct values from the root route object
for (let keys = Object.keys(nonWatchRouteValues), i = 0; i < keys.length; i++) {
const key = keys[i];
proto[key] = nonWatchRouteValues[key];
}
}
} else {
hardChange = true;
this.emit('hardChange', newRoute);
r.route = newRoute;
}
emitTransition();
// This route is equal to the previous, and we don't actually do transition,
// but for a "push" request we need to emit a "fake" transition event anyway
} else if (method === 'push') {
emitTransition();
// In this case, we don't do transition, but still,
// we should emit the special event, because some methods, like, `back` or `forward` can wait for it
} else {
emitTransition(true);
}
// Restoring the scroll position
if (meta.autoScroll !== false) {
(async () => {
const label = {
label: $$.autoScroll
};
const setScroll = () => {
const
s = meta.scroll;
if (s != null) {
this.r.scrollTo(s.x, s.y);
} else if (hardChange) {
this.r.scrollTo(0, 0);
}
};
// Restoring of scroll for static height components
await this.nextTick(label);
setScroll();
// Restoring of scroll for dynamic height components
await this.async.sleep(10, label);
setScroll();
})().catch(stderr);
}
return newRoute;
}
/**
* @deprecated
* @see [[bRouter.emitTransition]]
*/
setPage(
ref: Nullable<string>,
opts?: router.TransitionOptions,
method?: TransitionMethod
): Promise<CanUndef<router.Route>> {
return this.emitTransition(ref, opts, method);
}
/**
* Updates the schema of routes
*
* @param basePath - base route path
* @param [routes] - static schema of application routes
* @param [activeRoute]
*/
updateRoutes(
basePath: string,
routes?: StaticRoutes,
activeRoute?: Nullable<router.InitialRoute>
): Promise<router.RouteBlueprints>;
/**
* Updates the schema of routes
*
* @param basePath - base route path
* @param activeRoute
* @param [routes] - static schema of application routes
*/
updateRoutes(
basePath: string,
activeRoute: router.InitialRoute,
routes?: StaticRoutes
): Promise<router.RouteBlueprints>;
/**
* Updates the schema of routes
*
* @param routes - static schema of application routes
* @param [activeRoute]
*/
updateRoutes(
routes: StaticRoutes,
activeRoute?: Nullable<router.InitialRoute>
): Promise<router.RouteBlueprints>;
/**
* @param basePathOrRoutes
* @param [routesOrActiveRoute]
* @param [activeRouteOrRoutes]
*/
async updateRoutes(
basePathOrRoutes: string | StaticRoutes,
routesOrActiveRoute?: StaticRoutes | Nullable<router.InitialRoute>,
activeRouteOrRoutes?: Nullable<router.InitialRoute> | StaticRoutes
): Promise<router.RouteBlueprints> {
let
basePath,
routes,
activeRoute;
if (Object.isString(basePathOrRoutes)) {
basePath = basePathOrRoutes;
if (Object.isString(routesOrActiveRoute)) {
routes = <StaticRoutes>activeRouteOrRoutes;
activeRoute = routesOrActiveRoute;
} else {
routes = routesOrActiveRoute;
activeRoute = <Nullable<router.InitialRoute>>activeRouteOrRoutes;
}
} else {
routes = basePathOrRoutes;
activeRoute = <Nullable<router.InitialRoute>>routesOrActiveRoute;
}
if (basePath != null) {
this.basePath = basePath;
}
if (routes != null) {
this.routes = this.compileStaticRoutes(routes);
}
this.routeStore = undefined;
await this.initRoute(activeRoute ?? this.initialRoute ?? this.defaultRoute);
return this.routes;
}
protected override initRemoteData(): CanUndef<CanPromise<router.RouteBlueprints | Dictionary>> {
if (!this.db) {
return;
}
const
val = this.convertDBToComponent<StaticRoutes>(this.db);
if (Object.isDictionary(val)) {
return Promise.all(this.state.set(val)).then(() => val);
}
if (Object.isArray(val)) {
// eslint-disable-next-line prefer-spread
return this.updateRoutes.apply(this, val);
}
return this.routes;
}
/**
* Initializes the router within an application
* @emits `$root.initRouter(router:` [[bRouter]]`)`
*/
protected init(): void {
this.field.set('routerStore', this, this.$root);
this.r.emit('initRouter', this);
}
/**
* Initializes the specified route
* @param [route] - route
*/
protected initRoute(route: Nullable<router.InitialRoute> = this.initialRoute): Promise<void> {
if (route != null) {
if (Object.isString(route)) {
return this.replace(route);
}
return this.replace(router.getRouteName(route), Object.reject(route, router.routeNames));
}
return this.replace(null);
}
/**
* Updates the current route value
*/
protected updateCurrentRoute(): Promise<void> {
return this.initRoute();
}
/**
* Compiles the specified static routes with the current base path and returns a new object
* @param [routes]
*/
protected compileStaticRoutes(routes: StaticRoutes = this.engine.routes ?? globalRoutes): router.RouteBlueprints {
return router.compileStaticRoutes(routes, {basePath: this.basePath});
}
protected override initBaseAPI(): void {
super.initBaseAPI();
const
i = this.instance;
this.compileStaticRoutes = i.compileStaticRoutes.bind(this);
this.emitTransition = i.emitTransition.bind(this);
}
/**
* Handler: click on an element with the `href` attribute
* @param e
* @emits `hrefTransition(event:` [[HrefTransitionEvent]]`)` - contains the `HTMLElement` onto which the event
* was dispatched and its `href` attribute value
*/
protected async onLink(e: MouseEvent): Promise<void> {
const
a = <HTMLElement>e.delegateTarget,
href = a.getAttribute('href')?.trim();
const cantIntercept =
!this.interceptLinks ||
href == null ||
href === '' ||
urlsToIgnore.some((scheme) => scheme.test(href)) ||
router.isExternal.test(href);
if (cantIntercept) {
return;
}
const hrefTransitionEvent = new HrefTransitionEvent(a);
this.emit('hrefTransition', hrefTransitionEvent);
if (hrefTransitionEvent.transitionPrevented) {
return;
}
e.preventDefault();
if (hrefTransitionEvent.defaultPrevented || Object.parse(a.getAttribute('data-router-prevent-transition'))) {
return;
}
const
l = Object.assign(document.createElement('a'), {href});
if (a.getAttribute('target') === '_blank' || e.ctrlKey || e.metaKey) {
globalThis.open(l.href, '_blank');
return;
}
const
method = a.getAttribute('data-router-method');
switch (method) {
case 'back':
this.back().catch(stderr);
break;
case 'forward':
this.back().catch(stderr);
break;
case 'go': {
const go = Object.parse(a.getAttribute('data-router-go'));
this.go(Object.isNumber(go) ? go : -1).catch(stderr);
break;
}
default: {
const
params = Object.parse(a.getAttribute('data-router-params')),
query = Object.parse(a.getAttribute('data-router-query')),
meta = Object.parse(a.getAttribute('data-router-meta'));
await this[method === 'replace' ? 'replace' : 'push'](href, {
params: Object.isDictionary(params) ? params : {},
query: Object.isDictionary(query) ? query : {},
meta: Object.isDictionary(meta) ? meta : {}
});
}
}
}
}