Sharkey/packages/frontend/src/nirax.ts
かっこかり 2cbe1d1210
feat(frontend): ノート・ユーザータイムライン埋め込み (#13929)
* fix

* navhookをbootに移動

* サーバーサイドのbootも分けるように

* 埋め込みページかどうかの判定は最初の一回だけに

* tooltipは出せるように

* fix design

* 埋め込み独自のtooltipを削除

* ロジックの分岐が多かったMkNoteDetailedを分離

* fix indent

* プレビュー用iframeにフォーカスが当たるのを修正

* popupの制御を出す側で行うように

* パラメータが逆になっていたのを修正

* Update MkEmbedCodeGenDialog.vue

* fix

* eliminate misskey-js lint warns

* fix

* add appropriate attributes to embed html

* enhance: サーバーサイドのembed系をさらに分離

* enhance: embed routerを分離(route定義をboot時に変更できるようにする改修を含む)

* type

* lint

* fix indent

* server-side styleを完全に分離

* Revert "refactor: 画面サイズのしきい値をconstにまとめる"

This reverts commit 05ca36f400889456981e89489ae0ae242fa09b67.

* fix

* revert all changes in base.pug

* embedドメインをまとめた

* embedドメインをまとめた

* prevent calling contextmenu in embed page by stopping at the caller

* fix import

* fix import

* improve directory structure

* fix import

* register timeline ui as a container

* wa-

* rename

* wa-

* Update EmMediaList.vue

* Update EmMediaList.vue

* Update EmMediaList.vue

* Update EmMediaImage.vue

* Update EmNote.vue

* revert mkmedialist changes

* 戻し漏れ

* wip

* tweak embed media ui

* revert original media components

* Update boot.embed.js

* rename

* wip

* Update MkNote.vue

* wip

* Update MkSubNoteContent.vue

* Update EmNote.vue

* Update packages/frontend/src/router/definition.ts

* Revert "Update packages/frontend/src/router/definition.ts"

This reverts commit 937ae44521cdb0f250796943b20142b65f8ed944.

* refactor EmMediaImage

* fix import

* remove unused imports

* Update router.ts

* wip

* Update boot.ts

* wip

* wip

* wip

* wip

* Update EmNote.vue

* Update EmNote.vue

* Create EmA.vue

* Create EmAvatar.vue

* Update EmAvatar.vue

* wip

* wip

* wip

* Create EmImgWithBlurhash.vue

* Update EmImgWithBlurhash.vue

* Create EmPagination.vue

* wip

* Update boot.ts

* wip

* wip

* wi@p

* wip

* wip

* wiop

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update boot.ts

* wip

* Update MkMisskeyFlavoredMarkdown.ts

* wip

* wip

* wip

* wip

* wip

* Update post-message.ts

* wip

* Update EmNoteDetailed.vue

* Update EmNoteDetailed.vue

* Create instance.ts

* Update EmNoteDetailed.vue

* wip

* Update EmNoteDetailed.vue

* wip

* wip

* wip

* Update pnpm-lock.yaml

* wip

* wip

* wp

* wip

* Update ClientServerService.ts

* wip

* Update boot.ts

* Update vite.config.local-dev.ts

* Update vite.config.ts

* Create index.html

* wa-

* wip

* Update boot.ts

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Create EmLink.vue

* Create EmMention.vue

* Update EmMfm.ts

* wip

* wip

* wip

* wip

* Update vite.config.ts

* Update boot.ts

* Update EmA.vue

* うぃp

* wip

* wip

* Create EmError.vue

* wip

* Update MkEmbedCodeGenDialog.vue

* Update EmNote.vue

* wip

* wip

* Update user-timeline.vue

* Update check-spdx-license-id.yml

* wip

* wip

* style(frontend-shared): lint fixes on build.js

* fix(frontend-shared): include `*.{js,json}` files in js-built

* wip

* use alias

* refactor

* refactor

* Update scroll.ts

* refactor

* refactor

* refactor

* wip

* wip

* wip

* wip

* Update roles.vue

* Update branding.vue

* wip

* wip

* wip

* Update page.vue

* wip

* fix import

* add missing css variables

* 絵文字をtwemojiに変更

クライアントデフォルトにあわせるため

* force empoll readonly

* fix compiler error

* fix broken imports

* tweak button style

* run api extractor

* fix storybook theme preloads

* fix storybook instance imports

* Update preview.ts

* Update preview.ts

* Update preview.ts

* Revert "Update preview.ts"

This reverts commit 12bab1c6fbd3baf753515df760ff19d027b85155.

* Revert "Update preview.ts"

This reverts commit 5c0ce01dbdf2194ffe94aba950f747a9968f29c4.

* Revert "Update preview.ts"

This reverts commit f4863524d7e5ca0f25470808849c24a72bea000a.

* Revert "fix storybook instance imports"

This reverts commit ed8eabb246edf731d31adffbe3c77c539e53ae9e.

* Revert "wip"

This reverts commit d3c1926519878155193a1654f49141e515d49683.

* Revert "Update page.vue"

This reverts commit 27c7900b0c1ae296b56075e8a9c22585d9cd744b.

* Revert "Update branding.vue"

This reverts commit c08ccb65ba66774c3e2b3dcfc6153004b5c0aa16.

* Revert "Update roles.vue"

This reverts commit 1488b670660cb1803d17d8f5c78f2d79e59fa52d.

* Revert "wip"

This reverts commit aab1c769814b08c257cad3025422a0eea3bfba4f.

* refactor: use common media proxy

* fix imports

* fix

* fix: MediaProxyの初期化を保証する(storybook対策?)

* enhance(frontend-embed): improve embedParams provide

* fix(backend): MK_DEV_PREFER=backendのときにembed viteが読み込めないのを修正

* fix

* embed-pageを共通化

* fix import

* fix import

* fix import

* const.jsを共通化

(たぶんrevertしすぎた)

* fix type error

* fix duplicated import

* fix lint

* fix

* コメントとして残す

* sharedとembedをlint対象にする

* lint

* attempt to fix eslint (frontend-shared)

* lint fixes

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Co-authored-by: zyoshoka <107108195+zyoshoka@users.noreply.github.com>
2024-09-09 20:57:36 +09:00

465 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// NIRAX --- A lightweight router
import { Component, onMounted, shallowRef, ShallowRef } from 'vue';
import { EventEmitter } from 'eventemitter3';
function safeURIDecode(str: string): string {
try {
return decodeURIComponent(str);
} catch {
return str;
}
}
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,
});
} 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;
} 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();
}
}
}
}
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 && res.route.path !== '/:(*)') {
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) {
const beforePath = this.currentPath;
if (path === beforePath) {
this.emit('same');
return;
}
if (this.navHook) {
const cancel = this.navHook(path, flag);
if (cancel) return;
}
const res = this.navigate(path, null);
if (res.route.path === '/:(*)') {
location.href = path;
} else {
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,
});
}
}
export function useScrollPositionManager(getScrollContainer: () => HTMLElement | null, router: IRouter) {
const scrollPosStore = new Map<string, number>();
onMounted(() => {
const scrollContainer = getScrollContainer();
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' });
});
});
}