おさむのひと f4deb005e9 feat: 新カスタム絵文字管理画面(β)の追加 (#13473)
* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix

* fix

* fix

* fix size

* fix register logs

* fix img autosize

* fix row selection

* support delete

* fix border rendering

* fix display:none

* tweak comments

* support choose pc file and drive file

* support directory drag-drop

* fix

* fix comment

* support context menu on data area

* fix autogen

* wip イベント整理

* イベントの整理

* refactor grid

* fix cell re-render bugs

* fix row remove

* fix comment

* fix validation

* fix utils

* list maximum

* add mimetype check

* fix

* fix number cell focus

* fix over 100 file drop

* remove log

* fix patchData

* fix performance

* fix

* support update and delete

* support remote import

* fix layout

* heightやめる

* fix performance

* add list v2 endpoint

* support pagination

* fix api call

* fix no clickable input text

* fix limit

* fix paging

* fix

* fix

* support search

* tweak logs

* tweak cell selection

* fix range select

* block delete

* add comment

* fix

* support import log

* fix dialog

* refactor

* add confirm dialog

* fix name

* fix autogen

* wip

* support image change and highlight row

* add columns

* wip

* support sort

* add role name

* add index to emoji

* refine context menu setting

* support role select

* remove unused buttons

* fix url

* fix MkRoleSelectDialog.vue

* add route

* refine remote page

* enter key search

* fix paste bugs

* fix copy/paste

* fix keyEvent

* fix copy/paste and delete

* fix comment

* fix MkRoleSelectDialog.vue and storybook scenario

* fix MkRoleSelectDialog.vue and storybook scenario

* add MkGrid.stories.impl.ts

* fix

* [wip] add custom-emojis-manager2.stories.impl.ts

* [wip] add custom-emojis-manager2.stories.impl.ts

* wip

* 課題はまだ残っているが、ひとまず完了

* fix validation and register roles

* fix upload

* optimize import

* patch from dev

* i18n

* revert excess fixes

* separate sort order component

* add SPDX

* revert excess fixes

* fix pre test

* fix bugs

* add type column

* fix types

* fix CHANGELOG.md

* fix lit

* lint

* tweak style

* refactor

* fix ci

* autogen

* Update types.ts

* CSS Module化

* fix log

* 縦スクロールを無効化

* MkStickyContainer化

* regenerate locales index.d.ts

* fix

* fix

* テスト

* ランダム値によるUI変更の抑制

* テスト

* tableタグやめる

* fix last-child css

* fix overflow css

* fix endpoint.ts

* tweak css

* 最新への追従とレイアウト微調整

* ソートキーの指定方法を他と合わせた

* fix focus

* fix layout

* v2エンドポイントのルールに対応

* 表示条件などを微調整

* fix MkDataCell.vue

* fix error code

* fix error

* add comment to MkModal.vue

* Update index.d.ts

* fix CHANGELOG.md

* fix color theme

* fix CHANGELOG.md

* fix CHANGELOG.md

* fix center

* fix: テーブルにフォーカスがあり、通常状態であるときはキーイベントの伝搬を止める

* fix: ロール選択用のダイアログにてコンディショナルロールを×ボタンで除外できなかったのを修正

* fix remote list folder

* sticky footers

* chore: fix ci error(just single line-break diff)

* fix loading

* fix like

* comma to space

* fix ci

* fix ci

* removed align-center

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>
2025-01-30 22:12:19 +08:00

756 lines
20 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
*/
// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
import { Component, markRaw, Ref, ref, defineAsyncComponent, nextTick } from 'vue';
import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import type { ComponentProps as CP } from 'vue-component-type-helpers';
import type { Form, GetFormResultType } from '@/scripts/form.js';
import type { MenuItem } from '@/types/menu.js';
import type { PostFormProps } from '@/types/post-form.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
import MkPageWindow from '@/components/MkPageWindow.vue';
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 MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { pleaseLogin } from '@/scripts/please-login.js';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
import { focusParent } from '@/scripts/focus.js';
export const openingWindowsCount = ref(0);
export type ApiWithDialogCustomErrors = Record<string, { title?: string; text: string; }>;
export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>(
endpoint: E,
data: P,
token?: string | null | undefined,
customErrors?: ApiWithDialogCustomErrors,
) => {
const promise = misskeyApi(endpoint, data, token);
promiseDialog(promise, null, async (err) => {
let title: string | undefined;
let text = err.message + '\n' + err.id;
if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription;
const date = new Date().toISOString();
const { result } = await actions({
type: 'error',
title,
text,
actions: [{
value: 'ok',
text: i18n.ts.gotIt,
primary: true,
}, {
value: 'copy',
text: i18n.ts.copyErrorInfo,
}],
});
if (result === 'copy') {
copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`);
success();
}
return;
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
title = i18n.ts.cannotPerformTemporary;
text = i18n.ts.cannotPerformTemporaryDescription;
} else if (err.code === 'INVALID_PARAM') {
title = i18n.ts.invalidParamError;
text = i18n.ts.invalidParamErrorDescription;
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
title = i18n.ts.permissionDeniedError;
text = i18n.ts.permissionDeniedErrorDescription;
} else if (err.code.startsWith('TOO_MANY')) {
title = i18n.ts.youCannotCreateAnymore;
text = `${i18n.ts.error}: ${err.id}`;
} else if (err.message.startsWith('Unexpected token')) {
title = i18n.ts.gotInvalidResponseError;
text = i18n.ts.gotInvalidResponseErrorDescription;
} else if (customErrors && customErrors[err.id] != null) {
title = customErrors[err.id].title;
text = customErrors[err.id].text;
}
alert({
type: 'error',
title,
text,
});
});
return promise;
});
export function promiseDialog<T extends Promise<any>>(
promise: T,
onSuccess?: ((res: Awaited<T>) => void) | null,
onFailure?: ((err: Misskey.api.APIError) => void) | null,
text?: string,
): T {
const showing = ref(true);
const success = ref(false);
promise.then(res => {
if (onSuccess) {
showing.value = false;
onSuccess(res);
} else {
success.value = true;
window.setTimeout(() => {
showing.value = false;
}, 1000);
}
}).catch(err => {
showing.value = false;
if (onFailure) {
onFailure(err);
} else {
alert({
type: 'error',
text: err,
});
}
});
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
const { dispose } = popup(MkWaitingDialog, {
success: success,
showing: showing,
text: text,
}, {
closed: () => dispose(),
});
return promise;
}
let popupIdCount = 0;
export const popups = ref<{
id: number;
component: Component;
props: Record<string, any>;
events: Record<string, any>;
}[]>([]);
const zIndexes = {
veryLow: 500000,
low: 1000000,
middle: 2000000,
high: 3000000,
};
export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number {
zIndexes[priority] += 100;
return zIndexes[priority];
}
// InstanceType<typeof Component>['$emit'] だとインターセクション型が返ってきて
// 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する
// FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい
type ComponentEmit<T> = T extends new () => { $props: infer Props }
? [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 function popup<T extends Component>(
component: T,
props: ComponentProps<T>,
events: ComponentEmit<T> = {} as ComponentEmit<T>,
): { dispose: () => void } {
markRaw(component);
const id = ++popupIdCount;
const dispose = () => {
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ
window.setTimeout(() => {
popups.value = popups.value.filter(p => p.id !== id);
}, 0);
};
const state = {
component,
props,
events,
id,
};
popups.value.push(state);
return {
dispose,
};
}
export function pageWindow(path: string) {
const { dispose } = popup(MkPageWindow, {
initialPath: path,
}, {
closed: () => dispose(),
});
}
export function toast(message: string) {
const { dispose } = popup(MkToast, {
message,
}, {
closed: () => dispose(),
});
}
export function alert(props: {
type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string;
text?: string;
}): Promise<void> {
return new Promise(resolve => {
const { dispose } = popup(MkDialog, props, {
done: () => {
resolve();
},
closed: () => dispose(),
});
});
}
export function confirm(props: {
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string;
text?: string;
okText?: string;
cancelText?: string;
}): Promise<{ canceled: boolean }> {
return new Promise(resolve => {
const { dispose } = popup(MkDialog, {
...props,
showCancelButton: true,
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => dispose(),
});
});
}
// TODO: const T extends ... にしたい
// https://zenn.dev/general_link/articles/813e47b7a0eef7#const-type-parameters
export function actions<T extends {
value: string;
text: string;
primary?: boolean,
danger?: boolean,
}[]>(props: {
type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question';
title?: string;
text?: string;
actions: T;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: T[number]['value'];
}> {
return new Promise(resolve => {
const { dispose } = popup(MkDialog, {
...props,
actions: props.actions.map(a => ({
text: a.text,
primary: a.primary,
danger: a.danger,
callback: () => {
resolve({ canceled: false, result: a.value });
},
})),
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => dispose(),
});
});
}
// default が指定されていたら result は null になり得ないことを保証する overload function
export function inputText(props: {
type?: 'text' | 'email' | 'password' | 'url';
title?: string;
text?: string;
placeholder?: string | null;
autocomplete?: string;
default: string;
minLength?: number;
maxLength?: number;
}): 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 => {
const { dispose } = popup(MkDialog, {
title: props.title,
text: props.text,
input: {
type: props.type,
placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default ?? null,
minLength: props.minLength,
maxLength: props.maxLength,
},
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => dispose(),
});
});
}
// default が指定されていたら result は null になり得ないことを保証する overload function
export function inputNumber(props: {
title?: string;
text?: string;
placeholder?: string | null;
autocomplete?: string;
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 => {
const { dispose } = popup(MkDialog, {
title: props.title,
text: props.text,
input: {
type: 'number',
placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default ?? null,
},
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => dispose(),
});
});
}
export function inputDate(props: {
title?: string;
text?: string;
placeholder?: string | null;
default?: string | null;
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: Date;
}> {
return new Promise(resolve => {
const { dispose } = popup(MkDialog, {
title: props.title,
text: props.text,
input: {
type: 'date',
placeholder: props.placeholder,
default: props.default ?? null,
},
}, {
done: result => {
resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
},
closed: () => dispose(),
});
});
}
export function authenticateDialog(): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: { password: string; token: string | null; };
}> {
return new Promise(resolve => {
const { dispose } = popup(MkPasswordDialog, {}, {
done: result => {
resolve(result ? { canceled: false, result } : { canceled: true, result: undefined });
},
closed: () => dispose(),
});
});
}
type SelectItem<C> = {
value: C;
text: string;
};
// default が指定されていたら result は null になり得ないことを保証する overload function
export function select<C = unknown>(props: {
title?: string;
text?: string;
default: string;
items: (SelectItem<C> | {
sectionTitle: string;
items: SelectItem<C>[];
} | undefined)[];
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: C;
}>;
export function select<C = unknown>(props: {
title?: string;
text?: string;
default?: string | null;
items: (SelectItem<C> | {
sectionTitle: string;
items: SelectItem<C>[];
} | undefined)[];
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: C | null;
}>;
export function select<C = unknown>(props: {
title?: string;
text?: string;
default?: string | null;
items: (SelectItem<C> | {
sectionTitle: string;
items: SelectItem<C>[];
} | undefined)[];
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: C | null;
}> {
return new Promise(resolve => {
const { dispose } = popup(MkDialog, {
title: props.title,
text: props.text,
select: {
items: props.items.filter(x => x !== undefined),
default: props.default ?? null,
},
}, {
done: result => {
resolve(result ? result : { canceled: true });
},
closed: () => dispose(),
});
});
}
export function success(): Promise<void> {
return new Promise(resolve => {
const showing = ref(true);
window.setTimeout(() => {
showing.value = false;
}, 1000);
const { dispose } = popup(MkWaitingDialog, {
success: true,
showing: showing,
}, {
done: () => resolve(),
closed: () => dispose(),
});
});
}
export function waiting(): Promise<void> {
return new Promise(resolve => {
const showing = ref(true);
const { dispose } = popup(MkWaitingDialog, {
success: false,
showing: showing,
}, {
done: () => resolve(),
closed: () => dispose(),
});
});
}
export function form<F extends Form>(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType<F> }> {
return new Promise(resolve => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
done: result => {
resolve(result);
},
closed: () => dispose(),
});
});
}
export async function selectUser(opts: { includeSelf?: boolean; localOnly?: boolean; } = {}): Promise<Misskey.entities.UserDetailed> {
return new Promise(resolve => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
includeSelf: opts.includeSelf,
localOnly: opts.localOnly,
}, {
ok: user => {
resolve(user);
},
closed: () => dispose(),
});
});
}
export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
return new Promise(resolve => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'file',
multiple,
}, {
done: files => {
if (files) {
resolve(files);
}
},
closed: () => dispose(),
});
});
}
export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> {
return new Promise(resolve => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
type: 'folder',
multiple,
}, {
done: folders => {
if (folders) {
resolve(folders);
}
},
closed: () => dispose(),
});
});
}
export async function selectRole(params: {
initialRoleIds?: string[],
title?: string,
infoMessage?: string,
publicOnly?: boolean,
}): Promise<
{ canceled: true; result: undefined; } |
{ canceled: false; result: Misskey.entities.Role[] }
> {
return new Promise((resolve) => {
popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, {
done: roles => {
resolve({ canceled: false, result: roles });
},
close: () => {
resolve({ canceled: true, result: undefined });
},
}, 'dispose');
});
}
export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> {
return new Promise(resolve => {
const { dispose } = popup(MkEmojiPickerDialog, {
src,
...opts,
}, {
done: emoji => {
resolve(emoji);
},
closed: () => dispose(),
});
});
}
export async function cropImage(image: Misskey.entities.DriveFile, options: {
aspectRatio: number;
uploadFolder?: string | null;
}): Promise<Misskey.entities.DriveFile> {
return new Promise(resolve => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
file: image,
aspectRatio: options.aspectRatio,
uploadFolder: options.uploadFolder,
}, {
ok: x => {
resolve(x);
},
closed: () => dispose(),
});
});
}
export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: {
align?: string;
width?: number;
onClosing?: () => void;
}): Promise<void> {
let returnFocusTo = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(document.activeElement);
return new Promise(resolve => nextTick(() => {
const { dispose } = popup(MkPopupMenu, {
items,
src,
width: options?.width,
align: options?.align,
returnFocusTo,
}, {
closed: () => {
resolve();
dispose();
returnFocusTo = null;
},
closing: () => {
options?.onClosing?.();
},
});
}));
}
export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
if (
defaultStore.state.contextMenu === 'native' ||
(defaultStore.state.contextMenu === 'appWithShift' && !ev.shiftKey)
) {
return Promise.resolve();
}
let returnFocusTo = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(document.activeElement);
ev.preventDefault();
return new Promise(resolve => nextTick(() => {
const { dispose } = popup(MkContextMenu, {
items,
ev,
}, {
closed: () => {
resolve();
dispose();
// MkModalを通していないのでここでフォーカスを戻す処理を行う
if (returnFocusTo != null) {
focusParent(returnFocusTo, true, false);
returnFocusTo = null;
}
},
});
}));
}
export function post(props: PostFormProps = {}): Promise<void> {
pleaseLogin({
openOnRemote: (props.initialText || props.initialNote ? {
type: 'share',
params: {
text: props.initialText ?? props.initialNote?.text ?? '',
visibility: props.initialVisibility ?? props.initialNote?.visibility ?? 'public',
localOnly: (props.initialLocalOnly || props.initialNote?.localOnly) ? '1' : '0',
},
} : undefined),
});
showMovedDialog();
return new Promise(resolve => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
// Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、
// 複数のpost formを開いたときに場合によってはエラーになる
// もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが
const { dispose } = popup(MkPostFormDialog, props, {
closed: () => {
resolve();
dispose();
},
});
});
}
export const deckGlobalEvents = new EventEmitter();
/*
export function checkExistence(fileData: ArrayBuffer): Promise<any> {
return new Promise((resolve, reject) => {
const data = new FormData();
data.append('md5', getMD5(fileData));
api('drive/files/find-by-hash', {
md5: getMD5(fileData)
}).then(resp => {
resolve(resp.length > 0 ? resp[0] : null);
});
});
}*/