diff options
Diffstat (limited to 'packages/frontend/src/os.ts')
| -rw-r--r-- | packages/frontend/src/os.ts | 302 |
1 files changed, 172 insertions, 130 deletions
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index a4fde6b701..c561e84a23 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -7,9 +7,9 @@ import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue'; import { EventEmitter } from 'eventemitter3'; -import insertTextAtCursor from 'insert-text-at-cursor'; import * as Misskey from 'misskey-js'; -import type { ComponentProps } from 'vue-component-type-helpers'; +import type { ComponentProps as CP } from 'vue-component-type-helpers'; +import type { Form, GetFormResultType } from '@/scripts/form.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; @@ -19,7 +19,6 @@ import MkToast from '@/components/MkToast.vue'; import MkDialog from '@/components/MkDialog.vue'; import MkPasswordDialog from '@/components/MkPasswordDialog.vue'; import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue'; -import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue'; import MkPopupMenu from '@/components/MkPopupMenu.vue'; import MkContextMenu from '@/components/MkContextMenu.vue'; import { MenuItem } from '@/types/menu.js'; @@ -28,15 +27,15 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; export const openingWindowsCount = ref(0); -export const apiWithDialog = (( - endpoint: string, - data: Record<string, any> = {}, +export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>( + endpoint: E, + data: P = {} as any, token?: string | null | undefined, ) => { const promise = misskeyApi(endpoint, data, token); promiseDialog(promise, null, async (err) => { - let title = null; - let text = err.message + '\n' + (err as any).id; + let title: string | undefined; + let text = err.message + '\n' + err.id; if (err.code === 'INTERNAL_ERROR') { title = i18n.ts.internalServerError; text = i18n.ts.internalServerErrorDescription; @@ -88,7 +87,7 @@ export const apiWithDialog = (( export function promiseDialog<T extends Promise<any>>( promise: T, onSuccess?: ((res: any) => void) | null, - onFailure?: ((err: Error) => void) | null, + onFailure?: ((err: Misskey.api.APIError) => void) | null, text?: string, ): T { const showing = ref(true); @@ -149,14 +148,30 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number { // 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する // FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい type ComponentEmit<T> = T extends new () => { $props: infer Props } - ? EmitsExtractor<Props> - : never; + ? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never] + ? Record<string, unknown> // *.ts ファイルから型がうまく取れないとき用(これがないと {} になって型エラーがうるさい) + : EmitsExtractor<Props> + : T extends (...args: any) => any + ? ReturnType<T> extends { [x: string]: any; __ctx?: { [x: string]: any; props: infer Props } } + ? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never] + ? Record<string, unknown> + : EmitsExtractor<Props> + : never + : never; + +// props に ref を許可するようにする +type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> }; type EmitsExtractor<T> = { [K in keyof T as K extends `onVnode${string}` ? never : K extends `on${infer E}` ? Uncapitalize<E> : K extends string ? never : K]: T[K]; }; -export async function popup<T extends Component>(component: T, props: ComponentProps<T>, events: ComponentEmit<T> = {} as ComponentEmit<T>, disposeEvent?: keyof ComponentEmit<T>) { +export async function popup<T extends Component>( + component: T, + props: ComponentProps<T>, + events: ComponentEmit<T> = {} as ComponentEmit<T>, + disposeEvent?: keyof ComponentEmit<T>, +): Promise<{ dispose: () => void }> { markRaw(component); const id = ++popupIdCount; @@ -197,12 +212,12 @@ export function toast(message: string) { export function alert(props: { type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; }): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, props, { - done: result => { + done: () => { resolve(); }, }, 'closed'); @@ -211,12 +226,12 @@ export function alert(props: { export function confirm(props: { type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; okText?: string; cancelText?: string; }): Promise<{ canceled: boolean }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { ...props, showCancelButton: true, @@ -237,13 +252,15 @@ export function actions<T extends { danger?: boolean, }[]>(props: { type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; actions: T; -}): Promise<{ canceled: true; result: undefined; } | { +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: T[number]['value']; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { ...props, actions: props.actions.map(a => ({ @@ -262,19 +279,50 @@ export function actions<T extends { }); } +// default が指定されていたら result は null になり得ないことを保証する overload function export function inputText(props: { type?: 'text' | 'email' | 'password' | 'url'; - title?: string | null; - text?: string | null; + title?: string; + text?: string; placeholder?: string | null; autocomplete?: string; - default?: string | null; + default: string; minLength?: number; maxLength?: number; -}): Promise<{ canceled: true; result: undefined; } | { +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: string; +}>; +export function inputText(props: { + type?: 'text' | 'email' | 'password' | 'url'; + title?: string; + text?: string; + placeholder?: string | null; + autocomplete?: string; + default?: string | null; + minLength?: number; + maxLength?: number; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: string | null; +}>; +export function inputText(props: { + type?: 'text' | 'email' | 'password' | 'url'; + title?: string; + text?: string; + placeholder?: string | null; + autocomplete?: string; + default?: string | null; + minLength?: number; + maxLength?: number; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: string | null; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { title: props.title, text: props.text, @@ -282,7 +330,7 @@ export function inputText(props: { type: props.type, placeholder: props.placeholder, autocomplete: props.autocomplete, - default: props.default, + default: props.default ?? null, minLength: props.minLength, maxLength: props.maxLength, }, @@ -294,16 +342,41 @@ export function inputText(props: { }); } +// default が指定されていたら result は null になり得ないことを保証する overload function export function inputNumber(props: { - title?: string | null; - text?: string | null; + title?: string; + text?: string; placeholder?: string | null; autocomplete?: string; - default?: number | null; -}): Promise<{ canceled: true; result: undefined; } | { + default: number; +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: number; +}>; +export function inputNumber(props: { + title?: string; + text?: string; + placeholder?: string | null; + autocomplete?: string; + default?: number | null; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: number | null; +}>; +export function inputNumber(props: { + title?: string; + text?: string; + placeholder?: string | null; + autocomplete?: string; + default?: number | null; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: number | null; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { title: props.title, text: props.text, @@ -311,7 +384,7 @@ export function inputNumber(props: { type: 'number', placeholder: props.placeholder, autocomplete: props.autocomplete, - default: props.default, + default: props.default ?? null, }, }, { done: result => { @@ -322,34 +395,38 @@ export function inputNumber(props: { } export function inputDate(props: { - title?: string | null; - text?: string | null; + title?: string; + text?: string; placeholder?: string | null; - default?: Date | null; -}): Promise<{ canceled: true; result: undefined; } | { + default?: string | null; +}): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: Date; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { title: props.title, text: props.text, input: { type: 'date', placeholder: props.placeholder, - default: props.default, + default: props.default ?? null, }, }, { done: result => { - resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true }); + resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true }); }, }, 'closed'); }); } -export function authenticateDialog(): Promise<{ canceled: true; result: undefined; } | { +export function authenticateDialog(): Promise<{ + canceled: true; result: undefined; +} | { canceled: false; result: { password: string; token: string | null; }; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkPasswordDialog, {}, { done: result => { resolve(result ? { canceled: false, result } : { canceled: true, result: undefined }); @@ -358,34 +435,53 @@ export function authenticateDialog(): Promise<{ canceled: true; result: undefine }); } +// default が指定されていたら result は null になり得ないことを保証する overload function export function select<C = any>(props: { - title?: string | null; - text?: string | null; + title?: string; + text?: string; + default: string; + items: { + value: C; + text: string; + }[]; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: C; +}>; +export function select<C = any>(props: { + title?: string; + text?: string; default?: string | null; -} & ({ items: { value: C; text: string; }[]; +}): Promise<{ + canceled: true; result: undefined; } | { - groupedItems: { - label: string; - items: { - value: C; - text: string; - }[]; + canceled: false; result: C | null; +}>; +export function select<C = any>(props: { + title?: string; + text?: string; + default?: string | null; + items: { + value: C; + text: string; }[]; -})): Promise<{ canceled: true; result: undefined; } | { - canceled: false; result: C; +}): Promise<{ + canceled: true; result: undefined; +} | { + canceled: false; result: C | null; }> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(MkDialog, { title: props.title, text: props.text, select: { items: props.items, - groupedItems: props.groupedItems, - default: props.default, + default: props.default ?? null, }, }, { done: result => { @@ -396,7 +492,7 @@ export function select<C = any>(props: { } export function success(): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { const showing = ref(true); window.setTimeout(() => { showing.value = false; @@ -411,7 +507,7 @@ export function success(): Promise<void> { } export function waiting(): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { const showing = ref(true); popup(MkWaitingDialog, { success: false, @@ -422,9 +518,9 @@ export function waiting(): Promise<void> { }); } -export function form(title, form) { - return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, { +export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType<F> }> { + return new Promise(resolve => { + popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, { done: result => { resolve(result); }, @@ -433,7 +529,7 @@ export function form(title, form) { } export async function selectUser(opts: { includeSelf?: boolean; localOnly?: boolean; } = {}): Promise<Misskey.entities.UserDetailed> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), { includeSelf: opts.includeSelf, localOnly: opts.localOnly, @@ -446,7 +542,7 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool } export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'file', multiple, @@ -460,23 +556,23 @@ export async function selectDriveFile(multiple: boolean): Promise<Misskey.entiti }); } -export async function selectDriveFolder(multiple: boolean) { - return new Promise((resolve, reject) => { +export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'folder', multiple, }, { done: folders => { if (folders) { - resolve(multiple ? folders : folders[0]); + resolve(folders); } }, }, 'closed'); }); } -export async function pickEmoji(src: HTMLElement | null, opts) { - return new Promise((resolve, reject) => { +export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> { + return new Promise(resolve => { popup(MkEmojiPickerDialog, { src, ...opts, @@ -492,7 +588,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: { aspectRatio: number; uploadFolder?: string | null; }): Promise<Misskey.entities.DriveFile> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { file: image, aspectRatio: options.aspectRatio, @@ -505,67 +601,13 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: { }); } -type AwaitType<T> = - T extends Promise<infer U> ? U : - T extends (...args: any[]) => Promise<infer V> ? V : - T; -let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null; -let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null; -export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) { - if (openingEmojiPicker) return; - - activeTextarea = initialTextarea; - - const textareas = document.querySelectorAll('textarea, input'); - for (const textarea of Array.from(textareas)) { - textarea.addEventListener('focus', () => { - activeTextarea = textarea; - }); - } - - const observer = new MutationObserver(records => { - for (const record of records) { - for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) { - const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>; - for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) { - if (document.activeElement === textarea) activeTextarea = textarea; - textarea.addEventListener('focus', () => { - activeTextarea = textarea; - }); - } - } - } - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - attributes: false, - characterData: false, - }); - - openingEmojiPicker = await popup(MkEmojiPickerWindow, { - src, - ...opts, - }, { - chosen: emoji => { - insertTextAtCursor(activeTextarea, emoji); - }, - closed: () => { - openingEmojiPicker!.dispose(); - openingEmojiPicker = null; - observer.disconnect(); - }, - }); -} - -export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement | EventTarget | null, options?: { +export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: { align?: string; width?: number; viaKeyboard?: boolean; onClosing?: () => void; }): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise(resolve => { let dispose; popup(MkPopupMenu, { items, @@ -587,9 +629,9 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement }); } -export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent): Promise<void> { +export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { ev.preventDefault(); - return new Promise((resolve, reject) => { + return new Promise(resolve => { let dispose; popup(MkContextMenu, { items, @@ -608,7 +650,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) export function post(props: Record<string, any> = {}): Promise<void> { showMovedDialog(); - return new Promise((resolve, reject) => { + return new Promise(resolve => { // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 |