1
0
mirror of https://github.com/hotomoe/hotomoe synced 2024-12-15 15:18:08 +09:00
hotomoe/packages/frontend/src/nirax.ts

455 lines
11 KiB
TypeScript
Raw Normal View History

/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
2022-06-21 14:18:06 +09:00
// NIRAX --- A lightweight router
import { Component, onMounted, shallowRef, ShallowRef } from 'vue';
import { EventEmitter } from 'eventemitter3';
2023-09-19 16:37:43 +09:00
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
interface RouteDefBase {
path: string;
query?: Record<string, string>;
loginRequired?: boolean;
name?: string;
hash?: string;
globalCacheKey?: string;
children?: RouteDef[];
}
interface RouteDefWithComponent extends RouteDefBase {
component: Component,
}
interface RouteDefWithRedirect extends RouteDefBase {
redirect: string | ((props: Map<string, string | boolean>) => string);
}
export type RouteDef = RouteDefWithComponent | RouteDefWithRedirect;
type ParsedPath = (string | {
name: string;
startsWith?: string;
wildcard?: boolean;
optional?: boolean;
})[];
export type RouterEvent = {
change: (ctx: {
beforePath: string;
path: string;
resolved: Resolved;
key: string;
}) => void;
replace: (ctx: {
path: string;
key: string;
}) => void;
push: (ctx: {
beforePath: string;
path: string;
route: RouteDef | null;
props: Map<string, string> | null;
key: string;
}) => void;
same: () => void;
}
export type Resolved = {
route: RouteDef;
props: Map<string, string | boolean>;
child?: Resolved;
redirected?: boolean;
/** @internal */
_parsedRoute: {
fullPath: string;
queryString: string | null;
hash: string | null;
};
};
function parsePath(path: string): ParsedPath {
const res = [] as ParsedPath;
path = path.substring(1);
for (const part of path.split('/')) {
if (part.includes(':')) {
const prefix = part.substring(0, part.indexOf(':'));
const placeholder = part.substring(part.indexOf(':') + 1);
const wildcard = placeholder.includes('(*)');
const optional = placeholder.endsWith('?');
res.push({
name: placeholder.replace('(*)', '').replace('?', ''),
startsWith: prefix !== '' ? prefix : undefined,
wildcard,
optional,
});
2022-06-24 01:26:15 +09:00
} else if (part.length !== 0) {
res.push(part);
}
}
return res;
}
export interface IRouter extends EventEmitter<RouterEvent> {
current: Resolved;
currentRef: ShallowRef<Resolved>;
currentRoute: ShallowRef<RouteDef>;
navHook: ((path: string, flag?: any) => boolean) | null;
/**
* eventListenerの定義後に必ず呼び出すこと
*/
init(): void;
resolve(path: string): Resolved | null;
getCurrentPath(): any;
getCurrentKey(): string;
push(path: string, flag?: any): void;
replace(path: string, key?: string | null): void;
/** @see EventEmitter */
eventNames(): Array<EventEmitter.EventNames<RouterEvent>>;
/** @see EventEmitter */
listeners<T extends EventEmitter.EventNames<RouterEvent>>(
event: T
): Array<EventEmitter.EventListener<RouterEvent, T>>;
/** @see EventEmitter */
listenerCount(
event: EventEmitter.EventNames<RouterEvent>
): number;
/** @see EventEmitter */
emit<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
...args: EventEmitter.EventArgs<RouterEvent, T>
): boolean;
/** @see EventEmitter */
on<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn: EventEmitter.EventListener<RouterEvent, T>,
context?: any
): this;
/** @see EventEmitter */
addListener<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn: EventEmitter.EventListener<RouterEvent, T>,
context?: any
): this;
/** @see EventEmitter */
once<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn: EventEmitter.EventListener<RouterEvent, T>,
context?: any
): this;
/** @see EventEmitter */
removeListener<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn?: EventEmitter.EventListener<RouterEvent, T>,
context?: any,
once?: boolean | undefined
): this;
/** @see EventEmitter */
off<T extends EventEmitter.EventNames<RouterEvent>>(
event: T,
fn?: EventEmitter.EventListener<RouterEvent, T>,
context?: any,
once?: boolean | undefined
): this;
/** @see EventEmitter */
removeAllListeners(
event?: EventEmitter.EventNames<RouterEvent>
): this;
}
export class Router extends EventEmitter<RouterEvent> implements IRouter {
private routes: RouteDef[];
public current: Resolved;
public currentRef: ShallowRef<Resolved>;
public currentRoute: ShallowRef<RouteDef>;
private currentPath: string;
private isLoggedIn: boolean;
private notFoundPageComponent: Component;
private currentKey = Date.now().toString();
private redirectCount = 0;
public navHook: ((path: string, flag?: any) => boolean) | null = null;
constructor(routes: Router['routes'], currentPath: Router['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) {
super();
this.routes = routes;
this.current = this.resolve(currentPath)!;
this.currentRef = shallowRef(this.current);
this.currentRoute = shallowRef(this.current.route);
this.currentPath = currentPath;
this.isLoggedIn = isLoggedIn;
this.notFoundPageComponent = notFoundPageComponent;
}
public init() {
const res = this.navigate(this.currentPath, null, false);
this.emit('replace', {
path: res._parsedRoute.fullPath,
key: this.currentKey,
});
}
public resolve(path: string): Resolved | null {
const fullPath = path;
let queryString: string | null = null;
let hash: string | null = null;
if (path[0] === '/') path = path.substring(1);
if (path.includes('#')) {
hash = path.substring(path.indexOf('#') + 1);
path = path.substring(0, path.indexOf('#'));
}
if (path.includes('?')) {
queryString = path.substring(path.indexOf('?') + 1);
path = path.substring(0, path.indexOf('?'));
}
const _parsedRoute = {
fullPath,
queryString,
hash,
};
if (_DEV_) console.log('Routing: ', path, queryString);
function check(routes: RouteDef[], _parts: string[]): Resolved | null {
forEachRouteLoop:
for (const route of routes) {
let parts = [..._parts];
const props = new Map<string, string>();
pathMatchLoop:
for (const p of parsePath(route.path)) {
if (typeof p === 'string') {
if (p === parts[0]) {
parts.shift();
} else {
continue forEachRouteLoop;
}
} else {
if (parts[0] == null && !p.optional) {
continue forEachRouteLoop;
}
if (p.wildcard) {
if (parts.length !== 0) {
props.set(p.name, safeURIDecode(parts.join('/')));
parts = [];
}
break pathMatchLoop;
2022-06-21 14:12:39 +09:00
} else {
if (p.startsWith) {
if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop;
props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length)));
parts.shift();
} else {
if (parts[0]) {
props.set(p.name, safeURIDecode(parts[0]));
}
parts.shift();
2022-07-13 18:28:04 +09:00
}
2022-06-21 14:12:39 +09:00
}
}
}
if (parts.length === 0) {
if (route.children) {
const child = check(route.children, []);
if (child) {
return {
route,
props,
child,
_parsedRoute,
};
} else {
continue forEachRouteLoop;
}
}
if (route.hash != null && hash != null) {
props.set(route.hash, safeURIDecode(hash));
}
if (route.query != null && queryString != null) {
const queryObject = [...new URLSearchParams(queryString).entries()]
.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
for (const q in route.query) {
const as = route.query[q];
if (queryObject[q]) {
props.set(as, safeURIDecode(queryObject[q]));
}
}
}
return {
route,
props,
_parsedRoute,
};
} else {
if (route.children) {
const child = check(route.children, parts);
if (child) {
return {
route,
props,
child,
_parsedRoute,
};
} else {
continue forEachRouteLoop;
}
} else {
continue forEachRouteLoop;
}
}
}
return null;
}
const _parts = path.split('/').filter(part => part.length !== 0);
return check(this.routes, _parts);
}
private navigate(path: string, key: string | null | undefined, emitChange = true, _redirected = false): Resolved {
const beforePath = this.currentPath;
this.currentPath = path;
const res = this.resolve(this.currentPath);
if (res == null) {
throw new Error('no route found for: ' + path);
}
if ('redirect' in res.route) {
let redirectPath: string;
if (typeof res.route.redirect === 'function') {
redirectPath = res.route.redirect(res.props);
} else {
redirectPath = res.route.redirect + (res._parsedRoute.queryString ? '?' + res._parsedRoute.queryString : '') + (res._parsedRoute.hash ? '#' + res._parsedRoute.hash : '');
}
if (_DEV_) console.log('Redirecting to: ', redirectPath);
if (_redirected && this.redirectCount++ > 10) {
throw new Error('redirect loop detected');
}
return this.navigate(redirectPath, null, emitChange, true);
}
if (res.route.loginRequired && !this.isLoggedIn) {
res.route.component = this.notFoundPageComponent;
res.props.set('showLoginPopup', true);
}
const isSamePath = beforePath === path;
if (isSamePath && key == null) key = this.currentKey;
this.current = res;
this.currentRef.value = res;
this.currentRoute.value = res.route;
this.currentKey = res.route.globalCacheKey ?? key ?? path;
if (emitChange) {
this.emit('change', {
beforePath,
path,
resolved: res,
key: this.currentKey,
});
}
this.redirectCount = 0;
return {
...res,
redirected: _redirected,
};
}
public getCurrentPath() {
return this.currentPath;
}
public getCurrentKey() {
return this.currentKey;
}
public push(path: string, flag?: any) {
2022-07-05 22:25:27 +09:00
const beforePath = this.currentPath;
if (path === beforePath) {
this.emit('same');
return;
}
2022-06-28 17:59:23 +09:00
if (this.navHook) {
const cancel = this.navHook(path, flag);
2022-06-28 17:59:23 +09:00
if (cancel) return;
}
const res = this.navigate(path, null);
this.emit('push', {
beforePath,
path: res._parsedRoute.fullPath,
route: res.route,
props: res.props,
key: this.currentKey,
});
}
public replace(path: string, key?: string | null) {
const res = this.navigate(path, key);
this.emit('replace', {
path: res._parsedRoute.fullPath,
key: this.currentKey,
});
}
}
refactor: frontendのcomponentsの型エラーを改善 (#12926) * add: safeFloatParserを追加 * fix: 欠けていた型を追加 * refactor: pageBlockTypesをjson-schemaに移植 * refactor: components/global内の型エラーが出ている箇所を修正 * lint: fix null check style * refactor: fix type error * refactor: fix some type errors * fix: 翻訳が抜けていた箇所を修正 * refactor: getJsonSchemaで正しいスキーマが返されるように修正 * fix: MkChartの型エラーとbytesオプションが機能していない問題を修正 * fix(misskey-js): `drive`->`folderUpdated`のpayloadの型が間違っていたのを修正 * refactor: fix some type errors * change: Captcha読み込み中の文言をLoadingに変更 * refactor(backend/misskey-js): MainEventの型を改善 * refactor: chartjs-plugin-gradientが二重でpluginに登録されていたのを修正 * update: misskey-js.api.md * refactor: fix some type errors * fix: backendのtypecheckが落ちていたのを修正 * update: misskey-js.api.md * add: json-schemaのnoteにpollの型定義を追加 * refactor: noteのjson-schemaの型を改善 * refactor: MkPoll * refactor: fix some type errors * change: UserLiteにisLockedを持たせるように * fix: notificationスキーマにroleが含まれていないのを修正 * Revert "change: UserLiteにisLockedを持たせるように" This reverts commit 1bb0c8e7a9b19a4e9f21bf7381712b98f27672a5. * fix: フォロー通知から鍵垢へのフォローを行うと処理中のまま止まってしまう問題を修正 * refactor: noteスキーマのvisibilityにenumを追加 * change: deepCloneのCloneableTypeにundefinedを追加 * refactor: fix some type errors * refactor: `allowEmpty: false`を使用していた箇所を`minLength: 1`に置き換え * enhance: API 'retension' のresponseの型を追加 * fix: Chart関連のtooltipが正しい位置に表示されない問題を修正 * refactor: fix some type errors * fix: 型情報が不足していたのを修正 * enhance: announcementスキーマにenumを追加 * enhance: ロールポリシーの型定義をRoleServiceからjson-schemaに移植 * refactor: policiesを`ref: RolePolicies`に統一 * fix: API `meta` のレスポンスの型にpoliciesが含まれていないのを修正 * refactor: fix some type errors * fix: backendのlintが落ちているのを修正 * fix: MkFoldableSectionの開閉時のanimationが適用されていない問題を修正 * fix: backendのtypecheckが落ちているのを修正 * update: run build-misskey-js-with-types * fix: MkDialogのmount時に文字数制限の判定が行われない問題を修正 * update: CHANGELOG.md * refactor: MkUserSelectDialogの型を改善 * fix: deepCloneでundefinedはcloneしないように (#9207) * change: frontendのcloneをbackend側にも反映 * update: CHANGELOG.md * fix: RoleServiceからPackを通して型RolePoliciesに依存させないように * Update packages/frontend/src/scripts/get-note-summary.ts * revert RoleService.ts changes * change: optional chaining -> non-null assertion * remove: unused import * fix: propsで渡されたuserがUserLiteの場合に意図しない動作になってしまうのを修正 * change: fix null check style * refactor: fix type error * change: fix null check style * Update packages/frontend/src/components/MkDrive.vue Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * refactor: css moduleでglobalを使わないように * refactor: roleのiconUrlは必ず存在するものとして扱うように * enhance: MenuButtonのactiveにcomputedを受け付けられるように * Update packages/frontend/src/components/MkNotePreview.vue * Update MkWindow.vue * refactor: notification.noteは必ず存在するものとして扱うように * Update packages/frontend/src/components/MkNotification.vue Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * fix: MkSignupDialogでdoneのemit時にresを含んでいなかったのを修正 * Update packages/frontend/src/scripts/clone.ts Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * refactor: 不要な返り値の型を削除 * refactor: 不要なnullチェックを削除 * update: misskey-js-autogen * update: clone.ts * refactor * Update MkNotification.vue * Update MkNotification.vue * :v: * Update MkNotification.vue * Update MkNotification.vue * Update MkNotification.vue * Update MkNotifications.vue * Update MkUserSetupDialog.Profile.vue * Update MkUserCardMini.vue * :v: * Update MkMenu.vue --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2024-01-30 19:53:53 +09:00
export function useScrollPositionManager(getScrollContainer: () => HTMLElement | null, router: IRouter) {
const scrollPosStore = new Map<string, number>();
onMounted(() => {
const scrollContainer = getScrollContainer();
refactor: frontendのcomponentsの型エラーを改善 (#12926) * add: safeFloatParserを追加 * fix: 欠けていた型を追加 * refactor: pageBlockTypesをjson-schemaに移植 * refactor: components/global内の型エラーが出ている箇所を修正 * lint: fix null check style * refactor: fix type error * refactor: fix some type errors * fix: 翻訳が抜けていた箇所を修正 * refactor: getJsonSchemaで正しいスキーマが返されるように修正 * fix: MkChartの型エラーとbytesオプションが機能していない問題を修正 * fix(misskey-js): `drive`->`folderUpdated`のpayloadの型が間違っていたのを修正 * refactor: fix some type errors * change: Captcha読み込み中の文言をLoadingに変更 * refactor(backend/misskey-js): MainEventの型を改善 * refactor: chartjs-plugin-gradientが二重でpluginに登録されていたのを修正 * update: misskey-js.api.md * refactor: fix some type errors * fix: backendのtypecheckが落ちていたのを修正 * update: misskey-js.api.md * add: json-schemaのnoteにpollの型定義を追加 * refactor: noteのjson-schemaの型を改善 * refactor: MkPoll * refactor: fix some type errors * change: UserLiteにisLockedを持たせるように * fix: notificationスキーマにroleが含まれていないのを修正 * Revert "change: UserLiteにisLockedを持たせるように" This reverts commit 1bb0c8e7a9b19a4e9f21bf7381712b98f27672a5. * fix: フォロー通知から鍵垢へのフォローを行うと処理中のまま止まってしまう問題を修正 * refactor: noteスキーマのvisibilityにenumを追加 * change: deepCloneのCloneableTypeにundefinedを追加 * refactor: fix some type errors * refactor: `allowEmpty: false`を使用していた箇所を`minLength: 1`に置き換え * enhance: API 'retension' のresponseの型を追加 * fix: Chart関連のtooltipが正しい位置に表示されない問題を修正 * refactor: fix some type errors * fix: 型情報が不足していたのを修正 * enhance: announcementスキーマにenumを追加 * enhance: ロールポリシーの型定義をRoleServiceからjson-schemaに移植 * refactor: policiesを`ref: RolePolicies`に統一 * fix: API `meta` のレスポンスの型にpoliciesが含まれていないのを修正 * refactor: fix some type errors * fix: backendのlintが落ちているのを修正 * fix: MkFoldableSectionの開閉時のanimationが適用されていない問題を修正 * fix: backendのtypecheckが落ちているのを修正 * update: run build-misskey-js-with-types * fix: MkDialogのmount時に文字数制限の判定が行われない問題を修正 * update: CHANGELOG.md * refactor: MkUserSelectDialogの型を改善 * fix: deepCloneでundefinedはcloneしないように (#9207) * change: frontendのcloneをbackend側にも反映 * update: CHANGELOG.md * fix: RoleServiceからPackを通して型RolePoliciesに依存させないように * Update packages/frontend/src/scripts/get-note-summary.ts * revert RoleService.ts changes * change: optional chaining -> non-null assertion * remove: unused import * fix: propsで渡されたuserがUserLiteの場合に意図しない動作になってしまうのを修正 * change: fix null check style * refactor: fix type error * change: fix null check style * Update packages/frontend/src/components/MkDrive.vue Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * refactor: css moduleでglobalを使わないように * refactor: roleのiconUrlは必ず存在するものとして扱うように * enhance: MenuButtonのactiveにcomputedを受け付けられるように * Update packages/frontend/src/components/MkNotePreview.vue * Update MkWindow.vue * refactor: notification.noteは必ず存在するものとして扱うように * Update packages/frontend/src/components/MkNotification.vue Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * fix: MkSignupDialogでdoneのemit時にresを含んでいなかったのを修正 * Update packages/frontend/src/scripts/clone.ts Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * refactor: 不要な返り値の型を削除 * refactor: 不要なnullチェックを削除 * update: misskey-js-autogen * update: clone.ts * refactor * Update MkNotification.vue * Update MkNotification.vue * :v: * Update MkNotification.vue * Update MkNotification.vue * Update MkNotification.vue * Update MkNotifications.vue * Update MkUserSetupDialog.Profile.vue * Update MkUserCardMini.vue * :v: * Update MkMenu.vue --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2024-01-30 19:53:53 +09:00
if (scrollContainer == null) return;
scrollContainer.addEventListener('scroll', () => {
scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop);
}, { passive: true });
router.addListener('change', ctx => {
const scrollPos = scrollPosStore.get(ctx.key) ?? 0;
scrollContainer.scroll({ top: scrollPos, behavior: 'instant' });
if (scrollPos !== 0) {
window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
scrollContainer.scroll({ top: scrollPos, behavior: 'instant' });
}, 100);
}
});
router.addListener('same', () => {
scrollContainer.scroll({ top: 0, behavior: 'smooth' });
});
});
}