summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md3
-rw-r--r--locales/index.d.ts224
-rw-r--r--locales/ja-JP.yml64
-rw-r--r--packages/backend/migration/1709126576000-optimize-emoji-index.js18
-rw-r--r--packages/backend/src/const.ts12
-rw-r--r--packages/backend/src/core/CustomEmojiService.ts224
-rw-r--r--packages/backend/src/core/entities/EmojiEntityService.ts90
-rw-r--r--packages/backend/src/misc/json-schema.ts7
-rw-r--r--packages/backend/src/models/json-schema/emoji.ts83
-rw-r--r--packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts5
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts4
-rw-r--r--packages/backend/src/server/api/endpoints.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/add.ts31
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/copy.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/update.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts126
-rw-r--r--packages/backend/test/unit/CustomEmojiService.ts817
-rw-r--r--packages/frontend/.storybook/fake-utils.ts154
-rw-r--r--packages/frontend/.storybook/fakes.ts91
-rw-r--r--packages/frontend/.storybook/generate.tsx4
-rw-r--r--packages/frontend/src/components/MkFolder.vue6
-rw-r--r--packages/frontend/src/components/MkModal.vue29
-rw-r--r--packages/frontend/src/components/MkPagingButtons.vue124
-rw-r--r--packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts106
-rw-r--r--packages/frontend/src/components/MkRoleSelectDialog.vue200
-rw-r--r--packages/frontend/src/components/MkSortOrderEditor.define.ts11
-rw-r--r--packages/frontend/src/components/MkSortOrderEditor.vue112
-rw-r--r--packages/frontend/src/components/MkTagItem.stories.impl.ts70
-rw-r--r--packages/frontend/src/components/MkTagItem.vue76
-rw-r--r--packages/frontend/src/components/grid/MkCellTooltip.vue35
-rw-r--r--packages/frontend/src/components/grid/MkDataCell.vue391
-rw-r--r--packages/frontend/src/components/grid/MkDataRow.vue72
-rw-r--r--packages/frontend/src/components/grid/MkGrid.stories.impl.ts223
-rw-r--r--packages/frontend/src/components/grid/MkGrid.vue1342
-rw-r--r--packages/frontend/src/components/grid/MkHeaderCell.vue216
-rw-r--r--packages/frontend/src/components/grid/MkHeaderRow.vue60
-rw-r--r--packages/frontend/src/components/grid/MkNumberCell.vue61
-rw-r--r--packages/frontend/src/components/grid/cell-validators.ts110
-rw-r--r--packages/frontend/src/components/grid/cell.ts88
-rw-r--r--packages/frontend/src/components/grid/column.ts53
-rw-r--r--packages/frontend/src/components/grid/grid-event.ts46
-rw-r--r--packages/frontend/src/components/grid/grid-utils.ts215
-rw-r--r--packages/frontend/src/components/grid/grid.ts44
-rw-r--r--packages/frontend/src/components/grid/row.ts68
-rw-r--r--packages/frontend/src/components/hook/useLoading.ts52
-rw-r--r--packages/frontend/src/index.html1
-rw-r--r--packages/frontend/src/os.ts21
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts56
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue757
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue477
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.vue36
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue102
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue441
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts160
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager2.vue44
-rw-r--r--packages/frontend/src/pages/admin/index.vue5
-rw-r--r--packages/frontend/src/router/definition.ts4
-rw-r--r--packages/frontend/src/scripts/file-drop.ts121
-rw-r--r--packages/frontend/src/scripts/key-event.ts153
-rw-r--r--packages/frontend/src/scripts/select-file.ts20
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md12
-rw-r--r--packages/misskey-js/src/autogen/apiClientJSDoc.ts11
-rw-r--r--packages/misskey-js/src/autogen/endpoint.ts3
-rw-r--r--packages/misskey-js/src/autogen/entities.ts2
-rw-r--r--packages/misskey-js/src/autogen/models.ts1
-rw-r--r--packages/misskey-js/src/autogen/types.ts123
66 files changed, 8273 insertions, 56 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55833c59a1..2fa49e3456 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,8 @@
- 詳細は #14730 および `.config/example.yml` または `.config/docker_example.yml`の'Fulltext search configuration'をご参照願います.
### General
--
+- Feat: カスタム絵文字管理画面をリニューアル #10996
+ * β版として公開のため、旧画面も引き続き利用可能です
### Client
- Enhance: PC画面でチャンネルが複数列で表示されるように
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 8bd0e647b1..b98fd5d423 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -37,6 +37,10 @@ export interface Locale extends ILocale {
*/
"search": string;
/**
+ * リセット
+ */
+ "reset": string;
+ /**
* 通知
*/
"notifications": string;
@@ -10543,6 +10547,226 @@ export interface Locale extends ILocale {
*/
"native": string;
};
+ "_gridComponent": {
+ "_error": {
+ /**
+ * この値は必須項目です
+ */
+ "requiredValue": string;
+ /**
+ * 正規表現によるバリデーションはtype:textのカラムのみサポートします。
+ */
+ "columnTypeNotSupport": string;
+ /**
+ * この値は{pattern}のパターンに一致しません
+ */
+ "patternNotMatch": ParameterizedString<"pattern">;
+ /**
+ * この値は一意である必要があります
+ */
+ "notUnique": string;
+ };
+ };
+ "_roleSelectDialog": {
+ /**
+ * 選択されていません
+ */
+ "notSelected": string;
+ };
+ "_customEmojisManager": {
+ "_gridCommon": {
+ /**
+ * 選択行をコピー
+ */
+ "copySelectionRows": string;
+ /**
+ * 選択範囲をコピー
+ */
+ "copySelectionRanges": string;
+ /**
+ * 選択行を削除
+ */
+ "deleteSelectionRows": string;
+ /**
+ * 選択範囲の行を削除
+ */
+ "deleteSelectionRanges": string;
+ /**
+ * 検索設定
+ */
+ "searchSettings": string;
+ /**
+ * 検索条件を詳細に設定します。
+ */
+ "searchSettingCaption": string;
+ /**
+ * 並び順
+ */
+ "sortOrder": string;
+ /**
+ * 登録ログ
+ */
+ "registrationLogs": string;
+ /**
+ * 絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。
+ */
+ "registrationLogsCaption": string;
+ /**
+ * エラー
+ */
+ "alertEmojisRegisterFailedTitle": string;
+ /**
+ * 絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。
+ */
+ "alertEmojisRegisterFailedDescription": string;
+ };
+ "_logs": {
+ /**
+ * 成功ログを表示
+ */
+ "showSuccessLogSwitch": string;
+ /**
+ * 失敗ログはありません。
+ */
+ "failureLogNothing": string;
+ /**
+ * ログはありません。
+ */
+ "logNothing": string;
+ };
+ "_remote": {
+ /**
+ * 選択行をインポート
+ */
+ "importSelectionRows": string;
+ /**
+ * 選択範囲の行をインポート
+ */
+ "importSelectionRangesRows": string;
+ /**
+ * チェックされた絵文字をインポート
+ */
+ "importEmojisButton": string;
+ /**
+ * 絵文字のインポート
+ */
+ "confirmImportEmojisTitle": string;
+ /**
+ * リモートから受信した{count}個の絵文字のインポートを行います。絵文字のライセンスに十分な注意を払ってください。実行しますか?
+ */
+ "confirmImportEmojisDescription": ParameterizedString<"count">;
+ };
+ "_local": {
+ /**
+ * 登録済み絵文字一覧
+ */
+ "tabTitleList": string;
+ /**
+ * 絵文字の登録
+ */
+ "tabTitleRegister": string;
+ "_list": {
+ /**
+ * 登録された絵文字はありません。
+ */
+ "emojisNothing": string;
+ /**
+ * 選択行を削除対象にする
+ */
+ "markAsDeleteTargetRows": string;
+ /**
+ * 選択範囲の行を削除対象にする
+ */
+ "markAsDeleteTargetRanges": string;
+ /**
+ * 変更された絵文字はありません。
+ */
+ "alertUpdateEmojisNothingDescription": string;
+ /**
+ * 削除対象の絵文字はありません。
+ */
+ "alertDeleteEmojisNothingDescription": string;
+ /**
+ * 確認
+ */
+ "confirmUpdateEmojisTitle": string;
+ /**
+ * {count}個の絵文字を更新します。実行しますか?
+ */
+ "confirmUpdateEmojisDescription": ParameterizedString<"count">;
+ /**
+ * 確認
+ */
+ "confirmDeleteEmojisTitle": string;
+ /**
+ * チェックがつけられた{count}個の絵文字を削除します。実行しますか?
+ */
+ "confirmDeleteEmojisDescription": ParameterizedString<"count">;
+ /**
+ * 絵文字に設定されたロールで検索
+ */
+ "dialogSelectRoleTitle": string;
+ };
+ "_register": {
+ /**
+ * アップロード設定
+ */
+ "uploadSettingTitle": string;
+ /**
+ * この画面で絵文字アップロードを行う際の動作を設定できます。
+ */
+ "uploadSettingDescription": string;
+ /**
+ * ディレクトリ名を"category"に入力する
+ */
+ "directoryToCategoryLabel": string;
+ /**
+ * ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を"category"に入力します。
+ */
+ "directoryToCategoryCaption": string;
+ /**
+ * いずれかの方法で登録する絵文字を選択してください。
+ */
+ "emojiInputAreaCaption": string;
+ /**
+ * この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ
+ */
+ "emojiInputAreaList1": string;
+ /**
+ * このリンクをクリックしてPCから選択する
+ */
+ "emojiInputAreaList2": string;
+ /**
+ * このリンクをクリックしてドライブから選択する
+ */
+ "emojiInputAreaList3": string;
+ /**
+ * 確認
+ */
+ "confirmRegisterEmojisTitle": string;
+ /**
+ * リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)
+ */
+ "confirmRegisterEmojisDescription": ParameterizedString<"count">;
+ /**
+ * 確認
+ */
+ "confirmClearEmojisTitle": string;
+ /**
+ * 編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?
+ */
+ "confirmClearEmojisDescription": string;
+ /**
+ * 確認
+ */
+ "confirmUploadEmojisTitle": string;
+ /**
+ * ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?
+ */
+ "confirmUploadEmojisDescription": ParameterizedString<"count">;
+ };
+ };
+ };
"_embedCodeGen": {
/**
* 埋め込みコードをカスタマイズ
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 2a8fd94522..638f2a69c3 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -5,6 +5,7 @@ introMisskey: "ようこそ!Misskeyは、オープンソースの分散型マ
poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォーム<b>Misskey</b>のサーバーのひとつです。"
monthAndDay: "{month}月 {day}日"
search: "検索"
+reset: "リセット"
notifications: "通知"
username: "ユーザー名"
password: "パスワード"
@@ -2808,6 +2809,69 @@ _contextMenu:
appWithShift: "Shiftキーでアプリケーション"
native: "ブラウザのUI"
+_gridComponent:
+ _error:
+ requiredValue: "この値は必須項目です"
+ columnTypeNotSupport: "正規表現によるバリデーションはtype:textのカラムのみサポートします。"
+ patternNotMatch: "この値は{pattern}のパターンに一致しません"
+ notUnique: "この値は一意である必要があります"
+
+_roleSelectDialog:
+ notSelected: "選択されていません"
+
+_customEmojisManager:
+ _gridCommon:
+ copySelectionRows: "選択行をコピー"
+ copySelectionRanges: "選択範囲をコピー"
+ deleteSelectionRows: "選択行を削除"
+ deleteSelectionRanges: "選択範囲の行を削除"
+ searchSettings: "検索設定"
+ searchSettingCaption: "検索条件を詳細に設定します。"
+ sortOrder: "並び順"
+ registrationLogs: "登録ログ"
+ registrationLogsCaption: "絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。"
+ alertEmojisRegisterFailedTitle: "エラー"
+ alertEmojisRegisterFailedDescription: "絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。"
+ _logs:
+ showSuccessLogSwitch: "成功ログを表示"
+ failureLogNothing: "失敗ログはありません。"
+ logNothing: "ログはありません。"
+ _remote:
+ importSelectionRows: "選択行をインポート"
+ importSelectionRangesRows: "選択範囲の行をインポート"
+ importEmojisButton: "チェックされた絵文字をインポート"
+ confirmImportEmojisTitle: "絵文字のインポート"
+ confirmImportEmojisDescription: "リモートから受信した{count}個の絵文字のインポートを行います。絵文字のライセンスに十分な注意を払ってください。実行しますか?"
+ _local:
+ tabTitleList: "登録済み絵文字一覧"
+ tabTitleRegister: "絵文字の登録"
+ _list:
+ emojisNothing: "登録された絵文字はありません。"
+ markAsDeleteTargetRows: "選択行を削除対象にする"
+ markAsDeleteTargetRanges: "選択範囲の行を削除対象にする"
+ alertUpdateEmojisNothingDescription: "変更された絵文字はありません。"
+ alertDeleteEmojisNothingDescription: "削除対象の絵文字はありません。"
+ confirmUpdateEmojisTitle: "確認"
+ confirmUpdateEmojisDescription: "{count}個の絵文字を更新します。実行しますか?"
+ confirmDeleteEmojisTitle: "確認"
+ confirmDeleteEmojisDescription: "チェックがつけられた{count}個の絵文字を削除します。実行しますか?"
+ dialogSelectRoleTitle: "絵文字に設定されたロールで検索"
+ _register:
+ uploadSettingTitle: "アップロード設定"
+ uploadSettingDescription: "この画面で絵文字アップロードを行う際の動作を設定できます。"
+ directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する"
+ directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。"
+ emojiInputAreaCaption: "いずれかの方法で登録する絵文字を選択してください。"
+ emojiInputAreaList1: "この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ"
+ emojiInputAreaList2: "このリンクをクリックしてPCから選択する"
+ emojiInputAreaList3: "このリンクをクリックしてドライブから選択する"
+ confirmRegisterEmojisTitle: "確認"
+ confirmRegisterEmojisDescription: "リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)"
+ confirmClearEmojisTitle: "確認"
+ confirmClearEmojisDescription: "編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?"
+ confirmUploadEmojisTitle: "確認"
+ confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?"
+
_embedCodeGen:
title: "埋め込みコードをカスタマイズ"
header: "ヘッダーを表示"
diff --git a/packages/backend/migration/1709126576000-optimize-emoji-index.js b/packages/backend/migration/1709126576000-optimize-emoji-index.js
new file mode 100644
index 0000000000..e4184895d0
--- /dev/null
+++ b/packages/backend/migration/1709126576000-optimize-emoji-index.js
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class OptimizeEmojiIndex1709126576000 {
+ name = 'OptimizeEmojiIndex1709126576000'
+
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE INDEX "IDX_EMOJI_ROLE_IDS" ON "emoji" using gin ("roleIdsThatCanBeUsedThisEmojiAsReaction")`)
+ await queryRunner.query(`CREATE INDEX "IDX_EMOJI_CATEGORY" ON "emoji" ("category")`)
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`DROP INDEX "IDX_EMOJI_CATEGORY"`)
+ await queryRunner.query(`DROP INDEX "IDX_EMOJI_ROLE_IDS"`)
+ }
+}
diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts
index e3a61861f4..1ca0397206 100644
--- a/packages/backend/src/const.ts
+++ b/packages/backend/src/const.ts
@@ -26,6 +26,18 @@ export const DB_MAX_NOTE_TEXT_LENGTH = 8192;
export const DB_MAX_IMAGE_COMMENT_LENGTH = 512;
//#endregion
+export const FILE_TYPE_IMAGE = [
+ 'image/png',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/webp',
+ 'image/avif',
+ 'image/apng',
+ 'image/bmp',
+ 'image/tiff',
+ 'image/x-icon',
+];
+
// ブラウザで直接表示することを許可するファイルの種類のリスト
// ここに含まれないものは application/octet-stream としてレスポンスされる
// SVGはXSSを生むので許可しない
diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts
index 4566113449..da71a5de6f 100644
--- a/packages/backend/src/core/CustomEmojiService.ts
+++ b/packages/backend/src/core/CustomEmojiService.ts
@@ -4,24 +4,59 @@
*/
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
-import { In, IsNull } from 'typeorm';
import * as Redis from 'ioredis';
-import { DI } from '@/di-symbols.js';
-import { IdService } from '@/core/IdService.js';
+import { In, IsNull } from 'typeorm';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
-import type { MiDriveFile } from '@/models/DriveFile.js';
-import type { MiEmoji } from '@/models/Emoji.js';
-import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
+import { IdService } from '@/core/IdService.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
+import { DI } from '@/di-symbols.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
-import { UtilityService } from '@/core/UtilityService.js';
-import { query } from '@/misc/prelude/url.js';
+import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
+import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
+import type { MiEmoji } from '@/models/Emoji.js';
import type { Serialized } from '@/types.js';
-import { ModerationLogService } from '@/core/ModerationLogService.js';
const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/;
+export const fetchEmojisHostTypes = [
+ 'local',
+ 'remote',
+ 'all',
+] as const;
+export type FetchEmojisHostTypes = typeof fetchEmojisHostTypes[number];
+export const fetchEmojisSortKeys = [
+ '+id',
+ '-id',
+ '+updatedAt',
+ '-updatedAt',
+ '+name',
+ '-name',
+ '+host',
+ '-host',
+ '+uri',
+ '-uri',
+ '+publicUrl',
+ '-publicUrl',
+ '+type',
+ '-type',
+ '+aliases',
+ '-aliases',
+ '+category',
+ '-category',
+ '+license',
+ '-license',
+ '+isSensitive',
+ '-isSensitive',
+ '+localOnly',
+ '-localOnly',
+ '+roleIdsThatCanBeUsedThisEmojiAsReaction',
+ '-roleIdsThatCanBeUsedThisEmojiAsReaction',
+] as const;
+export type FetchEmojisSortKeys = typeof fetchEmojisSortKeys[number];
+
@Injectable()
export class CustomEmojiService implements OnApplicationShutdown {
private emojisCache: MemoryKVCache<MiEmoji | null>;
@@ -30,10 +65,8 @@ export class CustomEmojiService implements OnApplicationShutdown {
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
-
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
-
private utilityService: UtilityService,
private idService: IdService,
private emojiEntityService: EmojiEntityService,
@@ -58,7 +91,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
@bindThis
public async add(data: {
- driveFile: MiDriveFile;
+ originalUrl: string;
+ publicUrl: string;
+ fileType: string;
name: string;
category: string | null;
aliases: string[];
@@ -75,9 +110,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
category: data.category,
host: data.host,
aliases: data.aliases,
- originalUrl: data.driveFile.url,
- publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
- type: data.driveFile.webpublicType ?? data.driveFile.type,
+ originalUrl: data.originalUrl,
+ publicUrl: data.publicUrl,
+ type: data.fileType,
license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
@@ -105,8 +140,10 @@ export class CustomEmojiService implements OnApplicationShutdown {
@bindThis
public async update(data: (
{ id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], }
- ) & {
- driveFile?: MiDriveFile;
+ ) & {
+ originalUrl?: string;
+ publicUrl?: string;
+ fileType?: string;
category?: string | null;
aliases?: string[];
license?: string | null;
@@ -139,9 +176,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
license: data.license,
isSensitive: data.isSensitive,
localOnly: data.localOnly,
- originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
- publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined,
- type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,
+ originalUrl: data.originalUrl,
+ publicUrl: data.publicUrl,
+ type: data.fileType,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
});
@@ -308,7 +345,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
@bindThis
private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
- // クエリに使うホスト
+ // クエリに使うホスト
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定
@@ -415,6 +452,151 @@ export class CustomEmojiService implements OnApplicationShutdown {
}
@bindThis
+ public async fetchEmojis(
+ params?: {
+ query?: {
+ updatedAtFrom?: string;
+ updatedAtTo?: string;
+ name?: string;
+ host?: string;
+ uri?: string;
+ publicUrl?: string;
+ type?: string;
+ aliases?: string;
+ category?: string;
+ license?: string;
+ isSensitive?: boolean;
+ localOnly?: boolean;
+ hostType?: FetchEmojisHostTypes;
+ roleIds?: string[];
+ },
+ sinceId?: string;
+ untilId?: string;
+ },
+ opts?: {
+ limit?: number;
+ page?: number;
+ sortKeys?: FetchEmojisSortKeys[]
+ },
+ ) {
+ function multipleWordsToQuery(words: string) {
+ return words.split(/\s/).filter(x => x.length > 0).map(x => `%${sqlLikeEscape(x)}%`);
+ }
+
+ const builder = this.emojisRepository.createQueryBuilder('emoji');
+ if (params?.query) {
+ const q = params.query;
+ if (q.updatedAtFrom) {
+ // noIndexScan
+ builder.andWhere('CAST(emoji.updatedAt AS DATE) >= :updateAtFrom', { updateAtFrom: q.updatedAtFrom });
+ }
+ if (q.updatedAtTo) {
+ // noIndexScan
+ builder.andWhere('CAST(emoji.updatedAt AS DATE) <= :updateAtTo', { updateAtTo: q.updatedAtTo });
+ }
+ if (q.name) {
+ builder.andWhere('emoji.name ~~ ANY(ARRAY[:...name])', { name: multipleWordsToQuery(q.name) });
+ }
+
+ switch (true) {
+ case q.hostType === 'local': {
+ builder.andWhere('emoji.host IS NULL');
+ break;
+ }
+ case q.hostType === 'remote': {
+ if (q.host) {
+ // noIndexScan
+ builder.andWhere('emoji.host ~~ ANY(ARRAY[:...host])', { host: multipleWordsToQuery(q.host) });
+ } else {
+ builder.andWhere('emoji.host IS NOT NULL');
+ }
+ break;
+ }
+ }
+
+ if (q.uri) {
+ // noIndexScan
+ builder.andWhere('emoji.uri ~~ ANY(ARRAY[:...uri])', { uri: multipleWordsToQuery(q.uri) });
+ }
+ if (q.publicUrl) {
+ // noIndexScan
+ builder.andWhere('emoji.publicUrl ~~ ANY(ARRAY[:...publicUrl])', { publicUrl: multipleWordsToQuery(q.publicUrl) });
+ }
+ if (q.type) {
+ // noIndexScan
+ builder.andWhere('emoji.type ~~ ANY(ARRAY[:...type])', { type: multipleWordsToQuery(q.type) });
+ }
+ if (q.aliases) {
+ // noIndexScan
+ const subQueryBuilder = builder.subQuery()
+ .select('COUNT(0)', 'count')
+ .from(
+ sq2 => sq2
+ .select('unnest(subEmoji.aliases)', 'alias')
+ .addSelect('subEmoji.id', 'id')
+ .from('emoji', 'subEmoji'),
+ 'aliasTable',
+ )
+ .where('"emoji"."id" = "aliasTable"."id"')
+ .andWhere('"aliasTable"."alias" ~~ ANY(ARRAY[:...aliases])', { aliases: multipleWordsToQuery(q.aliases) });
+
+ builder.andWhere(`(${subQueryBuilder.getQuery()}) > 0`);
+ }
+ if (q.category) {
+ builder.andWhere('emoji.category ~~ ANY(ARRAY[:...category])', { category: multipleWordsToQuery(q.category) });
+ }
+ if (q.license) {
+ // noIndexScan
+ builder.andWhere('emoji.license ~~ ANY(ARRAY[:...license])', { license: multipleWordsToQuery(q.license) });
+ }
+ if (q.isSensitive != null) {
+ // noIndexScan
+ builder.andWhere('emoji.isSensitive = :isSensitive', { isSensitive: q.isSensitive });
+ }
+ if (q.localOnly != null) {
+ // noIndexScan
+ builder.andWhere('emoji.localOnly = :localOnly', { localOnly: q.localOnly });
+ }
+ if (q.roleIds && q.roleIds.length > 0) {
+ builder.andWhere('emoji.roleIdsThatCanBeUsedThisEmojiAsReaction && ARRAY[:...roleIds]::VARCHAR[]', { roleIds: q.roleIds });
+ }
+ }
+
+ if (params?.sinceId) {
+ builder.andWhere('emoji.id > :sinceId', { sinceId: params.sinceId });
+ }
+ if (params?.untilId) {
+ builder.andWhere('emoji.id < :untilId', { untilId: params.untilId });
+ }
+
+ if (opts?.sortKeys && opts.sortKeys.length > 0) {
+ for (const sortKey of opts.sortKeys) {
+ const direction = sortKey.startsWith('-') ? 'DESC' : 'ASC';
+ const key = sortKey.replace(/^[+-]/, '');
+ builder.addOrderBy(`emoji.${key}`, direction);
+ }
+ } else {
+ builder.addOrderBy('emoji.id', 'DESC');
+ }
+
+ const limit = opts?.limit ?? 10;
+ if (opts?.page) {
+ builder.skip((opts.page - 1) * limit);
+ }
+
+ builder.take(limit);
+
+ const [emojis, count] = await builder.getManyAndCount();
+
+ return {
+ emojis,
+ count: (count > limit ? emojis.length : count),
+ allCount: count,
+ allPages: Math.ceil(count / limit),
+ };
+ }
+
+ @bindThis
public dispose(): void {
this.emojisCache.dispose();
}
diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts
index 841bd731c0..490d3f2511 100644
--- a/packages/backend/src/core/entities/EmojiEntityService.ts
+++ b/packages/backend/src/core/entities/EmojiEntityService.ts
@@ -4,10 +4,10 @@
*/
import { Inject, Injectable } from '@nestjs/common';
+import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { EmojisRepository } from '@/models/_.js';
+import type { EmojisRepository, MiRole, RolesRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
-import type { } from '@/models/Blocking.js';
import type { MiEmoji } from '@/models/Emoji.js';
import { bindThis } from '@/decorators.js';
@@ -16,6 +16,8 @@ export class EmojiEntityService {
constructor(
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
+ @Inject(DI.rolesRepository)
+ private rolesRepository: RolesRepository,
) {
}
@@ -68,8 +70,90 @@ export class EmojiEntityService {
@bindThis
public packDetailedMany(
emojis: any[],
- ) {
+ ): Promise<Packed<'EmojiDetailed'>[]> {
return Promise.all(emojis.map(x => this.packDetailed(x)));
}
+
+ @bindThis
+ public async packDetailedAdmin(
+ src: MiEmoji['id'] | MiEmoji,
+ hint?: {
+ roles?: Map<MiRole['id'], MiRole>
+ },
+ ): Promise<Packed<'EmojiDetailedAdmin'>> {
+ const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
+
+ const roles = Array.of<MiRole>();
+ if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0) {
+ if (hint?.roles) {
+ const hintRoles = hint.roles;
+ roles.push(
+ ...emoji.roleIdsThatCanBeUsedThisEmojiAsReaction
+ .filter(x => hintRoles.has(x))
+ .map(x => hintRoles.get(x)!),
+ );
+ } else {
+ roles.push(
+ ...await this.rolesRepository.findBy({ id: In(emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) }),
+ );
+ }
+
+ roles.sort((a, b) => {
+ if (a.displayOrder !== b.displayOrder) {
+ return b.displayOrder - a.displayOrder;
+ }
+
+ return a.id.localeCompare(b.id);
+ });
+ }
+
+ return {
+ id: emoji.id,
+ updatedAt: emoji.updatedAt?.toISOString() ?? null,
+ name: emoji.name,
+ host: emoji.host,
+ uri: emoji.uri,
+ type: emoji.type,
+ aliases: emoji.aliases,
+ category: emoji.category,
+ publicUrl: emoji.publicUrl,
+ originalUrl: emoji.originalUrl,
+ license: emoji.license,
+ localOnly: emoji.localOnly,
+ isSensitive: emoji.isSensitive,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: roles.map(it => ({ id: it.id, name: it.name })),
+ };
+ }
+
+ @bindThis
+ public async packDetailedAdminMany(
+ emojis: MiEmoji['id'][] | MiEmoji[],
+ hint?: {
+ roles?: Map<MiRole['id'], MiRole>
+ },
+ ): Promise<Packed<'EmojiDetailedAdmin'>[]> {
+ // IDのみの要素をピックアップし、DBからレコードを取り出して他の値を補完する
+ const emojiEntities = emojis.filter(x => typeof x === 'object') as MiEmoji[];
+ const emojiIdOnlyList = emojis.filter(x => typeof x === 'string') as string[];
+ if (emojiIdOnlyList.length > 0) {
+ emojiEntities.push(...await this.emojisRepository.findBy({ id: In(emojiIdOnlyList) }));
+ }
+
+ // 特定ロール専用の絵文字である場合、そのロール情報をあらかじめまとめて取得しておく(pack側で都度取得も出来るが負荷が高いので)
+ let hintRoles: Map<MiRole['id'], MiRole>;
+ if (hint?.roles) {
+ hintRoles = hint.roles;
+ } else {
+ const roles = Array.of<MiRole>();
+ const roleIds = [...new Set(emojiEntities.flatMap(x => x.roleIdsThatCanBeUsedThisEmojiAsReaction))];
+ if (roleIds.length > 0) {
+ roles.push(...await this.rolesRepository.findBy({ id: In(roleIds) }));
+ }
+
+ hintRoles = new Map(roles.map(x => [x.id, x]));
+ }
+
+ return Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles })));
+ }
}
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 040e36228c..f612591eda 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -33,7 +33,11 @@ import { packedClipSchema } from '@/models/json-schema/clip.js';
import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js';
import { packedQueueCountSchema } from '@/models/json-schema/queue.js';
import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js';
-import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js';
+import {
+ packedEmojiDetailedAdminSchema,
+ packedEmojiDetailedSchema,
+ packedEmojiSimpleSchema,
+} from '@/models/json-schema/emoji.js';
import { packedFlashSchema } from '@/models/json-schema/flash.js';
import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
import { packedSigninSchema } from '@/models/json-schema/signin.js';
@@ -95,6 +99,7 @@ export const refs = {
GalleryPost: packedGalleryPostSchema,
EmojiSimple: packedEmojiSimpleSchema,
EmojiDetailed: packedEmojiDetailedSchema,
+ EmojiDetailedAdmin: packedEmojiDetailedAdminSchema,
Flash: packedFlashSchema,
Signin: packedSigninSchema,
RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema,
diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts
index 62686ad5ae..3cd263fa37 100644
--- a/packages/backend/src/models/json-schema/emoji.ts
+++ b/packages/backend/src/models/json-schema/emoji.ts
@@ -104,3 +104,86 @@ export const packedEmojiDetailedSchema = {
},
},
} as const;
+
+export const packedEmojiDetailedAdminSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ format: 'id',
+ optional: false, nullable: false,
+ },
+ updatedAt: {
+ type: 'string',
+ format: 'date-time',
+ optional: false, nullable: true,
+ },
+ name: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ host: {
+ type: 'string',
+ optional: false, nullable: true,
+ description: 'The local host is represented with `null`.',
+ },
+ publicUrl: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ originalUrl: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ uri: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ type: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ aliases: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'string',
+ format: 'id',
+ optional: false, nullable: false,
+ },
+ },
+ category: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ license: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ localOnly: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ isSensitive: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ roleIdsThatCanBeUsedThisEmojiAsReaction: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ format: 'misskey:id',
+ optional: false, nullable: false,
+ },
+ name: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ },
+ },
+ },
+ },
+} as const;
diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts
index 9e1b8fee70..725e1c8ba2 100644
--- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts
@@ -87,6 +87,7 @@ export class ImportCustomEmojisProcessorService {
await this.emojisRepository.delete({
name: emojiInfo.name,
});
+
try {
const driveFile = await this.driveService.addFile({
user: null,
@@ -95,11 +96,13 @@ export class ImportCustomEmojisProcessorService {
force: true,
});
await this.customEmojiService.add({
+ originalUrl: driveFile.url,
+ publicUrl: driveFile.webpublicUrl ?? driveFile.url,
+ fileType: driveFile.webpublicType ?? driveFile.type,
name: emojiInfo.name,
category: emojiInfo.category,
host: null,
aliases: emojiInfo.aliases,
- driveFile,
license: emojiInfo.license,
isSensitive: emojiInfo.isSensitive,
localOnly: emojiInfo.localOnly,
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index c2462d8b3d..87c9841fd0 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -50,6 +50,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al
import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js';
import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js';
import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js';
+import * as ep___v2_admin_emoji_list from './endpoints/v2/admin/emoji/list.js';
import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js';
import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js';
import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js';
@@ -440,6 +441,7 @@ const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-ali
const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default };
const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default };
const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default };
+const $admin_emoji_v2_list: Provider = { provide: 'ep:v2/admin/emoji/list', useClass: ep___v2_admin_emoji_list.default };
const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default };
const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default };
const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default };
@@ -834,6 +836,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_emoji_setCategoryBulk,
$admin_emoji_setLicenseBulk,
$admin_emoji_update,
+ $admin_emoji_v2_list,
$admin_federation_deleteAllFiles,
$admin_federation_refreshRemoteInstanceMetadata,
$admin_federation_removeAllFollowing,
@@ -1222,6 +1225,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_emoji_setCategoryBulk,
$admin_emoji_setLicenseBulk,
$admin_emoji_update,
+ $admin_emoji_v2_list,
$admin_federation_deleteAllFiles,
$admin_federation_refreshRemoteInstanceMetadata,
$admin_federation_removeAllFollowing,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 86728ef381..4d0c45cc91 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -55,6 +55,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al
import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js';
import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js';
import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js';
+import * as ep___v2_admin_emoji_list from './endpoints/v2/admin/emoji/list.js';
import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js';
import * as ep___admin_federation_refreshRemoteInstanceMetadata
from './endpoints/admin/federation/refresh-remote-instance-metadata.js';
@@ -444,6 +445,7 @@ const eps = [
['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk],
['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk],
['admin/emoji/update', ep___admin_emoji_update],
+ ['v2/admin/emoji/list', ep___v2_admin_emoji_list],
['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles],
['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata],
['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing],
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
index 796f273330..53256565f6 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
@@ -9,6 +9,7 @@ import type { DriveFilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
+import { FILE_TYPE_IMAGE } from '@/const.js';
import { ApiError } from '../../../error.js';
export const meta = {
@@ -24,6 +25,11 @@ export const meta = {
code: 'NO_SUCH_FILE',
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
},
+ unsupportedFileType: {
+ message: 'Unsupported file type.',
+ code: 'UNSUPPORTED_FILE_TYPE',
+ id: 'f7599d96-8750-af68-1633-9575d625c1a7',
+ },
duplicateName: {
message: 'Duplicate name.',
code: 'DUPLICATE_NAME',
@@ -47,15 +53,21 @@ export const paramDef = {
nullable: true,
description: 'Use `null` to reset the category.',
},
- aliases: { type: 'array', items: {
- type: 'string',
- } },
+ aliases: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ },
license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
- roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
- type: 'string',
- } },
+ roleIdsThatCanBeUsedThisEmojiAsReaction: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ },
},
required: ['name', 'fileId'],
} as const;
@@ -67,9 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
-
private customEmojiService: CustomEmojiService,
-
private emojiEntityService: EmojiEntityService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -77,9 +87,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
+ if (!FILE_TYPE_IMAGE.includes(driveFile.type)) throw new ApiError(meta.errors.unsupportedFileType);
const emoji = await this.customEmojiService.add({
- driveFile,
+ originalUrl: driveFile.url,
+ publicUrl: driveFile.webpublicUrl ?? driveFile.url,
+ fileType: driveFile.webpublicType ?? driveFile.type,
name: ps.name,
category: ps.category ?? null,
aliases: ps.aliases ?? [],
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
index 975f892df9..87b58ff6f6 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts
@@ -86,7 +86,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
const addedEmoji = await this.customEmojiService.add({
- driveFile,
+ originalUrl: driveFile.url,
+ publicUrl: driveFile.webpublicUrl ?? driveFile.url,
+ fileType: driveFile.webpublicType ?? driveFile.type,
name: emoji.name,
category: emoji.category,
aliases: emoji.aliases,
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
index 212cba5c5d..e3aaa051c1 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
@@ -79,13 +79,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
// JSON schemeのanyOfの型変換がうまくいっていないらしい
- const required = { id: ps.id, name: ps.name } as
+ const required = { id: ps.id, name: ps.name } as
| { id: MiEmoji['id']; name?: string }
| { id?: MiEmoji['id']; name: string };
const error = await this.customEmojiService.update({
...required,
- driveFile,
+ originalUrl: driveFile != null ? driveFile.url : undefined,
+ publicUrl: driveFile != null ? (driveFile.webpublicUrl ?? driveFile.url) : undefined,
+ fileType: driveFile != null ? (driveFile.webpublicType ?? driveFile.type) : undefined,
category: ps.category,
aliases: ps.aliases,
license: ps.license,
diff --git a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts
new file mode 100644
index 0000000000..9426318e34
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts
@@ -0,0 +1,126 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
+import { CustomEmojiService, fetchEmojisHostTypes, fetchEmojisSortKeys } from '@/core/CustomEmojiService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireRolePolicy: 'canManageCustomEmojis',
+ kind: 'read:admin:emoji',
+
+ res: {
+ type: 'object',
+ properties: {
+ emojis: {
+ type: 'array',
+ items: {
+ type: 'object',
+ ref: 'EmojiDetailedAdmin',
+ },
+ },
+ count: { type: 'integer' },
+ allCount: { type: 'integer' },
+ allPages: { type: 'integer' },
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'object',
+ nullable: true,
+ properties: {
+ updatedAtFrom: { type: 'string' },
+ updatedAtTo: { type: 'string' },
+ name: { type: 'string' },
+ host: { type: 'string' },
+ uri: { type: 'string' },
+ publicUrl: { type: 'string' },
+ originalUrl: { type: 'string' },
+ type: { type: 'string' },
+ aliases: { type: 'string' },
+ category: { type: 'string' },
+ license: { type: 'string' },
+ isSensitive: { type: 'boolean' },
+ localOnly: { type: 'boolean' },
+ hostType: {
+ type: 'string',
+ enum: fetchEmojisHostTypes,
+ default: 'all',
+ },
+ roleIds: {
+ type: 'array',
+ items: { type: 'string', format: 'misskey:id' },
+ },
+ },
+ },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ page: { type: 'integer' },
+ sortKeys: {
+ type: 'array',
+ default: ['-id'],
+ items: {
+ type: 'string',
+ enum: fetchEmojisSortKeys,
+ },
+ },
+ },
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private customEmojiService: CustomEmojiService,
+ private emojiEntityService: EmojiEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const q = ps.query;
+ const result = await this.customEmojiService.fetchEmojis(
+ {
+ query: {
+ updatedAtFrom: q?.updatedAtFrom,
+ updatedAtTo: q?.updatedAtTo,
+ name: q?.name,
+ host: q?.host,
+ uri: q?.uri,
+ publicUrl: q?.publicUrl,
+ type: q?.type,
+ aliases: q?.aliases,
+ category: q?.category,
+ license: q?.license,
+ isSensitive: q?.isSensitive,
+ localOnly: q?.localOnly,
+ hostType: q?.hostType,
+ roleIds: q?.roleIds,
+ },
+ sinceId: ps.sinceId,
+ untilId: ps.untilId,
+ },
+ {
+ limit: ps.limit,
+ page: ps.page,
+ sortKeys: ps.sortKeys,
+ },
+ );
+
+ return {
+ emojis: await this.emojiEntityService.packDetailedAdminMany(result.emojis),
+ count: result.count,
+ allCount: result.allCount,
+ allPages: result.allPages,
+ };
+ });
+ }
+}
diff --git a/packages/backend/test/unit/CustomEmojiService.ts b/packages/backend/test/unit/CustomEmojiService.ts
new file mode 100644
index 0000000000..10b687c6a0
--- /dev/null
+++ b/packages/backend/test/unit/CustomEmojiService.ts
@@ -0,0 +1,817 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { afterEach, beforeAll, describe, test } from '@jest/globals';
+import { Test, TestingModule } from '@nestjs/testing';
+import { CustomEmojiService } from '@/core/CustomEmojiService.js';
+import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { IdService } from '@/core/IdService.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { UtilityService } from '@/core/UtilityService.js';
+import { DI } from '@/di-symbols.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { EmojisRepository } from '@/models/_.js';
+import { MiEmoji } from '@/models/Emoji.js';
+
+describe('CustomEmojiService', () => {
+ let app: TestingModule;
+ let service: CustomEmojiService;
+
+ let emojisRepository: EmojisRepository;
+ let idService: IdService;
+
+ beforeAll(async () => {
+ app = await Test
+ .createTestingModule({
+ imports: [
+ GlobalModule,
+ ],
+ providers: [
+ CustomEmojiService,
+ UtilityService,
+ IdService,
+ EmojiEntityService,
+ ModerationLogService,
+ GlobalEventService,
+ ],
+ })
+ .compile();
+ app.enableShutdownHooks();
+
+ service = app.get<CustomEmojiService>(CustomEmojiService);
+ emojisRepository = app.get<EmojisRepository>(DI.emojisRepository);
+ idService = app.get<IdService>(IdService);
+ });
+
+ describe('fetchEmojis', () => {
+ async function insert(data: Partial<MiEmoji>[]) {
+ for (const d of data) {
+ const id = idService.gen();
+ await emojisRepository.insert({
+ id: id,
+ updatedAt: new Date(),
+ ...d,
+ });
+ }
+ }
+
+ function call(params: Parameters<CustomEmojiService['fetchEmojis']>['0']) {
+ return service.fetchEmojis(
+ params,
+ {
+ // テスト向けに
+ sortKeys: ['+id'],
+ },
+ );
+ }
+
+ function defaultData(suffix: string, override?: Partial<MiEmoji>): Partial<MiEmoji> {
+ return {
+ name: `emoji${suffix}`,
+ host: null,
+ category: 'default',
+ originalUrl: `https://example.com/emoji${suffix}.png`,
+ publicUrl: `https://example.com/emoji${suffix}.png`,
+ type: 'image/png',
+ aliases: [`emoji${suffix}`],
+ license: 'CC0',
+ isSensitive: false,
+ localOnly: false,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: [],
+ ...override,
+ };
+ }
+
+ afterEach(async () => {
+ await emojisRepository.delete({});
+ });
+
+ describe('単独', () => {
+ test('updatedAtFrom', async () => {
+ await insert([
+ defaultData('001', { updatedAt: new Date('2021-01-01T00:00:00.000Z') }),
+ defaultData('002', { updatedAt: new Date('2021-01-02T00:00:00.000Z') }),
+ defaultData('003', { updatedAt: new Date('2021-01-03T00:00:00.000Z') }),
+ ]);
+
+ const actual = await call({
+ query: {
+ updatedAtFrom: '2021-01-02T00:00:00.000Z',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji002');
+ expect(actual.emojis[1].name).toBe('emoji003');
+ });
+
+ test('updatedAtTo', async () => {
+ await insert([
+ defaultData('001', { updatedAt: new Date('2021-01-01T00:00:00.000Z') }),
+ defaultData('002', { updatedAt: new Date('2021-01-02T00:00:00.000Z') }),
+ defaultData('003', { updatedAt: new Date('2021-01-03T00:00:00.000Z') }),
+ ]);
+
+ const actual = await call({
+ query: {
+ updatedAtTo: '2021-01-02T00:00:00.000Z',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji002');
+ });
+
+ describe('name', () => {
+ test('single', async () => {
+ await insert([
+ defaultData('001'),
+ defaultData('002'),
+ ]);
+
+ const actual = await call({
+ query: {
+ name: 'emoji001',
+ },
+ });
+
+ expect(actual.allCount).toBe(1);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ });
+
+ test('multi', async () => {
+ await insert([
+ defaultData('001'),
+ defaultData('002'),
+ ]);
+
+ const actual = await call({
+ query: {
+ name: 'emoji001 emoji002',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji002');
+ });
+
+ test('keyword', async () => {
+ await insert([
+ defaultData('001'),
+ defaultData('002'),
+ defaultData('003', { name: 'em003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ name: 'oji',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji002');
+ });
+
+ test('escape', async () => {
+ await insert([
+ defaultData('001'),
+ ]);
+
+ const actual = await call({
+ query: {
+ name: '%',
+ },
+ });
+
+ expect(actual.allCount).toBe(0);
+ });
+ });
+
+ describe('host', () => {
+ test('single', async () => {
+ await insert([
+ defaultData('001', { host: 'example.com' }),
+ defaultData('002', { host: 'example.com' }),
+ defaultData('003', { host: '1.example.com' }),
+ defaultData('004', { host: '2.example.com' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ host: 'example.com',
+ hostType: 'remote',
+ },
+ });
+
+ expect(actual.allCount).toBe(4);
+ });
+
+ test('multi', async () => {
+ await insert([
+ defaultData('001', { host: 'example.com' }),
+ defaultData('002', { host: 'example.com' }),
+ defaultData('003', { host: '1.example.com' }),
+ defaultData('004', { host: '2.example.com' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ host: '1.example.com 2.example.com',
+ hostType: 'remote',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji003');
+ expect(actual.emojis[1].name).toBe('emoji004');
+ });
+
+ test('keyword', async () => {
+ await insert([
+ defaultData('001', { host: 'example.com' }),
+ defaultData('002', { host: 'example.com' }),
+ defaultData('003', { host: '1.example.com' }),
+ defaultData('004', { host: '2.example.com' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ host: 'example',
+ hostType: 'remote',
+ },
+ });
+
+ expect(actual.allCount).toBe(4);
+ });
+
+ test('escape', async () => {
+ await insert([
+ defaultData('001', { host: 'example.com' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ host: '%',
+ hostType: 'remote',
+ },
+ });
+
+ expect(actual.allCount).toBe(0);
+ });
+ });
+
+ describe('uri', () => {
+ test('single', async () => {
+ await insert([
+ defaultData('001', { uri: 'uri001' }),
+ defaultData('002', { uri: 'uri002' }),
+ defaultData('003', { uri: 'uri003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ uri: 'uri002',
+ },
+ });
+
+ expect(actual.allCount).toBe(1);
+ expect(actual.emojis[0].name).toBe('emoji002');
+ });
+
+ test('multi', async () => {
+ await insert([
+ defaultData('001', { uri: 'uri001' }),
+ defaultData('002', { uri: 'uri002' }),
+ defaultData('003', { uri: 'uri003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ uri: 'uri001 uri003',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji003');
+ });
+
+ test('keyword', async () => {
+ await insert([
+ defaultData('001', { uri: 'uri001' }),
+ defaultData('002', { uri: 'uri002' }),
+ defaultData('003', { uri: 'uri003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ uri: 'ri',
+ },
+ });
+
+ expect(actual.allCount).toBe(3);
+ });
+
+ test('escape', async () => {
+ await insert([
+ defaultData('001', { uri: 'uri001' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ uri: '%',
+ },
+ });
+
+ expect(actual.allCount).toBe(0);
+ });
+ });
+
+ describe('publicUrl', () => {
+ test('single', async () => {
+ await insert([
+ defaultData('001', { publicUrl: 'publicUrl001' }),
+ defaultData('002', { publicUrl: 'publicUrl002' }),
+ defaultData('003', { publicUrl: 'publicUrl003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ publicUrl: 'publicUrl002',
+ },
+ });
+
+ expect(actual.allCount).toBe(1);
+ expect(actual.emojis[0].name).toBe('emoji002');
+ });
+
+ test('multi', async () => {
+ await insert([
+ defaultData('001', { publicUrl: 'publicUrl001' }),
+ defaultData('002', { publicUrl: 'publicUrl002' }),
+ defaultData('003', { publicUrl: 'publicUrl003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ publicUrl: 'publicUrl001 publicUrl003',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji003');
+ });
+
+ test('keyword', async () => {
+ await insert([
+ defaultData('001', { publicUrl: 'publicUrl001' }),
+ defaultData('002', { publicUrl: 'publicUrl002' }),
+ defaultData('003', { publicUrl: 'publicUrl003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ publicUrl: 'Url',
+ },
+ });
+
+ expect(actual.allCount).toBe(3);
+ });
+
+ test('escape', async () => {
+ await insert([
+ defaultData('001', { publicUrl: 'publicUrl001' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ publicUrl: '%',
+ },
+ });
+
+ expect(actual.allCount).toBe(0);
+ });
+ });
+
+ describe('type', () => {
+ test('single', async () => {
+ await insert([
+ defaultData('001', { type: 'type001' }),
+ defaultData('002', { type: 'type002' }),
+ defaultData('003', { type: 'type003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ type: 'type002',
+ },
+ });
+
+ expect(actual.allCount).toBe(1);
+ expect(actual.emojis[0].name).toBe('emoji002');
+ });
+
+ test('multi', async () => {
+ await insert([
+ defaultData('001', { type: 'type001' }),
+ defaultData('002', { type: 'type002' }),
+ defaultData('003', { type: 'type003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ type: 'type001 type003',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji003');
+ });
+
+ test('keyword', async () => {
+ await insert([
+ defaultData('001', { type: 'type001' }),
+ defaultData('002', { type: 'type002' }),
+ defaultData('003', { type: 'type003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ type: 'pe',
+ },
+ });
+
+ expect(actual.allCount).toBe(3);
+ });
+
+ test('escape', async () => {
+ await insert([
+ defaultData('001', { type: 'type001' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ type: '%',
+ },
+ });
+
+ expect(actual.allCount).toBe(0);
+ });
+ });
+
+ describe('aliases', () => {
+ test('single', async () => {
+ await insert([
+ defaultData('001', { aliases: ['alias001', 'alias002'] }),
+ defaultData('002', { aliases: ['alias002'] }),
+ defaultData('003', { aliases: ['alias003'] }),
+ ]);
+
+ const actual = await call({
+ query: {
+ aliases: 'alias002',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji002');
+ });
+
+ test('multi', async () => {
+ await insert([
+ defaultData('001', { aliases: ['alias001', 'alias002'] }),
+ defaultData('002', { aliases: ['alias002', 'alias004'] }),
+ defaultData('003', { aliases: ['alias003'] }),
+ defaultData('004', { aliases: ['alias004'] }),
+ ]);
+
+ const actual = await call({
+ query: {
+ aliases: 'alias001 alias004',
+ },
+ });
+
+ expect(actual.allCount).toBe(3);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji002');
+ expect(actual.emojis[2].name).toBe('emoji004');
+ });
+
+ test('keyword', async () => {
+ await insert([
+ defaultData('001', { aliases: ['alias001', 'alias002'] }),
+ defaultData('002', { aliases: ['alias002', 'alias004'] }),
+ defaultData('003', { aliases: ['alias003'] }),
+ defaultData('004', { aliases: ['alias004'] }),
+ ]);
+
+ const actual = await call({
+ query: {
+ aliases: 'ias',
+ },
+ });
+
+ expect(actual.allCount).toBe(4);
+ });
+
+ test('escape', async () => {
+ await insert([
+ defaultData('001', { aliases: ['alias001', 'alias002'] }),
+ ]);
+
+ const actual = await call({
+ query: {
+ aliases: '%',
+ },
+ });
+
+ expect(actual.allCount).toBe(0);
+ });
+ });
+
+ describe('category', () => {
+ test('single', async () => {
+ await insert([
+ defaultData('001', { category: 'category001' }),
+ defaultData('002', { category: 'category002' }),
+ defaultData('003', { category: 'category003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ category: 'category002',
+ },
+ });
+
+ expect(actual.allCount).toBe(1);
+ expect(actual.emojis[0].name).toBe('emoji002');
+ });
+
+ test('multi', async () => {
+ await insert([
+ defaultData('001', { category: 'category001' }),
+ defaultData('002', { category: 'category002' }),
+ defaultData('003', { category: 'category003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ category: 'category001 category003',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji003');
+ });
+
+ test('keyword', async () => {
+ await insert([
+ defaultData('001', { category: 'category001' }),
+ defaultData('002', { category: 'category002' }),
+ defaultData('003', { category: 'category003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ category: 'egory',
+ },
+ });
+
+ expect(actual.allCount).toBe(3);
+ });
+
+ test('escape', async () => {
+ await insert([
+ defaultData('001', { category: 'category001' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ category: '%',
+ },
+ });
+
+ expect(actual.allCount).toBe(0);
+ });
+ });
+
+ describe('license', () => {
+ test('single', async () => {
+ await insert([
+ defaultData('001', { license: 'license001' }),
+ defaultData('002', { license: 'license002' }),
+ defaultData('003', { license: 'license003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ license: 'license002',
+ },
+ });
+
+ expect(actual.allCount).toBe(1);
+ expect(actual.emojis[0].name).toBe('emoji002');
+ });
+
+ test('multi', async () => {
+ await insert([
+ defaultData('001', { license: 'license001' }),
+ defaultData('002', { license: 'license002' }),
+ defaultData('003', { license: 'license003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ license: 'license001 license003',
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji003');
+ });
+
+ test('keyword', async () => {
+ await insert([
+ defaultData('001', { license: 'license001' }),
+ defaultData('002', { license: 'license002' }),
+ defaultData('003', { license: 'license003' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ license: 'cense',
+ },
+ });
+
+ expect(actual.allCount).toBe(3);
+ });
+
+ test('escape', async () => {
+ await insert([
+ defaultData('001', { license: 'license001' }),
+ ]);
+
+ const actual = await call({
+ query: {
+ license: '%',
+ },
+ });
+
+ expect(actual.allCount).toBe(0);
+ });
+ });
+
+ describe('isSensitive', () => {
+ test('true', async () => {
+ await insert([
+ defaultData('001', { isSensitive: true }),
+ defaultData('002', { isSensitive: false }),
+ defaultData('003', { isSensitive: true }),
+ ]);
+
+ const actual = await call({
+ query: {
+ isSensitive: true,
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji003');
+ });
+
+ test('false', async () => {
+ await insert([
+ defaultData('001', { isSensitive: true }),
+ defaultData('002', { isSensitive: false }),
+ defaultData('003', { isSensitive: true }),
+ ]);
+
+ const actual = await call({
+ query: {
+ isSensitive: false,
+ },
+ });
+
+ expect(actual.allCount).toBe(1);
+ expect(actual.emojis[0].name).toBe('emoji002');
+ });
+
+ test('null', async () => {
+ await insert([
+ defaultData('001', { isSensitive: true }),
+ defaultData('002', { isSensitive: false }),
+ defaultData('003', { isSensitive: true }),
+ ]);
+
+ const actual = await call({
+ query: {},
+ });
+
+ expect(actual.allCount).toBe(3);
+ });
+ });
+
+ describe('localOnly', () => {
+ test('true', async () => {
+ await insert([
+ defaultData('001', { localOnly: true }),
+ defaultData('002', { localOnly: false }),
+ defaultData('003', { localOnly: true }),
+ ]);
+
+ const actual = await call({
+ query: {
+ localOnly: true,
+ },
+ });
+
+ expect(actual.allCount).toBe(2);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji003');
+ });
+
+ test('false', async () => {
+ await insert([
+ defaultData('001', { localOnly: true }),
+ defaultData('002', { localOnly: false }),
+ defaultData('003', { localOnly: true }),
+ ]);
+
+ const actual = await call({
+ query: {
+ localOnly: false,
+ },
+ });
+
+ expect(actual.allCount).toBe(1);
+ expect(actual.emojis[0].name).toBe('emoji002');
+ });
+
+ test('null', async () => {
+ await insert([
+ defaultData('001', { localOnly: true }),
+ defaultData('002', { localOnly: false }),
+ defaultData('003', { localOnly: true }),
+ ]);
+
+ const actual = await call({
+ query: {},
+ });
+
+ expect(actual.allCount).toBe(3);
+ });
+ });
+
+ describe('roleId', () => {
+ test('single', async () => {
+ await insert([
+ defaultData('001', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role001'] }),
+ defaultData('002', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role002'] }),
+ defaultData('003', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role003'] }),
+ ]);
+
+ const actual = await call({
+ query: {
+ roleIds: ['role002'],
+ },
+ });
+
+ expect(actual.allCount).toBe(1);
+ expect(actual.emojis[0].name).toBe('emoji002');
+ });
+
+ test('multi', async () => {
+ await insert([
+ defaultData('001', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role001'] }),
+ defaultData('002', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role002', 'role003'] }),
+ defaultData('003', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role003'] }),
+ defaultData('004', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role004'] }),
+ ]);
+
+ const actual = await call({
+ query: {
+ roleIds: ['role001', 'role003'],
+ },
+ });
+
+ expect(actual.allCount).toBe(3);
+ expect(actual.emojis[0].name).toBe('emoji001');
+ expect(actual.emojis[1].name).toBe('emoji002');
+ expect(actual.emojis[2].name).toBe('emoji003');
+ });
+ });
+ });
+ });
+});
diff --git a/packages/frontend/.storybook/fake-utils.ts b/packages/frontend/.storybook/fake-utils.ts
new file mode 100644
index 0000000000..c777cbbe72
--- /dev/null
+++ b/packages/frontend/.storybook/fake-utils.ts
@@ -0,0 +1,154 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import seedrandom from 'seedrandom';
+
+/**
+ * AIで生成した無作為なファーストネーム
+ */
+export const firstNameDict = [
+ 'Ethan', 'Olivia', 'Jackson', 'Emma', 'Liam', 'Ava', 'Aiden', 'Sophia', 'Mason', 'Isabella',
+ 'Noah', 'Mia', 'Lucas', 'Harper', 'Caleb', 'Abigail', 'Samuel', 'Emily', 'Logan',
+ 'Madison', 'Benjamin', 'Chloe', 'Elijah', 'Grace', 'Alexander', 'Scarlett', 'William', 'Zoey', 'James', 'Lily',
+]
+
+/**
+ * AIで生成した無作為なラストネーム
+ */
+export const lastNameDict = [
+ 'Anderson', 'Johnson', 'Thompson', 'Davis', 'Rodriguez', 'Smith', 'Patel', 'Williams', 'Lee', 'Brown',
+ 'Garcia', 'Jackson', 'Martinez', 'Taylor', 'Harris', 'Nguyen', 'Miller', 'Jones', 'Wilson',
+ 'White', 'Thomas', 'Garcia', 'Martinez', 'Robinson', 'Turner', 'Lewis', 'Hall', 'King', 'Baker', 'Cooper',
+]
+
+/**
+ * AIで生成した無作為な国名
+ */
+export const countryDict = [
+ 'Japan', 'Canada', 'Brazil', 'Australia', 'Italy', 'SouthAfrica', 'Mexico', 'Sweden', 'Russia', 'India',
+ 'Germany', 'Argentina', 'South Korea', 'France', 'Nigeria', 'Turkey', 'Spain', 'Egypt', 'Thailand',
+ 'Vietnam', 'Kenya', 'Saudi Arabia', 'Netherlands', 'Colombia', 'Poland', 'Chile', 'Malaysia', 'Ukraine', 'New Zealand', 'Peru',
+]
+
+export function text(length: number = 10, seed?: string): string {
+ let result = "";
+
+ // シード値を使う場合、同じ数値が羅列されるが、ランダム文字列という意味では満たせていると思うのでこのまま使っておく
+ const rand = seed ? seedrandom(seed)() : Math.random();
+ while (result.length < length) {
+ result += rand.toString(36).substring(2);
+ }
+
+ return result.substring(0, length);
+}
+
+export function integer(min: number = 0, max: number = 9999, seed?: string): number {
+ const rand = seed ? seedrandom(seed)() : Math.random();
+ return Math.floor(rand * (max - min)) + min;
+}
+
+export function date(params?: {
+ yearMin?: number,
+ yearMax?: number,
+ monthMin?: number,
+ monthMax?: number,
+ dayMin?: number,
+ dayMax?: number,
+ hourMin?: number,
+ hourMax?: number,
+ minuteMin?: number,
+ minuteMax?: number,
+ secondMin?: number,
+ secondMax?: number,
+ millisecondMin?: number,
+ millisecondMax?: number,
+}, seed?: string): Date {
+ const year = integer(params?.yearMin ?? 1970, params?.yearMax ?? (new Date()).getFullYear(), seed);
+ const month = integer(params?.monthMin ?? 1, params?.monthMax ?? 12, seed);
+ let day = integer(params?.dayMin ?? 1, params?.dayMax ?? 31, seed);
+ if (month === 2) {
+ day = Math.min(day, 28);
+ } else if ([4, 6, 9, 11].includes(month)) {
+ day = Math.min(day, 30);
+ } else {
+ day = Math.min(day, 31);
+ }
+
+ const hour = integer(params?.hourMin ?? 0, params?.hourMax ?? 23, seed);
+ const minute = integer(params?.minuteMin ?? 0, params?.minuteMax ?? 59, seed);
+ const second = integer(params?.secondMin ?? 0, params?.secondMax ?? 59, seed);
+ const millisecond = integer(params?.millisecondMin ?? 0, params?.millisecondMax ?? 999, seed);
+
+ return new Date(year, month - 1, day, hour, minute, second, millisecond);
+}
+
+export function boolean(seed?: string): boolean {
+ const rand = seed ? seedrandom(seed)() : Math.random();
+ return rand < 0.5;
+}
+
+export function choose<T>(array: T[], seed?: string): T {
+ const rand = seed ? seedrandom(seed)() : Math.random();
+ return array[Math.floor(rand * array.length)];
+}
+
+export function firstName(seed?: string): string {
+ return choose(firstNameDict, seed);
+}
+
+export function lastName(seed?: string): string {
+ return choose(lastNameDict, seed);
+}
+
+export function country(seed?: string): string {
+ return choose(countryDict, seed);
+}
+
+const TIME2000 = 946684800000;
+export function fakeId(seed?: string): string {
+ let time = new Date().getTime();
+
+ time = time - TIME2000;
+ if (time < 0) time = 0;
+
+ const timeStr = time.toString(36).padStart(8, '0');
+ const noiseStr = text(2, seed);
+
+ return timeStr + noiseStr;
+}
+
+export function imageDataUrl(options?: {
+ size?: {
+ width?: number,
+ height?: number,
+ },
+ color?: {
+ red?: number,
+ green?: number,
+ blue?: number,
+ alpha?: number,
+ }
+}, seed?: string): string {
+ const canvas = document.createElement('canvas');
+ canvas.width = options?.size?.width ?? 100;
+ canvas.height = options?.size?.height ?? 100;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ throw new Error('Failed to get 2d context');
+ }
+
+ ctx.beginPath()
+
+ const red = options?.color?.red ?? integer(0, 255, seed);
+ const green = options?.color?.green ?? integer(0, 255, seed);
+ const blue = options?.color?.blue ?? integer(0, 255, seed);
+ const alpha = options?.color?.alpha ?? 1;
+ ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2, true);
+ ctx.fillStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
+ ctx.fill();
+
+ return canvas.toDataURL('image/png', 1.0);
+}
diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts
index fc3b0334e4..0a5ac15aa5 100644
--- a/packages/frontend/.storybook/fakes.ts
+++ b/packages/frontend/.storybook/fakes.ts
@@ -5,6 +5,7 @@
import { AISCRIPT_VERSION } from '@syuilo/aiscript';
import type { entities } from 'misskey-js'
+import { date, imageDataUrl, text } from "./fake-utils.js";
export function abuseUserReport() {
return {
@@ -301,3 +302,93 @@ export function inviteCode(isUsed = false, hasExpiration = false, isExpired = fa
used: isUsed,
}
}
+
+export function role(params: {
+ id?: string,
+ name?: string,
+ color?: string | null,
+ iconUrl?: string | null,
+ description?: string,
+ isModerator?: boolean,
+ isAdministrator?: boolean,
+ displayOrder?: number,
+ createdAt?: string,
+ updatedAt?: string,
+ target?: 'manual' | 'conditional',
+ isPublic?: boolean,
+ isExplorable?: boolean,
+ asBadge?: boolean,
+ canEditMembersByModerator?: boolean,
+ usersCount?: number,
+}, seed?: string): entities.Role {
+ const prefix = params.displayOrder ? params.displayOrder.toString().padStart(3, '0') + '-' : '';
+ const genId = text(36, seed);
+ const createdAt = params.createdAt ?? date({}, seed).toISOString();
+ const updatedAt = params.updatedAt ?? date({}, seed).toISOString();
+
+ return {
+ id: params.id ?? genId,
+ name: params.name ?? `${prefix}TestRole-${genId}`,
+ color: params.color ?? '#445566',
+ iconUrl: params.iconUrl ?? null,
+ description: params.description ?? '',
+ isModerator: params.isModerator ?? false,
+ isAdministrator: params.isAdministrator ?? false,
+ displayOrder: params.displayOrder ?? 0,
+ createdAt: createdAt,
+ updatedAt: updatedAt,
+ target: params.target ?? 'manual',
+ isPublic: params.isPublic ?? true,
+ isExplorable: params.isExplorable ?? true,
+ asBadge: params.asBadge ?? true,
+ canEditMembersByModerator: params.canEditMembersByModerator ?? false,
+ usersCount: params.usersCount ?? 10,
+ condFormula: {
+ id: '',
+ type: 'or',
+ values: []
+ },
+ policies: {},
+ }
+}
+
+export function emoji(params?: {
+ id?: string,
+ name?: string,
+ host?: string,
+ uri?: string,
+ publicUrl?: string,
+ originalUrl?: string,
+ type?: string,
+ aliases?: string[],
+ category?: string,
+ license?: string,
+ isSensitive?: boolean,
+ localOnly?: boolean,
+ roleIdsThatCanBeUsedThisEmojiAsReaction?: {id:string, name:string}[],
+ updatedAt?: string,
+}, seed?: string): entities.EmojiDetailedAdmin {
+ const _seed = seed ?? (params?.id ?? "DEFAULT_SEED");
+ const id = params?.id ?? text(32, _seed);
+ const name = params?.name ?? text(8, _seed);
+ const updatedAt = params?.updatedAt ?? date({}, _seed).toISOString();
+
+ const image = imageDataUrl({}, _seed)
+
+ return {
+ id: id,
+ name: name,
+ host: params?.host ?? null,
+ uri: params?.uri ?? null,
+ publicUrl: params?.publicUrl ?? image,
+ originalUrl: params?.originalUrl ?? image,
+ type: params?.type ?? 'image/png',
+ aliases: params?.aliases ?? [`alias1-${name}`, `alias2-${name}`],
+ category: params?.category ?? null,
+ license: params?.license ?? null,
+ isSensitive: params?.isSensitive ?? false,
+ localOnly: params?.localOnly ?? false,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: params?.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
+ updatedAt: updatedAt,
+ }
+}
diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx
index f2bdc631d2..8830523810 100644
--- a/packages/frontend/.storybook/generate.tsx
+++ b/packages/frontend/.storybook/generate.tsx
@@ -416,6 +416,10 @@ function toStories(component: string): Promise<string> {
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/components/MkInstanceCardMini.vue'),
glob('src/components/MkInviteCode.vue'),
+ glob('src/components/MkTagItem.vue'),
+ glob('src/components/MkRoleSelectDialog.vue'),
+ glob('src/components/grid/MkGrid.vue'),
+ glob('src/pages/admin/custom-emojis-manager2.vue'),
glob('src/pages/admin/overview.ap-requests.vue'),
glob('src/pages/user/home.vue'),
glob('src/pages/search.vue'),
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 7bdc06a8b4..384c0c0b34 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<KeepAlive>
<div v-show="opened">
- <MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22">
+ <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
<slot></slot>
</MkSpacer>
<div v-else>
@@ -64,10 +64,14 @@ const props = withDefaults(defineProps<{
defaultOpen?: boolean;
maxHeight?: number | null;
withSpacer?: boolean;
+ spacerMin?: number;
+ spacerMax?: number;
}>(), {
defaultOpen: false,
maxHeight: null,
withSpacer: true,
+ spacerMin: 14,
+ spacerMax: 22,
});
const rootEl = shallowRef<HTMLElement>();
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index c766a33823..a446dad0ab 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -288,20 +288,23 @@ const align = () => {
const onOpened = () => {
emit('opened');
- // NOTE: Chromatic テストの際に undefined になる場合がある
- if (content.value == null) return;
+ // contentの子要素にアクセスするためレンダリングの完了を待つ必要がある(nextTickが必要)
+ nextTick(() => {
+ // NOTE: Chromatic テストの際に undefined になる場合がある
+ if (content.value == null) return;
- // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
- const el = content.value.children[0];
- el.addEventListener('mousedown', ev => {
- contentClicking = true;
- window.addEventListener('mouseup', ev => {
- // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
- window.setTimeout(() => {
- contentClicking = false;
- }, 100);
- }, { passive: true, once: true });
- }, { passive: true });
+ // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
+ const el = content.value.children[0];
+ el.addEventListener('mousedown', ev => {
+ contentClicking = true;
+ window.addEventListener('mouseup', ev => {
+ // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
+ window.setTimeout(() => {
+ contentClicking = false;
+ }, 100);
+ }, { passive: true, once: true });
+ }, { passive: true });
+ });
};
const onClosed = () => {
diff --git a/packages/frontend/src/components/MkPagingButtons.vue b/packages/frontend/src/components/MkPagingButtons.vue
new file mode 100644
index 0000000000..fe59efd83a
--- /dev/null
+++ b/packages/frontend/src/components/MkPagingButtons.vue
@@ -0,0 +1,124 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+ <MkButton primary :disabled="min === current" @click="onToPrevButtonClicked">&lt;</MkButton>
+
+ <div :class="$style.buttons">
+ <div v-if="prevDotVisible" :class="$style.headTailButtons">
+ <MkButton @click="onToHeadButtonClicked">{{ min }}</MkButton>
+ <span class="ti ti-dots"/>
+ </div>
+
+ <MkButton
+ v-for="i in buttonRanges" :key="i"
+ :disabled="current === i"
+ @click="onNumberButtonClicked(i)"
+ >
+ {{ i }}
+ </MkButton>
+
+ <div v-if="nextDotVisible" :class="$style.headTailButtons">
+ <span class="ti ti-dots"/>
+ <MkButton @click="onToTailButtonClicked">{{ max }}</MkButton>
+ </div>
+ </div>
+
+ <MkButton primary :disabled="max === current" @click="onToNextButtonClicked">&gt;</MkButton>
+</div>
+</template>
+
+<script setup lang="ts">
+
+import { computed, toRefs } from 'vue';
+import MkButton from '@/components/MkButton.vue';
+
+const min = 1;
+
+const emit = defineEmits<{
+ (ev: 'pageChanged', pageNumber: number): void;
+}>();
+
+const props = defineProps<{
+ current: number;
+ max: number;
+ buttonCount: number;
+}>();
+
+const { current, max } = toRefs(props);
+
+const buttonCount = computed(() => Math.min(max.value, props.buttonCount));
+const buttonCountHalf = computed(() => Math.floor(buttonCount.value / 2));
+const buttonCountStart = computed(() => Math.min(Math.max(min, current.value - buttonCountHalf.value), max.value - buttonCount.value + 1));
+const buttonRanges = computed(() => Array.from({ length: buttonCount.value }, (_, i) => buttonCountStart.value + i));
+
+const prevDotVisible = computed(() => (current.value - 1 > buttonCountHalf.value) && (max.value > buttonCount.value));
+const nextDotVisible = computed(() => (current.value < max.value - buttonCountHalf.value) && (max.value > buttonCount.value));
+
+if (_DEV_) {
+ console.log('[MkPagingButtons]', current.value, max.value, buttonCount.value, buttonCountHalf.value);
+ console.log('[MkPagingButtons]', current.value < max.value - buttonCountHalf.value);
+ console.log('[MkPagingButtons]', max.value > buttonCount.value);
+}
+
+function onNumberButtonClicked(pageNumber: number) {
+ emit('pageChanged', pageNumber);
+}
+
+function onToHeadButtonClicked() {
+ emit('pageChanged', min);
+}
+
+function onToPrevButtonClicked() {
+ const newPageNumber = current.value <= min ? min : current.value - 1;
+ emit('pageChanged', newPageNumber);
+}
+
+function onToNextButtonClicked() {
+ const newPageNumber = current.value >= max.value ? max.value : current.value + 1;
+ emit('pageChanged', newPageNumber);
+}
+
+function onToTailButtonClicked() {
+ emit('pageChanged', max.value);
+}
+</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 24px;
+
+ button {
+ border-radius: 9999px;
+ min-width: 2.5em;
+ min-height: 2.5em;
+ max-width: 2.5em;
+ max-height: 2.5em;
+ padding: 4px;
+ }
+}
+
+.buttons {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.headTailButtons {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+
+ span {
+ font-size: 0.75em;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts
new file mode 100644
index 0000000000..411d62edf9
--- /dev/null
+++ b/packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts
@@ -0,0 +1,106 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { http, HttpResponse } from 'msw';
+import { role } from '../../.storybook/fakes.js';
+import { commonHandlers } from '../../.storybook/mocks.js';
+import MkRoleSelectDialog from '@/components/MkRoleSelectDialog.vue';
+
+const roles = [
+ role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'),
+ role({ displayOrder: 2 }, '2'), role({ displayOrder: 2 }, '2'), role({ displayOrder: 3 }, '3'), role({ displayOrder: 3 }, '3'),
+ role({ displayOrder: 4 }, '4'), role({ displayOrder: 5 }, '5'), role({ displayOrder: 6 }, '6'), role({ displayOrder: 7 }, '7'),
+ role({ displayOrder: 999, name: 'privateRole', isPublic: false }, '999'),
+];
+
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkRoleSelectDialog,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkRoleSelectDialog v-bind="props" />',
+ };
+ },
+ args: {
+ initialRoleIds: undefined,
+ infoMessage: undefined,
+ title: undefined,
+ publicOnly: true,
+ },
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ http.post('/api/admin/roles/list', ({ params }) => {
+ return HttpResponse.json(roles);
+ }),
+ ],
+ },
+ },
+ decorators: [() => ({
+ template: '<div style="width:100cqmin"><story/></div>',
+ })],
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const InitialIds = {
+ ...Default,
+ args: {
+ ...Default.args,
+ initialRoleIds: [roles[0].id, roles[1].id, roles[4].id, roles[6].id, roles[8].id, roles[10].id],
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const InfoMessage = {
+ ...Default,
+ args: {
+ ...Default.args,
+ infoMessage: 'This is a message.',
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const Title = {
+ ...Default,
+ args: {
+ ...Default.args,
+ title: 'Select roles',
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const Full = {
+ ...Default,
+ args: {
+ ...Default.args,
+ initialRoleIds: roles.map(it => it.id),
+ infoMessage: InfoMessage.args.infoMessage,
+ title: Title.args.title,
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
+
+export const FullWithPrivate = {
+ ...Default,
+ args: {
+ ...Default.args,
+ initialRoleIds: roles.map(it => it.id),
+ infoMessage: InfoMessage.args.infoMessage,
+ title: Title.args.title,
+ publicOnly: false,
+ },
+} satisfies StoryObj<typeof MkRoleSelectDialog>;
diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue
new file mode 100644
index 0000000000..67a7a3f752
--- /dev/null
+++ b/packages/frontend/src/components/MkRoleSelectDialog.vue
@@ -0,0 +1,200 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="windowEl"
+ :withOkButton="false"
+ :okButtonDisabled="false"
+ :width="400"
+ :height="500"
+ @close="onCloseModalWindow"
+ @closed="console.log('MkRoleSelectDialog: closed') ; $emit('dispose')"
+>
+ <template #header>{{ title }}</template>
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <MkLoading v-if="fetching"/>
+ <div v-else class="_gaps" :class="$style.root">
+ <div :class="$style.header">
+ <MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ </div>
+
+ <div v-if="selectedRoles.length > 0" class="_gaps" :class="$style.roleItemArea">
+ <div v-for="role in selectedRoles" :key="role.id" :class="$style.roleItem">
+ <MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/>
+ <button class="_button" :class="$style.roleUnAssign" @click="removeRole(role.id)"><i class="ti ti-x"></i></button>
+ </div>
+ </div>
+ <div v-else :class="$style.roleItemArea" style="text-align: center">
+ {{ i18n.ts._roleSelectDialog.notSelected }}
+ </div>
+
+ <MkInfo v-if="infoMessage">{{ infoMessage }}</MkInfo>
+
+ <div :class="$style.buttons">
+ <MkButton primary @click="onOkClicked">{{ i18n.ts.ok }}</MkButton>
+ <MkButton @click="onCancelClicked">{{ i18n.ts.cancel }}</MkButton>
+ </div>
+ </div>
+ </MkSpacer>
+</MkModalWindow>
+</template>
+
+<script setup lang="ts">
+import { computed, defineProps, ref, toRefs } from 'vue';
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+import MkButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import MkRolePreview from '@/components/MkRolePreview.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import * as os from '@/os.js';
+import MkSpacer from '@/components/global/MkSpacer.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import MkLoading from '@/components/global/MkLoading.vue';
+
+const emit = defineEmits<{
+ (ev: 'done', value: Misskey.entities.Role[]),
+ (ev: 'close'),
+ (ev: 'dispose'),
+}>();
+
+const props = withDefaults(defineProps<{
+ initialRoleIds?: string[],
+ infoMessage?: string,
+ title?: string,
+ publicOnly: boolean,
+}>(), {
+ initialRoleIds: undefined,
+ infoMessage: undefined,
+ title: undefined,
+ publicOnly: true,
+});
+
+const { initialRoleIds, infoMessage, title, publicOnly } = toRefs(props);
+
+const windowEl = ref<InstanceType<typeof MkModalWindow>>();
+const roles = ref<Misskey.entities.Role[]>([]);
+const selectedRoleIds = ref<string[]>(initialRoleIds.value ?? []);
+const fetching = ref(false);
+
+const selectedRoles = computed(() => {
+ const r = roles.value.filter(role => selectedRoleIds.value.includes(role.id));
+ r.sort((a, b) => {
+ if (a.displayOrder !== b.displayOrder) {
+ return b.displayOrder - a.displayOrder;
+ }
+
+ return a.id.localeCompare(b.id);
+ });
+ return r;
+});
+
+async function fetchRoles() {
+ fetching.value = true;
+ const result = await misskeyApi('admin/roles/list', {});
+ roles.value = result.filter(it => publicOnly.value ? it.isPublic : true);
+ fetching.value = false;
+}
+
+async function addRole() {
+ const items = roles.value
+ .filter(r => r.isPublic)
+ .filter(r => !selectedRoleIds.value.includes(r.id))
+ .map(r => ({ text: r.name, value: r }));
+
+ const { canceled, result: role } = await os.select({ items });
+ if (canceled) {
+ return;
+ }
+
+ selectedRoleIds.value.push(role.id);
+}
+
+async function removeRole(roleId: string) {
+ selectedRoleIds.value = selectedRoleIds.value.filter(x => x !== roleId);
+}
+
+function onOkClicked() {
+ emit('done', selectedRoles.value);
+ windowEl.value?.close();
+}
+
+function onCancelClicked() {
+ emit('close');
+ windowEl.value?.close();
+}
+
+function onCloseModalWindow() {
+ emit('close');
+ windowEl.value?.close();
+}
+
+fetchRoles();
+</script>
+
+<style module lang="scss">
+.root {
+ max-height: 410px;
+ height: 410px;
+ display: flex;
+ flex-direction: column;
+}
+
+.roleItemArea {
+ background-color: var(--MI_THEME-acrylicBg);
+ border-radius: var(--MI-radius);
+ padding: 12px;
+ overflow-y: auto;
+}
+
+.roleItem {
+ display: flex;
+}
+
+.role {
+ flex: 1;
+}
+
+.roleUnAssign {
+ width: 32px;
+ height: 32px;
+ margin-left: 8px;
+ align-self: center;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+.title {
+ flex: 1;
+}
+
+.addRoleButton {
+ min-width: 32px;
+ min-height: 32px;
+ max-width: 32px;
+ max-height: 32px;
+ margin-left: 8px;
+ align-self: center;
+ padding: 0;
+}
+
+.buttons {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ margin-top: auto;
+}
+
+.divider {
+ border-top: solid 0.5px var(--MI_THEME-divider);
+}
+
+</style>
diff --git a/packages/frontend/src/components/MkSortOrderEditor.define.ts b/packages/frontend/src/components/MkSortOrderEditor.define.ts
new file mode 100644
index 0000000000..f023b5d72b
--- /dev/null
+++ b/packages/frontend/src/components/MkSortOrderEditor.define.ts
@@ -0,0 +1,11 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type SortOrderDirection = '+' | '-'
+
+export type SortOrder<T extends string> = {
+ key: T;
+ direction: SortOrderDirection;
+}
diff --git a/packages/frontend/src/components/MkSortOrderEditor.vue b/packages/frontend/src/components/MkSortOrderEditor.vue
new file mode 100644
index 0000000000..da08f12297
--- /dev/null
+++ b/packages/frontend/src/components/MkSortOrderEditor.vue
@@ -0,0 +1,112 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.sortOrderArea">
+ <div :class="$style.sortOrderAreaTags">
+ <MkTagItem
+ v-for="order in currentOrders"
+ :key="order.key"
+ :iconClass="order.direction === '+' ? 'ti ti-arrow-up' : 'ti ti-arrow-down'"
+ :exButtonIconClass="'ti ti-x'"
+ :content="order.key"
+ @click="onToggleSortOrderButtonClicked(order)"
+ @exButtonClick="onRemoveSortOrderButtonClicked(order)"
+ />
+ </div>
+ <MkButton :class="$style.sortOrderAddButton" @click="onAddSortOrderButtonClicked">
+ <span class="ti ti-plus"/>
+ </MkButton>
+</div>
+</template>
+
+<script setup lang="ts" generic="T extends string">
+import { toRefs } from 'vue';
+import MkTagItem from '@/components/MkTagItem.vue';
+import MkButton from '@/components/MkButton.vue';
+import { MenuItem } from '@/types/menu.js';
+import * as os from '@/os.js';
+import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
+
+const emit = defineEmits<{
+ (ev: 'update', sortOrders: SortOrder<T>[]): void;
+}>();
+
+const props = defineProps<{
+ baseOrderKeyNames: T[];
+ currentOrders: SortOrder<T>[];
+}>();
+
+const { currentOrders } = toRefs(props);
+
+function onToggleSortOrderButtonClicked(order: SortOrder<T>) {
+ switch (order.direction) {
+ case '+':
+ order.direction = '-';
+ break;
+ case '-':
+ order.direction = '+';
+ break;
+ }
+
+ emitOrder(currentOrders.value);
+}
+
+function onAddSortOrderButtonClicked(ev: MouseEvent) {
+ const menuItems: MenuItem[] = props.baseOrderKeyNames
+ .filter(baseKey => !currentOrders.value.map(it => it.key).includes(baseKey))
+ .map(it => {
+ return {
+ text: it,
+ action: () => {
+ emitOrder([...currentOrders.value, { key: it, direction: '+' }]);
+ },
+ };
+ });
+ os.contextMenu(menuItems, ev);
+}
+
+function onRemoveSortOrderButtonClicked(order: SortOrder<T>) {
+ emitOrder(currentOrders.value.filter(it => it.key !== order.key));
+}
+
+function emitOrder(sortOrders: SortOrder<T>[]) {
+ emit('update', sortOrders);
+}
+
+</script>
+
+<style module lang="scss">
+.sortOrderArea {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: flex-start;
+}
+
+.sortOrderAreaTags {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.sortOrderAddButton {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-sizing: border-box;
+ min-width: 2.0em;
+ min-height: 2.0em;
+ max-width: 2.0em;
+ max-height: 2.0em;
+ padding: 8px;
+ margin-left: auto;
+ border-radius: 9999px;
+ background-color: var(--MI_THEME-buttonBg);
+}
+</style>
diff --git a/packages/frontend/src/components/MkTagItem.stories.impl.ts b/packages/frontend/src/components/MkTagItem.stories.impl.ts
new file mode 100644
index 0000000000..3f243ff651
--- /dev/null
+++ b/packages/frontend/src/components/MkTagItem.stories.impl.ts
@@ -0,0 +1,70 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+/* eslint-disable import/no-default-export */
+import { action } from '@storybook/addon-actions';
+import { StoryObj } from '@storybook/vue3';
+import MkTagItem from './MkTagItem.vue';
+
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkTagItem: MkTagItem,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ events() {
+ return {
+ click: action('click'),
+ exButtonClick: action('exButtonClick'),
+ };
+ },
+ },
+ template: '<MkTagItem v-bind="props" v-on="events"></MkTagItem>',
+ };
+ },
+ args: {
+ content: 'name',
+ },
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkTagItem>;
+
+export const Icon = {
+ ...Default,
+ args: {
+ ...Default.args,
+ iconClass: 'ti ti-arrow-up',
+ },
+} satisfies StoryObj<typeof MkTagItem>;
+
+export const ExButton = {
+ ...Default,
+ args: {
+ ...Default.args,
+ exButtonIconClass: 'ti ti-x',
+ },
+} satisfies StoryObj<typeof MkTagItem>;
+
+export const IconExButton = {
+ ...Default,
+ args: {
+ ...Default.args,
+ iconClass: 'ti ti-arrow-up',
+ exButtonIconClass: 'ti ti-x',
+ },
+} satisfies StoryObj<typeof MkTagItem>;
diff --git a/packages/frontend/src/components/MkTagItem.vue b/packages/frontend/src/components/MkTagItem.vue
new file mode 100644
index 0000000000..98f2411392
--- /dev/null
+++ b/packages/frontend/src/components/MkTagItem.vue
@@ -0,0 +1,76 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root" @click="(ev) => emit('click', ev)">
+ <span v-if="iconClass" :class="[$style.icon, iconClass]"/>
+ <span :class="$style.content">{{ content }}</span>
+ <MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)">
+ <span :class="[$style.exButtonIcon, exButtonIconClass]"/>
+ </MkButton>
+</div>
+</template>
+
+<script setup lang="ts">
+import MkButton from '@/components/MkButton.vue';
+
+const emit = defineEmits<{
+ (ev: 'click', payload: MouseEvent): void;
+ (ev: 'exButtonClick', payload: MouseEvent): void;
+}>();
+
+defineProps<{
+ iconClass?: string;
+ content: string;
+ exButtonIconClass?: string
+}>();
+</script>
+
+<style module lang="scss">
+$buttonSize : 1.8em;
+
+.root {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ padding: 4px 6px;
+ gap: 3px;
+
+ background-color: var(--MI_THEME-buttonBg);
+
+ &:hover {
+ background-color: var(--MI_THEME-buttonHoverBg);
+ }
+}
+
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.70em;
+}
+
+.exButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ max-height: $buttonSize;
+ max-width: $buttonSize;
+ min-height: $buttonSize;
+ min-width: $buttonSize;
+ padding: 0;
+ box-sizing: border-box;
+ font-size: 0.65em;
+}
+
+.exButtonIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.80em;
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkCellTooltip.vue b/packages/frontend/src/components/grid/MkCellTooltip.vue
new file mode 100644
index 0000000000..fd289c6cd9
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkCellTooltip.vue
@@ -0,0 +1,35 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')">
+ <div :class="$style.root">
+ {{ content }}
+ </div>
+</MkTooltip>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkTooltip from '@/components/MkTooltip.vue';
+
+defineProps<{
+ showing: boolean;
+ content: string;
+ targetElement: HTMLElement;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'closed'): void;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+ font-size: 0.9em;
+ text-align: left;
+ text-wrap: normal;
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue
new file mode 100644
index 0000000000..0ffd42abda
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkDataCell.vue
@@ -0,0 +1,391 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ v-if="cell.row.using"
+ ref="rootEl"
+ class="mk_grid_td"
+ :class="$style.cell"
+ :style="{ maxWidth: cellWidth, minWidth: cellWidth }"
+ :tabindex="-1"
+ data-grid-cell
+ :data-grid-cell-row="cell.row.index"
+ :data-grid-cell-col="cell.column.index"
+ @keydown="onCellKeyDown"
+ @dblclick.prevent="onCellDoubleClick"
+>
+ <div
+ :class="[
+ $style.root,
+ [(cell.violation.valid || cell.selected) ? {} : $style.error],
+ [cell.selected ? $style.selected : {}],
+ // 行が選択されているときは範囲選択色の適用を行側に任せる
+ [(cell.ranged && !cell.row.ranged) ? $style.ranged : {}],
+ [needsContentCentering ? $style.center : {}],
+ ]"
+ >
+ <div v-if="!editing" :class="[$style.contentArea]" :style="cellType === 'boolean' ? 'justify-content: center' : ''">
+ <div ref="contentAreaEl" :class="$style.content">
+ <div v-if="cellType === 'text'">
+ {{ cell.value }}
+ </div>
+ <div v-if="cellType === 'number'">
+ {{ cell.value }}
+ </div>
+ <div v-if="cellType === 'date'">
+ {{ cell.value }}
+ </div>
+ <div v-else-if="cellType === 'boolean'">
+ <span v-if="cell.value === true" class="ti ti-check"/>
+ <span v-else class="ti"/>
+ </div>
+ <div v-else-if="cellType === 'image'">
+ <img
+ :src="cell.value as string"
+ :alt="cell.value as string"
+ :class="$style.viewImage"
+ @load="emitContentSizeChanged"
+ />
+ </div>
+ </div>
+ </div>
+ <div v-else ref="inputAreaEl" :class="$style.inputArea">
+ <input
+ v-if="cellType === 'text'"
+ type="text"
+ :class="$style.editingInput"
+ :value="editingValue"
+ @input="onInputText"
+ @mousedown.stop
+ @contextmenu.stop
+ />
+ <input
+ v-if="cellType === 'number'"
+ type="number"
+ :class="$style.editingInput"
+ :value="editingValue"
+ @input="onInputText"
+ @mousedown.stop
+ @contextmenu.stop
+ />
+ <input
+ v-if="cellType === 'date'"
+ type="date"
+ :class="$style.editingInput"
+ :value="editingValue"
+ @input="onInputText"
+ @mousedown.stop
+ @contextmenu.stop
+ />
+ </div>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue';
+import { GridEventEmitter, Size } from '@/components/grid/grid.js';
+import { useTooltip } from '@/scripts/use-tooltip.js';
+import * as os from '@/os.js';
+import { CellValue, GridCell } from '@/components/grid/cell.js';
+import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
+import { GridRowSetting } from '@/components/grid/row.js';
+
+const emit = defineEmits<{
+ (ev: 'operation:beginEdit', sender: GridCell): void;
+ (ev: 'operation:endEdit', sender: GridCell): void;
+ (ev: 'change:value', sender: GridCell, newValue: CellValue): void;
+ (ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
+}>();
+const props = defineProps<{
+ cell: GridCell,
+ rowSetting: GridRowSetting,
+ bus: GridEventEmitter,
+}>();
+
+const { cell, bus } = toRefs(props);
+
+const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>();
+const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
+const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
+
+/** 値が編集中かどうか */
+const editing = ref<boolean>(false);
+/** 編集中の値. {@link beginEditing}と{@link endEditing}内、および各inputタグやそのコールバックからの操作のみを想定する */
+const editingValue = ref<CellValue>(undefined);
+
+const cellWidth = computed(() => cell.value.column.width);
+const cellType = computed(() => cell.value.column.setting.type);
+const needsContentCentering = computed(() => {
+ switch (cellType.value) {
+ case 'boolean':
+ return true;
+ default:
+ return false;
+ }
+});
+
+watch(() => [cell.value.value], () => {
+ // 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する
+ nextTick(emitContentSizeChanged);
+}, { immediate: true });
+
+watch(() => cell.value.selected, () => {
+ if (cell.value.selected) {
+ requestFocus();
+ }
+});
+
+function onCellDoubleClick(ev: MouseEvent) {
+ switch (ev.type) {
+ case 'dblclick': {
+ beginEditing(ev.target as HTMLElement);
+ break;
+ }
+ }
+}
+
+function onOutsideMouseDown(ev: MouseEvent) {
+ const isOutside = ev.target instanceof Node && !rootEl.value?.contains(ev.target);
+ if (isOutside || !equalCellAddress(cell.value.address, getCellAddress(ev.target as HTMLElement))) {
+ endEditing(true, false);
+ }
+}
+
+function onCellKeyDown(ev: KeyboardEvent) {
+ if (!editing.value) {
+ ev.preventDefault();
+ switch (ev.code) {
+ case 'NumpadEnter':
+ case 'Enter':
+ case 'F2': {
+ beginEditing(ev.target as HTMLElement);
+ break;
+ }
+ }
+ } else {
+ switch (ev.code) {
+ case 'Escape': {
+ endEditing(false, true);
+ break;
+ }
+ case 'NumpadEnter':
+ case 'Enter': {
+ if (!ev.isComposing) {
+ endEditing(true, true);
+ }
+ }
+ }
+ }
+}
+
+function onInputText(ev: Event) {
+ editingValue.value = (ev.target as HTMLInputElement).value;
+}
+
+function onForceRefreshContentSize() {
+ emitContentSizeChanged();
+}
+
+function registerOutsideMouseDown() {
+ unregisterOutsideMouseDown();
+ addEventListener('mousedown', onOutsideMouseDown);
+}
+
+function unregisterOutsideMouseDown() {
+ removeEventListener('mousedown', onOutsideMouseDown);
+}
+
+async function beginEditing(target: HTMLElement) {
+ if (editing.value || !cell.value.selected || !cell.value.column.setting.editable) {
+ return;
+ }
+
+ if (cell.value.column.setting.customValueEditor) {
+ emit('operation:beginEdit', cell.value);
+ const newValue = await cell.value.column.setting.customValueEditor(
+ cell.value.row,
+ cell.value.column,
+ cell.value.value,
+ target,
+ );
+ emit('operation:endEdit', cell.value);
+
+ if (newValue !== cell.value.value) {
+ emitValueChange(newValue);
+ }
+
+ requestFocus();
+ } else {
+ switch (cellType.value) {
+ case 'number':
+ case 'date':
+ case 'text': {
+ editingValue.value = cell.value.value;
+ editing.value = true;
+ registerOutsideMouseDown();
+ emit('operation:beginEdit', cell.value);
+
+ await nextTick(() => {
+ // inputの展開後にフォーカスを当てたい
+ if (inputAreaEl.value) {
+ (inputAreaEl.value.querySelector('*') as HTMLElement).focus();
+ }
+ });
+ break;
+ }
+ case 'boolean': {
+ // とくに特殊なUIは設けず、トグルするだけ
+ emitValueChange(!cell.value.value);
+ break;
+ }
+ }
+ }
+}
+
+function endEditing(applyValue: boolean, requireFocus: boolean) {
+ if (!editing.value) {
+ return;
+ }
+
+ const newValue = editingValue.value;
+ editingValue.value = undefined;
+
+ emit('operation:endEdit', cell.value);
+ unregisterOutsideMouseDown();
+
+ if (applyValue && newValue !== cell.value.value) {
+ emitValueChange(newValue);
+ }
+
+ editing.value = false;
+
+ if (requireFocus) {
+ requestFocus();
+ }
+}
+
+function requestFocus() {
+ nextTick(() => {
+ rootEl.value?.focus();
+ });
+}
+
+function emitValueChange(newValue: CellValue) {
+ const _cell = cell.value;
+ emit('change:value', _cell, newValue);
+}
+
+function emitContentSizeChanged() {
+ emit('change:contentSize', cell.value, {
+ width: contentAreaEl.value?.clientWidth ?? 0,
+ height: contentAreaEl.value?.clientHeight ?? 0,
+ });
+}
+
+useTooltip(rootEl, (showing) => {
+ if (cell.value.violation.valid) {
+ return;
+ }
+
+ const content = cell.value.violation.violations.filter(it => !it.valid).map(it => it.result.message).join('\n');
+ const result = os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), {
+ showing,
+ content,
+ targetElement: rootEl.value!,
+ }, {
+ closed: () => {
+ result.dispose();
+ },
+ });
+});
+
+onMounted(() => {
+ bus.value.on('forceRefreshContentSize', onForceRefreshContentSize);
+});
+
+onUnmounted(() => {
+ bus.value.off('forceRefreshContentSize', onForceRefreshContentSize);
+});
+
+</script>
+
+<style module lang="scss">
+$cellHeight: 28px;
+
+.cell {
+ overflow: hidden;
+ white-space: nowrap;
+ height: $cellHeight;
+ max-height: $cellHeight;
+ min-height: $cellHeight;
+ cursor: cell;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+.root {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ box-sizing: border-box;
+ height: 100%;
+
+ // selected適用時に中身がズレてしまうので、透明の線をあらかじめ引いておきたい
+ border: solid 0.5px transparent;
+
+ &.selected {
+ border: solid 0.5px var(--MI_THEME-accentLighten);
+ }
+
+ &.ranged {
+ background-color: var(--MI_THEME-accentedBg);
+ }
+
+ &.center {
+ justify-content: center;
+ }
+
+ &.error {
+ border: solid 0.5px var(--MI_THEME-error);
+ }
+}
+
+.contentArea, .inputArea {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ max-width: 100%;
+}
+
+.content {
+ display: inline-block;
+ padding: 0 8px;
+}
+
+.viewImage {
+ width: auto;
+ max-height: $cellHeight;
+ height: $cellHeight;
+ object-fit: cover;
+}
+
+.editingInput {
+ padding: 0 8px;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ min-height: $cellHeight - 2;
+ max-height: $cellHeight - 2;
+ height: $cellHeight - 2;
+ outline: none;
+ border: none;
+ font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
+}
+
+</style>
diff --git a/packages/frontend/src/components/grid/MkDataRow.vue b/packages/frontend/src/components/grid/MkDataRow.vue
new file mode 100644
index 0000000000..280a14bc4a
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkDataRow.vue
@@ -0,0 +1,72 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ class="mk_grid_tr"
+ :class="[
+ $style.row,
+ row.ranged ? $style.ranged : {},
+ ...(row.additionalStyles ?? []).map(it => it.className ?? {}),
+ ]"
+ :style="[
+ ...(row.additionalStyles ?? []).map(it => it.style ?? {}),
+ ]"
+ :data-grid-row="row.index"
+>
+ <MkNumberCell
+ v-if="setting.showNumber"
+ :content="(row.index + 1).toString()"
+ :row="row"
+ />
+ <MkDataCell
+ v-for="cell in cells"
+ :key="cell.address.col"
+ :vIf="cell.column.setting.type !== 'hidden'"
+ :cell="cell"
+ :rowSetting="setting"
+ :bus="bus"
+ @operation:beginEdit="(sender) => emit('operation:beginEdit', sender)"
+ @operation:endEdit="(sender) => emit('operation:endEdit', sender)"
+ @change:value="(sender, newValue) => emit('change:value', sender, newValue)"
+ @change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
+ />
+</div>
+</template>
+
+<script setup lang="ts">
+import { GridEventEmitter, Size } from '@/components/grid/grid.js';
+import MkDataCell from '@/components/grid/MkDataCell.vue';
+import MkNumberCell from '@/components/grid/MkNumberCell.vue';
+import { CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridRow, GridRowSetting } from '@/components/grid/row.js';
+
+const emit = defineEmits<{
+ (ev: 'operation:beginEdit', sender: GridCell): void;
+ (ev: 'operation:endEdit', sender: GridCell): void;
+ (ev: 'change:value', sender: GridCell, newValue: CellValue): void;
+ (ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
+}>();
+defineProps<{
+ row: GridRow,
+ cells: GridCell[],
+ setting: GridRowSetting,
+ bus: GridEventEmitter,
+}>();
+
+</script>
+
+<style module lang="scss">
+.row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ width: fit-content;
+
+ &.ranged {
+ background-color: var(--MI_THEME-accentedBg);
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkGrid.stories.impl.ts b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts
new file mode 100644
index 0000000000..5801012f15
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkGrid.stories.impl.ts
@@ -0,0 +1,223 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { action } from '@storybook/addon-actions';
+import { StoryObj } from '@storybook/vue3';
+import { ref } from 'vue';
+import { commonHandlers } from '../../../.storybook/mocks.js';
+import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js';
+import MkGrid from './MkGrid.vue';
+import { GridContext, GridEvent } from '@/components/grid/grid-event.js';
+import { DataSource, GridSetting } from '@/components/grid/grid.js';
+import { GridColumnSetting } from '@/components/grid/column.js';
+
+function d(p: {
+ check?: boolean,
+ name?: string,
+ email?: string,
+ age?: number,
+ birthday?: string,
+ gender?: string,
+ country?: string,
+ reportCount?: number,
+ createdAt?: string,
+}, seed: string) {
+ const prefix = text(10, seed);
+
+ return {
+ check: p.check ?? boolean(seed),
+ name: p.name ?? `${firstName(seed)} ${lastName(seed)}`,
+ email: p.email ?? `${prefix}@example.com`,
+ age: p.age ?? integer(20, 80, seed),
+ birthday: date({}, seed).toISOString(),
+ gender: p.gender ?? choose(['male', 'female', 'other', 'unknown'], seed),
+ country: p.country ?? country(seed),
+ reportCount: p.reportCount ?? integer(0, 9999, seed),
+ createdAt: p.createdAt ?? date({}, seed).toISOString(),
+ };
+}
+
+const defaultCols: GridColumnSetting[] = [
+ { bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50 },
+ { bindTo: 'name', title: 'Name', type: 'text', width: 'auto' },
+ { bindTo: 'email', title: 'Email', type: 'text', width: 'auto' },
+ { bindTo: 'age', title: 'Age', type: 'number', width: 50 },
+ { bindTo: 'birthday', title: 'Birthday', type: 'date', width: 'auto' },
+ { bindTo: 'gender', title: 'Gender', type: 'text', width: 80 },
+ { bindTo: 'country', title: 'Country', type: 'text', width: 120 },
+ { bindTo: 'reportCount', title: 'ReportCount', type: 'number', width: 'auto' },
+ { bindTo: 'createdAt', title: 'CreatedAt', type: 'date', width: 'auto' },
+];
+
+function createArgs(overrides?: { settings?: Partial<GridSetting>, data?: DataSource[] }) {
+ const refData = ref<ReturnType<typeof d>[]>([]);
+ for (let i = 0; i < 100; i++) {
+ refData.value.push(d({}, i.toString()));
+ }
+
+ return {
+ settings: {
+ row: overrides?.settings?.row,
+ cols: [
+ ...defaultCols.filter(col => overrides?.settings?.cols?.every(c => c.bindTo !== col.bindTo) ?? true),
+ ...overrides?.settings?.cols ?? [],
+ ],
+ cells: overrides?.settings?.cells,
+ },
+ data: refData.value,
+ };
+}
+
+function createRender(params: { settings: GridSetting, data: DataSource[] }) {
+ return {
+ render(args) {
+ return {
+ components: {
+ MkGrid,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ data() {
+ return {
+ data: args.data,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...args,
+ };
+ },
+ events() {
+ return {
+ event: (event: GridEvent, context: GridContext) => {
+ switch (event.type) {
+ case 'cell-value-change': {
+ args.data[event.row.index][event.column.setting.bindTo] = event.newValue;
+ }
+ }
+ },
+ };
+ },
+ },
+ template: '<div style="padding:20px"><MkGrid v-bind="props" v-on="events" /></div>',
+ };
+ },
+ args: {
+ ...params,
+ },
+ parameters: {
+ layout: 'fullscreen',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ ],
+ },
+ },
+ } satisfies StoryObj<typeof MkGrid>;
+}
+
+export const Default = createRender(createArgs());
+
+export const NoNumber = createRender(createArgs({
+ settings: {
+ row: {
+ showNumber: false,
+ },
+ },
+}));
+
+export const NoSelectable = createRender(createArgs({
+ settings: {
+ row: {
+ selectable: false,
+ },
+ },
+}));
+
+export const Editable = createRender(createArgs({
+ settings: {
+ cols: defaultCols.map(col => ({ ...col, editable: true })),
+ },
+}));
+
+export const AdditionalRowStyle = createRender(createArgs({
+ settings: {
+ cols: defaultCols.map(col => ({ ...col, editable: true })),
+ row: {
+ styleRules: [
+ {
+ condition: ({ row }) => AdditionalRowStyle.args.data[row.index].check as boolean,
+ applyStyle: {
+ style: {
+ backgroundColor: 'lightgray',
+ },
+ },
+ },
+ ],
+ },
+ },
+}));
+
+export const ContextMenu = createRender(createArgs({
+ settings: {
+ cols: [
+ {
+ bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50, contextMenuFactory: (col, context) => [
+ {
+ type: 'button',
+ text: 'Check All',
+ action: () => {
+ for (const d of ContextMenu.args.data) {
+ d.check = true;
+ }
+ },
+ },
+ {
+ type: 'button',
+ text: 'Uncheck All',
+ action: () => {
+ for (const d of ContextMenu.args.data) {
+ d.check = false;
+ }
+ },
+ },
+ ],
+ },
+ ],
+ row: {
+ contextMenuFactory: (row, context) => [
+ {
+ type: 'button',
+ text: 'Delete',
+ action: () => {
+ const idxes = context.rangedRows.map(r => r.index);
+ const newData = ContextMenu.args.data.filter((d, i) => !idxes.includes(i));
+
+ ContextMenu.args.data.splice(0);
+ ContextMenu.args.data.push(...newData);
+ },
+ },
+ ],
+ },
+ cells: {
+ contextMenuFactory: (col, row, value, context) => [
+ {
+ type: 'button',
+ text: 'Delete',
+ action: () => {
+ for (const cell of context.rangedCells) {
+ ContextMenu.args.data[cell.row.index][cell.column.setting.bindTo] = undefined;
+ }
+ },
+ },
+ ],
+ },
+ },
+}));
diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue
new file mode 100644
index 0000000000..60738365fb
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkGrid.vue
@@ -0,0 +1,1342 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ ref="rootEl"
+ class="mk_grid_border"
+ :class="[$style.grid]"
+ @mousedown.prevent="onMouseDown"
+ @keydown="onKeyDown"
+ @contextmenu.prevent.stop="onContextMenu"
+>
+ <div class="mk_grid_thead">
+ <MkHeaderRow
+ :columns="columns"
+ :gridSetting="rowSetting"
+ :bus="bus"
+ @operation:beginWidthChange="onHeaderCellWidthBeginChange"
+ @operation:endWidthChange="onHeaderCellWidthEndChange"
+ @operation:widthLargest="onHeaderCellWidthLargest"
+ @change:width="onHeaderCellChangeWidth"
+ @change:contentSize="onHeaderCellChangeContentSize"
+ />
+ </div>
+ <div class="mk_grid_tbody">
+ <MkDataRow
+ v-for="row in rows"
+ v-show="row.using"
+ :key="row.index"
+ :row="row"
+ :cells="cells[row.index].cells"
+ :setting="rowSetting"
+ :bus="bus"
+ :using="row.using"
+ :class="[lastLine === row.index ? 'last_row' : '']"
+ @operation:beginEdit="onCellEditBegin"
+ @operation:endEdit="onCellEditEnd"
+ @change:value="onChangeCellValue"
+ @change:contentSize="onChangeCellContentSize"
+ />
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, toRefs, watch } from 'vue';
+import { DataSource, GridEventEmitter, GridSetting, GridState, Size } from '@/components/grid/grid.js';
+import MkDataRow from '@/components/grid/MkDataRow.vue';
+import MkHeaderRow from '@/components/grid/MkHeaderRow.vue';
+import { cellValidation } from '@/components/grid/cell-validators.js';
+import { CELL_ADDRESS_NONE, CellAddress, CellValue, createCell, GridCell, resetCell } from '@/components/grid/cell.js';
+import {
+ copyGridDataToClipboard,
+ equalCellAddress,
+ getCellAddress,
+ getCellElement,
+ pasteToGridFromClipboard,
+ removeDataFromGrid,
+} from '@/components/grid/grid-utils.js';
+import { MenuItem } from '@/types/menu.js';
+import * as os from '@/os.js';
+import { GridContext, GridEvent } from '@/components/grid/grid-event.js';
+import { createColumn, GridColumn } from '@/components/grid/column.js';
+import { createRow, defaultGridRowSetting, GridRow, GridRowSetting, resetRow } from '@/components/grid/row.js';
+import { handleKeyEvent } from '@/scripts/key-event.js';
+
+type RowHolder = {
+ row: GridRow,
+ cells: GridCell[],
+ origin: DataSource,
+}
+
+const emit = defineEmits<{
+ (ev: 'event', event: GridEvent, context: GridContext): void;
+}>();
+
+const props = defineProps<{
+ settings: GridSetting,
+ data: DataSource[]
+}>();
+
+// non-reactive
+// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const rowSetting: Required<GridRowSetting> = {
+ ...defaultGridRowSetting,
+ ...props.settings.row,
+};
+
+// non-reactive
+// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const columnSettings = props.settings.cols;
+
+// non-reactive
+const cellSettings = props.settings.cells ?? {};
+
+const { data } = toRefs(props);
+
+// #region Event Definitions
+// region Event Definitions
+
+/**
+ * grid -> 各子コンポーネントのイベント経路を担う{@link GridEventEmitter}。おもにpropsでの伝搬が難しいイベントを伝搬するために使用する。
+ * 子コンポーネント -> gridのイベントでは原則使用せず、{@link emit}を使用する。
+ */
+const bus = new GridEventEmitter();
+/**
+ * テーブルコンポーネントのリサイズイベントを監視するための{@link ResizeObserver}。
+ * 表示切替を検知し、サイズの再計算要求を発行するために使用する(マウント時にコンテンツが表示されていない場合、初手のサイズの自動計算が正常に働かないため)
+ *
+ * {@link setTimeout}を経由している理由は、{@link onResize}の中でサイズ再計算要求→サイズ変更が発生するとループとみなされ、
+ * 「ResizeObserver loop completed with undelivered notifications.」という警告が発生するため(再計算が完全に終われば通知は発生しなくなるので実際にはループしない)
+ *
+ * @see {@link onResize}
+ */
+const resizeObserver = new ResizeObserver((entries) => setTimeout(() => onResize(entries)));
+
+const rootEl = ref<InstanceType<typeof HTMLTableElement>>();
+/**
+ * グリッドの最も上位にある状態。
+ */
+const state = ref<GridState>('normal');
+/**
+ * グリッドの列定義。列定義の元の設定値は非リアクティブなので、初期値を生成して以降は変更しない。
+ */
+const columns = ref<GridColumn[]>(columnSettings.map(createColumn));
+/**
+ * グリッドの行定義。propsで受け取った{@link data}をもとに、{@link refreshData}で再計算される。
+ */
+const rows = ref<GridRow[]>([]);
+/**
+ * グリッドのセル定義。propsで受け取った{@link data}をもとに、{@link refreshData}で再計算される。
+ */
+const cells = ref<RowHolder[]>([]);
+
+/**
+ * mousemoveイベントが発生した際に、イベントから取得したセルアドレスを保持するための変数。
+ * セルアドレスが変わった瞬間にイベントを起こしたい時のために前回値として使用する。
+ */
+const previousCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE);
+/**
+ * 編集中のセルのアドレスを保持するための変数。
+ */
+const editingCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE);
+/**
+ * 列の範囲選択をする際の開始地点となるインデックスを保持するための変数。
+ * この開始地点からマウスが動いた地点までの範囲を選択する。
+ */
+const firstSelectionColumnIdx = ref<number>(CELL_ADDRESS_NONE.col);
+/**
+ * 行の範囲選択をする際の開始地点となるインデックスを保持するための変数。
+ * この開始地点からマウスが動いた地点までの範囲を選択する。
+ */
+const firstSelectionRowIdx = ref<number>(CELL_ADDRESS_NONE.row);
+
+/**
+ * 選択状態のセルを取得するための計算プロパティ。選択状態とは{@link GridCell.selected}がtrueのセルのこと。
+ */
+const selectedCell = computed(() => {
+ const selected = cells.value.flatMap(it => it.cells).filter(it => it.selected);
+ return selected.length > 0 ? selected[0] : undefined;
+});
+/**
+ * 範囲選択状態のセルを取得するための計算プロパティ。範囲選択状態とは{@link GridCell.ranged}がtrueのセルのこと。
+ */
+const rangedCells = computed(() => cells.value.flatMap(it => it.cells).filter(it => it.ranged));
+/**
+ * 範囲選択状態のセルの範囲を取得するための計算プロパティ。左上のセル番地と右下のセル番地を計算する。
+ */
+const rangedBounds = computed(() => {
+ const _cells = rangedCells.value;
+ const _cols = _cells.map(it => it.address.col);
+ const _rows = _cells.map(it => it.address.row);
+
+ const leftTop = {
+ col: Math.min(..._cols),
+ row: Math.min(..._rows),
+ };
+ const rightBottom = {
+ col: Math.max(..._cols),
+ row: Math.max(..._rows),
+ };
+
+ return {
+ leftTop,
+ rightBottom,
+ };
+});
+/**
+ * グリッドの中で使用可能なセルの範囲を取得するための計算プロパティ。左上のセル番地と右下のセル番地を計算する。
+ */
+const availableBounds = computed(() => {
+ const leftTop = {
+ col: 0,
+ row: 0,
+ };
+ const rightBottom = {
+ col: Math.max(...columns.value.map(it => it.index)),
+ row: Math.max(...rows.value.filter(it => it.using).map(it => it.index)),
+ };
+ return { leftTop, rightBottom };
+});
+/**
+ * 範囲選択状態の行を取得するための計算プロパティ。範囲選択状態とは{@link GridRow.ranged}がtrueの行のこと。
+ */
+const rangedRows = computed(() => rows.value.filter(it => it.ranged));
+
+const lastLine = computed(() => rows.value.filter(it => it.using).length - 1);
+
+// endregion
+// #endregion
+
+watch(data, patchData, { deep: true });
+
+if (_DEV_) {
+ watch(state, (value, oldValue) => {
+ console.log(`[grid][state] ${oldValue} -> ${value}`);
+ });
+}
+
+// #region Event Handlers
+// region Event Handlers
+
+function onResize(entries: ResizeObserverEntry[]) {
+ if (entries.length !== 1 || entries[0].target !== rootEl.value) {
+ return;
+ }
+
+ const contentRect = entries[0].contentRect;
+ if (_DEV_) {
+ console.log(`[grid][resize] contentRect: ${contentRect.width}x${contentRect.height}`);
+ }
+
+ switch (state.value) {
+ case 'hidden': {
+ if (contentRect.width > 0 && contentRect.height > 0) {
+ // 先に状態を変更しておき、再計算要求が複数回走らないようにする
+ state.value = 'normal';
+
+ // 選択状態が狂うかもしれないので解除しておく
+ unSelectionRangeAll();
+
+ // 再計算要求を発行。各セル側で最低限必要な横幅を算出し、emitで返してくるようになっている
+ bus.emit('forceRefreshContentSize');
+ }
+ break;
+ }
+ default: {
+ if (contentRect.width === 0 || contentRect.height === 0) {
+ state.value = 'hidden';
+ }
+ break;
+ }
+ }
+}
+
+function onKeyDown(ev: KeyboardEvent) {
+ const { ctrlKey, shiftKey, code } = ev;
+ if (_DEV_) {
+ console.log(`[grid][key] ctrl: ${ctrlKey}, shift: ${shiftKey}, code: ${code}`);
+ }
+
+ function updateSelectionRange(newBounds: { leftTop: CellAddress, rightBottom: CellAddress }) {
+ unSelectionOutOfRange(newBounds.leftTop, newBounds.rightBottom);
+ expandCellRange(newBounds.leftTop, newBounds.rightBottom);
+ }
+
+ switch (state.value) {
+ case 'normal': {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const selectedCellAddress = selectedCell.value?.address ?? CELL_ADDRESS_NONE;
+ const max = availableBounds.value;
+ const bounds = rangedBounds.value;
+
+ handleKeyEvent(ev, [
+ {
+ code: 'Delete', handler: () => {
+ if (rangedRows.value.length > 0) {
+ if (rowSetting.events.delete) {
+ rowSetting.events.delete(rangedRows.value);
+ }
+ } else {
+ const context = createContext();
+ removeDataFromGrid(context, (cell) => {
+ emitCellValue(cell, undefined);
+ });
+ }
+ },
+ },
+ {
+ code: 'KeyC', modifiers: ['Control'], handler: () => {
+ const context = createContext();
+ copyGridDataToClipboard(data.value, context);
+ },
+ },
+ {
+ code: 'KeyV', modifiers: ['Control'], handler: async () => {
+ const _cells = cells.value;
+ const context = createContext();
+ await pasteToGridFromClipboard(context, (row, col, parsedValue) => {
+ emitCellValue(_cells[row.index].cells[col.index], parsedValue);
+ });
+ },
+ },
+ {
+ code: 'ArrowRight', modifiers: ['Control', 'Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: { col: selectedCellAddress.col, row: bounds.leftTop.row },
+ rightBottom: { col: max.rightBottom.col, row: bounds.rightBottom.row },
+ });
+ },
+ },
+ {
+ code: 'ArrowLeft', modifiers: ['Control', 'Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: { col: max.leftTop.col, row: bounds.leftTop.row },
+ rightBottom: { col: selectedCellAddress.col, row: bounds.rightBottom.row },
+ });
+ },
+ },
+ {
+ code: 'ArrowUp', modifiers: ['Control', 'Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: { col: bounds.leftTop.col, row: max.leftTop.row },
+ rightBottom: { col: bounds.rightBottom.col, row: selectedCellAddress.row },
+ });
+ },
+ },
+ {
+ code: 'ArrowDown', modifiers: ['Control', 'Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: { col: bounds.leftTop.col, row: selectedCellAddress.row },
+ rightBottom: { col: bounds.rightBottom.col, row: max.rightBottom.row },
+ });
+ },
+ },
+ {
+ code: 'ArrowRight', modifiers: ['Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: {
+ col: bounds.leftTop.col < selectedCellAddress.col
+ ? bounds.leftTop.col + 1
+ : selectedCellAddress.col,
+ row: bounds.leftTop.row,
+ },
+ rightBottom: {
+ col: (bounds.rightBottom.col > selectedCellAddress.col || bounds.leftTop.col === selectedCellAddress.col)
+ ? bounds.rightBottom.col + 1
+ : selectedCellAddress.col,
+ row: bounds.rightBottom.row,
+ },
+ });
+ },
+ },
+ {
+ code: 'ArrowLeft', modifiers: ['Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: {
+ col: (bounds.leftTop.col < selectedCellAddress.col || bounds.rightBottom.col === selectedCellAddress.col)
+ ? bounds.leftTop.col - 1
+ : selectedCellAddress.col,
+ row: bounds.leftTop.row,
+ },
+ rightBottom: {
+ col: bounds.rightBottom.col > selectedCellAddress.col
+ ? bounds.rightBottom.col - 1
+ : selectedCellAddress.col,
+ row: bounds.rightBottom.row,
+ },
+ });
+ },
+ },
+ {
+ code: 'ArrowUp', modifiers: ['Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: {
+ col: bounds.leftTop.col,
+ row: (bounds.leftTop.row < selectedCellAddress.row || bounds.rightBottom.row === selectedCellAddress.row)
+ ? bounds.leftTop.row - 1
+ : selectedCellAddress.row,
+ },
+ rightBottom: {
+ col: bounds.rightBottom.col,
+ row: bounds.rightBottom.row > selectedCellAddress.row
+ ? bounds.rightBottom.row - 1
+ : selectedCellAddress.row,
+ },
+ });
+ },
+ },
+ {
+ code: 'ArrowDown', modifiers: ['Shift'], handler: () => {
+ updateSelectionRange({
+ leftTop: {
+ col: bounds.leftTop.col,
+ row: bounds.leftTop.row < selectedCellAddress.row
+ ? bounds.leftTop.row + 1
+ : selectedCellAddress.row,
+ },
+ rightBottom: {
+ col: bounds.rightBottom.col,
+ row: (bounds.rightBottom.row > selectedCellAddress.row || bounds.leftTop.row === selectedCellAddress.row)
+ ? bounds.rightBottom.row + 1
+ : selectedCellAddress.row,
+ },
+ });
+ },
+ },
+ {
+ code: 'ArrowDown', handler: () => {
+ selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row + 1 });
+ },
+ },
+ {
+ code: 'ArrowUp', handler: () => {
+ selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row - 1 });
+ },
+ },
+ {
+ code: 'ArrowRight', handler: () => {
+ selectionCell({ col: selectedCellAddress.col + 1, row: selectedCellAddress.row });
+ },
+ },
+ {
+ code: 'ArrowLeft', handler: () => {
+ selectionCell({ col: selectedCellAddress.col - 1, row: selectedCellAddress.row });
+ },
+ },
+ ]);
+
+ break;
+ }
+ }
+}
+
+function onMouseDown(ev: MouseEvent) {
+ switch (ev.button) {
+ case 0: {
+ onLeftMouseDown(ev);
+ break;
+ }
+ case 2: {
+ onRightMouseDown(ev);
+ break;
+ }
+ }
+}
+
+function onLeftMouseDown(ev: MouseEvent) {
+ const cellAddress = getCellAddress(ev.target as HTMLElement);
+ if (_DEV_) {
+ console.log(`[grid][mouse-left] state:${state.value}, button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`);
+ }
+
+ switch (state.value) {
+ case 'cellEditing': {
+ if (availableCellAddress(cellAddress) && !equalCellAddress(editingCellAddress.value, cellAddress)) {
+ selectionCell(cellAddress);
+ }
+ break;
+ }
+ case 'normal': {
+ if (availableCellAddress(cellAddress)) {
+ if (ev.shiftKey && selectedCell.value && !equalCellAddress(cellAddress, selectedCell.value.address)) {
+ const selectedCellAddress = selectedCell.value.address;
+
+ const leftTop = {
+ col: Math.min(selectedCellAddress.col, cellAddress.col),
+ row: Math.min(selectedCellAddress.row, cellAddress.row),
+ };
+
+ const rightBottom = {
+ col: Math.max(selectedCellAddress.col, cellAddress.col),
+ row: Math.max(selectedCellAddress.row, cellAddress.row),
+ };
+
+ unSelectionRangeAll();
+ expandCellRange(leftTop, rightBottom);
+
+ cells.value[selectedCellAddress.row].cells[selectedCellAddress.col].selected = true;
+ } else {
+ selectionCell(cellAddress);
+ }
+
+ previousCellAddress.value = cellAddress;
+
+ registerMouseUp();
+ registerMouseMove();
+ state.value = 'cellSelecting';
+ } else if (isColumnHeaderCellAddress(cellAddress)) {
+ if (ev.shiftKey) {
+ const rangedColumnIndexes = rangedCells.value.map(it => it.address.col);
+ const targetColumnIndexes = [cellAddress.col, ...rangedColumnIndexes];
+ unSelectionRangeAll();
+
+ const leftTop = {
+ col: Math.min(...targetColumnIndexes),
+ row: 0,
+ };
+
+ const rightBottom = {
+ col: Math.max(...targetColumnIndexes),
+ row: cells.value.length - 1,
+ };
+
+ expandCellRange(leftTop, rightBottom);
+
+ if (rangedColumnIndexes.length === 0) {
+ firstSelectionColumnIdx.value = cellAddress.col;
+ } else {
+ if (cellAddress.col > Math.min(...rangedColumnIndexes)) {
+ firstSelectionColumnIdx.value = Math.min(...rangedColumnIndexes);
+ } else {
+ firstSelectionColumnIdx.value = Math.max(...rangedColumnIndexes);
+ }
+ }
+ } else {
+ unSelectionRangeAll();
+
+ const colCells = cells.value.map(row => row.cells[cellAddress.col]);
+ selectionRange(...colCells.map(cell => cell.address));
+
+ firstSelectionColumnIdx.value = cellAddress.col;
+ }
+
+ registerMouseUp();
+ registerMouseMove();
+ previousCellAddress.value = cellAddress;
+ state.value = 'colSelecting';
+
+ // フォーカスを当てないとキーイベントが拾えないので
+ getCellElement(ev.target as HTMLElement)?.focus();
+ } else if (isRowNumberCellAddress(cellAddress)) {
+ if (ev.shiftKey) {
+ const rangedRowIndexes = rangedRows.value.map(it => it.index);
+ const targetRowIndexes = [cellAddress.row, ...rangedRowIndexes];
+ unSelectionRangeAll();
+
+ const leftTop = {
+ col: 0,
+ row: Math.min(...targetRowIndexes),
+ };
+
+ const rightBottom = {
+ col: Math.min(...cells.value.map(it => it.cells.length - 1)),
+ row: Math.max(...targetRowIndexes),
+ };
+
+ expandCellRange(leftTop, rightBottom);
+ expandRowRange(Math.min(...targetRowIndexes), Math.max(...targetRowIndexes));
+
+ if (rangedRowIndexes.length === 0) {
+ firstSelectionRowIdx.value = cellAddress.row;
+ } else {
+ if (cellAddress.col > Math.min(...rangedRowIndexes)) {
+ firstSelectionRowIdx.value = Math.min(...rangedRowIndexes);
+ } else {
+ firstSelectionRowIdx.value = Math.max(...rangedRowIndexes);
+ }
+ }
+ } else {
+ unSelectionRangeAll();
+ const rowCells = cells.value[cellAddress.row].cells;
+ selectionRange(...rowCells.map(cell => cell.address));
+ expandRowRange(cellAddress.row, cellAddress.row);
+
+ firstSelectionRowIdx.value = cellAddress.row;
+ }
+
+ registerMouseUp();
+ registerMouseMove();
+ previousCellAddress.value = cellAddress;
+ state.value = 'rowSelecting';
+
+ // フォーカスを当てないとキーイベントが拾えないので
+ getCellElement(ev.target as HTMLElement)?.focus();
+ }
+ break;
+ }
+ }
+}
+
+function onRightMouseDown(ev: MouseEvent) {
+ const cellAddress = getCellAddress(ev.target as HTMLElement);
+ if (_DEV_) {
+ console.log(`[grid][mouse-right] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`);
+ }
+
+ switch (state.value) {
+ case 'normal': {
+ if (!availableCellAddress(cellAddress)) {
+ return;
+ }
+
+ const _rangedCells = [...rangedCells.value];
+ if (!_rangedCells.some(it => equalCellAddress(it.address, cellAddress))) {
+ // 範囲選択外を右クリックした場合は、範囲選択を解除(範囲選択内であれば範囲選択を維持する)
+ selectionCell(cellAddress);
+ }
+
+ break;
+ }
+ }
+}
+
+function onMouseMove(ev: MouseEvent) {
+ ev.preventDefault();
+
+ const targetCellAddress = getCellAddress(ev.target as HTMLElement);
+ if (equalCellAddress(previousCellAddress.value, targetCellAddress)) {
+ // セルが変わるまでイベントを起こしたくない
+ return;
+ }
+
+ if (_DEV_) {
+ console.log(`[grid][mouse-move] button: ${ev.button}, cell: ${targetCellAddress.row}x${targetCellAddress.col}`);
+ }
+
+ switch (state.value) {
+ case 'cellSelecting': {
+ const selectedCellAddress = selectedCell.value?.address;
+ if (!availableCellAddress(targetCellAddress) || !selectedCellAddress) {
+ // 正しいセル範囲ではない
+ return;
+ }
+
+ const leftTop = {
+ col: Math.min(targetCellAddress.col, selectedCellAddress.col),
+ row: Math.min(targetCellAddress.row, selectedCellAddress.row),
+ };
+
+ const rightBottom = {
+ col: Math.max(targetCellAddress.col, selectedCellAddress.col),
+ row: Math.max(targetCellAddress.row, selectedCellAddress.row),
+ };
+
+ // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする
+ unSelectionOutOfRange(leftTop, rightBottom);
+ expandCellRange(leftTop, rightBottom);
+ previousCellAddress.value = targetCellAddress;
+
+ break;
+ }
+ case 'colSelecting': {
+ if (!isColumnHeaderCellAddress(targetCellAddress) || previousCellAddress.value.col === targetCellAddress.col) {
+ // セルが変わるまでイベントを起こしたくない
+ return;
+ }
+
+ const leftTop = {
+ col: Math.min(targetCellAddress.col, firstSelectionColumnIdx.value),
+ row: 0,
+ };
+
+ const rightBottom = {
+ col: Math.max(targetCellAddress.col, firstSelectionColumnIdx.value),
+ row: cells.value.length - 1,
+ };
+
+ // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする
+ unSelectionOutOfRange(leftTop, rightBottom);
+ expandCellRange(leftTop, rightBottom);
+ previousCellAddress.value = targetCellAddress;
+
+ // フォーカスを当てないとキーイベントが拾えないので
+ getCellElement(ev.target as HTMLElement)?.focus();
+
+ break;
+ }
+ case 'rowSelecting': {
+ if (!isRowNumberCellAddress(targetCellAddress) || previousCellAddress.value.row === targetCellAddress.row) {
+ // セルが変わるまでイベントを起こしたくない
+ return;
+ }
+
+ const leftTop = {
+ col: 0,
+ row: Math.min(targetCellAddress.row, firstSelectionRowIdx.value),
+ };
+
+ const rightBottom = {
+ col: Math.min(...cells.value.map(it => it.cells.length - 1)),
+ row: Math.max(targetCellAddress.row, firstSelectionRowIdx.value),
+ };
+
+ // 範囲外のセルは選択解除し、範囲内のセルは選択状態にする
+ unSelectionOutOfRange(leftTop, rightBottom);
+ expandCellRange(leftTop, rightBottom);
+
+ // 行も同様に
+ const rangedRowIndexes = [rows.value[targetCellAddress.row].index, ...rangedRows.value.map(it => it.index)];
+ expandRowRange(Math.min(...rangedRowIndexes), Math.max(...rangedRowIndexes));
+
+ previousCellAddress.value = targetCellAddress;
+
+ // フォーカスを当てないとキーイベントが拾えないので
+ getCellElement(ev.target as HTMLElement)?.focus();
+
+ break;
+ }
+ }
+}
+
+function onMouseUp(ev: MouseEvent) {
+ ev.preventDefault();
+ switch (state.value) {
+ case 'rowSelecting':
+ case 'colSelecting':
+ case 'cellSelecting': {
+ unregisterMouseUp();
+ unregisterMouseMove();
+ state.value = 'normal';
+ previousCellAddress.value = CELL_ADDRESS_NONE;
+ break;
+ }
+ }
+}
+
+function onContextMenu(ev: MouseEvent) {
+ const cellAddress = getCellAddress(ev.target as HTMLElement);
+ if (_DEV_) {
+ console.log(`[grid][context-menu] button: ${ev.button}, cell: ${cellAddress.row}x${cellAddress.col}`);
+ }
+
+ const context = createContext();
+ const menuItems = Array.of<MenuItem>();
+ switch (true) {
+ // 通常セルのコンテキストメニュー作成
+ case availableCellAddress(cellAddress): {
+ const cell = cells.value[cellAddress.row].cells[cellAddress.col];
+ if (cell.setting.contextMenuFactory) {
+ menuItems.push(...cell.setting.contextMenuFactory(cell.column, cell.row, cell.value, context));
+ }
+ break;
+ }
+ // 列ヘッダセルのコンテキストメニュー作成
+ case isColumnHeaderCellAddress(cellAddress): {
+ const col = columns.value[cellAddress.col];
+ if (col.setting.contextMenuFactory) {
+ menuItems.push(...col.setting.contextMenuFactory(col, context));
+ }
+ break;
+ }
+ // 行ヘッダセルのコンテキストメニュー作成
+ case isRowNumberCellAddress(cellAddress): {
+ const row = rows.value[cellAddress.row];
+ if (row.setting.contextMenuFactory) {
+ menuItems.push(...row.setting.contextMenuFactory(row, context));
+ }
+ break;
+ }
+ }
+
+ if (menuItems.length > 0) {
+ os.contextMenu(menuItems, ev);
+ }
+}
+
+function onCellEditBegin(sender: GridCell) {
+ state.value = 'cellEditing';
+ editingCellAddress.value = sender.address;
+ for (const cell of cells.value.flatMap(it => it.cells)) {
+ if (cell.address.col !== sender.address.col || cell.address.row !== sender.address.row) {
+ // 編集状態となったセル以外は全部選択解除
+ cell.selected = false;
+ }
+ }
+}
+
+function onCellEditEnd() {
+ editingCellAddress.value = CELL_ADDRESS_NONE;
+ state.value = 'normal';
+}
+
+function onChangeCellValue(sender: GridCell, newValue: CellValue) {
+ applyRowRules([sender]);
+ emitCellValue(sender, newValue);
+}
+
+function onChangeCellContentSize(sender: GridCell, contentSize: Size) {
+ const _cells = cells.value;
+ if (_cells.length > sender.address.row && _cells[sender.address.row].cells.length > sender.address.col) {
+ const currentSize = _cells[sender.address.row].cells[sender.address.col].contentSize;
+ if (currentSize.width !== contentSize.width || currentSize.height !== contentSize.height) {
+ // 通常セルのセル幅が確定したら、そのサイズを保持しておく(内容に引っ張られて想定よりも大きいセルサイズにならないようにするためのCSS作成に使用)
+ _cells[sender.address.row].cells[sender.address.col].contentSize = contentSize;
+
+ if (sender.column.setting.width === 'auto') {
+ calcLargestCellWidth(sender.column);
+ }
+ }
+ }
+}
+
+function onHeaderCellWidthBeginChange() {
+ switch (state.value) {
+ case 'normal': {
+ state.value = 'colResizing';
+ break;
+ }
+ }
+}
+
+function onHeaderCellWidthEndChange() {
+ switch (state.value) {
+ case 'colResizing': {
+ state.value = 'normal';
+ break;
+ }
+ }
+}
+
+function onHeaderCellChangeWidth(sender: GridColumn, width: string) {
+ switch (state.value) {
+ case 'colResizing': {
+ const column = columns.value[sender.index];
+ column.width = width;
+ break;
+ }
+ }
+}
+
+function onHeaderCellChangeContentSize(sender: GridColumn, newSize: Size) {
+ switch (state.value) {
+ case 'normal': {
+ const currentSize = columns.value[sender.index].contentSize;
+ if (currentSize.width !== newSize.width || currentSize.height !== newSize.height) {
+ // ヘッダセルのセル幅が確定したら、そのサイズを保持しておく(内容に引っ張られて想定よりも大きいセルサイズにならないようにするためのCSS作成に使用)
+ columns.value[sender.index].contentSize = newSize;
+
+ if (sender.setting.width === 'auto') {
+ calcLargestCellWidth(sender);
+ }
+ }
+ break;
+ }
+ }
+}
+
+function onHeaderCellWidthLargest(sender: GridColumn) {
+ switch (state.value) {
+ case 'normal': {
+ calcLargestCellWidth(sender);
+ break;
+ }
+ }
+}
+
+// endregion
+// #endregion
+
+// #region Methods
+// region Methods
+
+/**
+ * カラム内のコンテンツを表示しきるために必要な横幅と、各セルのコンテンツを表示しきるために必要な横幅を比較し、大きい方を列全体の横幅として採用する。
+ */
+function calcLargestCellWidth(column: GridColumn) {
+ const _cells = cells.value;
+ const largestColumnWidth = columns.value[column.index].contentSize.width;
+
+ const largestCellWidth = (_cells.length > 0)
+ ? _cells
+ .map(row => row.cells[column.index])
+ .reduce(
+ (acc, value) => Math.max(acc, value.contentSize.width),
+ 0,
+ )
+ : 0;
+
+ if (_DEV_) {
+ console.log(`[grid][calc-largest] idx:${column.setting.bindTo}, col:${largestColumnWidth}, cell:${largestCellWidth}`);
+ }
+
+ column.width = `${Math.max(largestColumnWidth, largestCellWidth)}px`;
+}
+
+/**
+ * {@link emit}を使用してイベントを発行する。
+ */
+function emitGridEvent(ev: GridEvent) {
+ const currentState: GridContext = {
+ selectedCell: selectedCell.value,
+ rangedCells: rangedCells.value,
+ rangedRows: rangedRows.value,
+ randedBounds: rangedBounds.value,
+ availableBounds: availableBounds.value,
+ state: state.value,
+ rows: rows.value,
+ columns: columns.value,
+ };
+
+ emit(
+ 'event',
+ ev,
+ currentState,
+ );
+}
+
+/**
+ * 親コンポーネントに新しい値を通知する。
+ * 新しい値は、イベント通知→元データへの反映→再計算(バリデーション含む)→再描画の流れで反映される。
+ */
+function emitCellValue(sender: GridCell | CellAddress, newValue: CellValue) {
+ const cellAddress = 'address' in sender ? sender.address : sender;
+ const cell = cells.value[cellAddress.row].cells[cellAddress.col];
+
+ emitGridEvent({
+ type: 'cell-value-change',
+ column: cell.column,
+ row: cell.row,
+ oldValue: cell.value,
+ newValue: newValue,
+ });
+
+ if (_DEV_) {
+ console.log(`[grid][cell-value] row:${cell.row}, col:${cell.column.index}, value:${newValue}`);
+ }
+}
+
+/**
+ * {@link target}のセルを選択状態にする。
+ * その際、{@link target}以外の行およびセルの範囲選択状態を解除する。
+ */
+function selectionCell(target: CellAddress) {
+ if (!availableCellAddress(target)) {
+ return;
+ }
+
+ unSelectionRangeAll();
+
+ const _cells = cells.value;
+ _cells[target.row].cells[target.col].selected = true;
+ _cells[target.row].cells[target.col].ranged = true;
+}
+
+/**
+ * {@link targets}のセルを範囲選択状態にする。
+ */
+function selectionRange(...targets: CellAddress[]) {
+ const _cells = cells.value;
+ for (const target of targets) {
+ const row = _cells[target.row];
+ if (row.row.using) {
+ row.cells[target.col].ranged = true;
+ }
+ }
+}
+
+/**
+ * 行およびセルの範囲選択状態をすべて解除する。
+ */
+function unSelectionRangeAll() {
+ const _cells = rangedCells.value;
+ for (const cell of _cells) {
+ cell.selected = false;
+ cell.ranged = false;
+ }
+
+ const _rows = rows.value.filter(it => it.using);
+ for (const row of _rows) {
+ row.ranged = false;
+ }
+}
+
+/**
+ * {@link leftTop}から{@link rightBottom}の範囲外にあるセルを範囲選択状態から外す。
+ */
+function unSelectionOutOfRange(leftTop: CellAddress, rightBottom: CellAddress) {
+ const safeBounds = getSafeAddressBounds({ leftTop, rightBottom });
+
+ const _cells = rangedCells.value;
+ for (const cell of _cells) {
+ const outOfRangeCol = cell.address.col < safeBounds.leftTop.col || cell.address.col > safeBounds.rightBottom.col;
+ const outOfRangeRow = cell.address.row < safeBounds.leftTop.row || cell.address.row > safeBounds.rightBottom.row;
+ if (outOfRangeCol || outOfRangeRow) {
+ cell.ranged = false;
+ }
+ }
+
+ const outOfRangeRows = rows.value.filter((_, index) => index < safeBounds.leftTop.row || index > safeBounds.rightBottom.row);
+ for (const row of outOfRangeRows) {
+ row.ranged = false;
+ }
+}
+
+/**
+ * {@link leftTop}から{@link rightBottom}の範囲内にあるセルを範囲選択状態にする。
+ */
+function expandCellRange(leftTop: CellAddress, rightBottom: CellAddress) {
+ const safeBounds = getSafeAddressBounds({ leftTop, rightBottom });
+ const targetRows = cells.value.slice(safeBounds.leftTop.row, safeBounds.rightBottom.row + 1);
+ for (const row of targetRows) {
+ for (const cell of row.cells.slice(safeBounds.leftTop.col, safeBounds.rightBottom.col + 1)) {
+ cell.ranged = true;
+ }
+ }
+}
+
+/**
+ * {@link top}から{@link bottom}までの行を範囲選択状態にする。
+ */
+function expandRowRange(top: number, bottom: number) {
+ if (!rowSetting.selectable) {
+ return;
+ }
+
+ const targetRows = rows.value.slice(top, bottom + 1);
+ for (const row of targetRows) {
+ row.ranged = true;
+ }
+}
+
+/**
+ * 特定の条件下でのみ適用されるCSSを反映する。
+ */
+function applyRowRules(targetCells: GridCell[]) {
+ const _rows = rows.value;
+ const targetRowIdxes = [...new Set(targetCells.map(it => it.address.row))];
+ const rowGroups = Array.of<{ row: GridRow, cells: GridCell[] }>();
+ for (const rowIdx of targetRowIdxes) {
+ const rowGroup = targetCells.filter(it => it.address.row === rowIdx);
+ rowGroups.push({ row: _rows[rowIdx], cells: rowGroup });
+ }
+
+ const _cells = cells.value;
+ for (const group of rowGroups.filter(it => it.row.using)) {
+ const row = group.row;
+ const targetCols = group.cells.map(it => it.column);
+ const rowCells = _cells[group.row.index].cells;
+
+ const newStyles = rowSetting.styleRules
+ .filter(it => it.condition({ row, targetCols, cells: rowCells }))
+ .map(it => it.applyStyle);
+
+ if (JSON.stringify(newStyles) !== JSON.stringify(row.additionalStyles)) {
+ row.additionalStyles = newStyles;
+ }
+ }
+}
+
+function availableCellAddress(cellAddress: CellAddress): boolean {
+ const safeBounds = availableBounds.value;
+ return cellAddress.row >= safeBounds.leftTop.row &&
+ cellAddress.col >= safeBounds.leftTop.col &&
+ cellAddress.row <= safeBounds.rightBottom.row &&
+ cellAddress.col <= safeBounds.rightBottom.col;
+}
+
+function isColumnHeaderCellAddress(cellAddress: CellAddress): boolean {
+ return cellAddress.row === -1 && cellAddress.col >= 0;
+}
+
+function isRowNumberCellAddress(cellAddress: CellAddress): boolean {
+ return cellAddress.row >= 0 && cellAddress.col === -1;
+}
+
+function getSafeAddressBounds(
+ bounds: { leftTop: CellAddress, rightBottom: CellAddress },
+): { leftTop: CellAddress, rightBottom: CellAddress } {
+ const available = availableBounds.value;
+
+ const safeLeftTop = {
+ col: Math.max(bounds.leftTop.col, available.leftTop.col),
+ row: Math.max(bounds.leftTop.row, available.leftTop.row),
+ };
+ const safeRightBottom = {
+ col: Math.min(bounds.rightBottom.col, available.rightBottom.col),
+ row: Math.min(bounds.rightBottom.row, available.rightBottom.row),
+ };
+
+ return { leftTop: safeLeftTop, rightBottom: safeRightBottom };
+}
+
+function registerMouseMove() {
+ unregisterMouseMove();
+ addEventListener('mousemove', onMouseMove);
+}
+
+function unregisterMouseMove() {
+ removeEventListener('mousemove', onMouseMove);
+}
+
+function registerMouseUp() {
+ unregisterMouseUp();
+ addEventListener('mouseup', onMouseUp);
+}
+
+function unregisterMouseUp() {
+ removeEventListener('mouseup', onMouseUp);
+}
+
+function createContext(): GridContext {
+ return {
+ selectedCell: selectedCell.value,
+ rangedCells: rangedCells.value,
+ rangedRows: rangedRows.value,
+ randedBounds: rangedBounds.value,
+ availableBounds: availableBounds.value,
+ state: state.value,
+ rows: rows.value,
+ columns: columns.value,
+ };
+}
+
+function refreshData() {
+ if (_DEV_) {
+ console.log('[grid][refresh-data][begin]');
+ }
+
+ // データを元に行・列・セルを作成する。
+ // 行は元データの配列の長さに応じて作成するが、最低限の行数は設定によって決まる。
+ // 行数が変わるたびに都度レンダリングするとパフォーマンスがイマイチなので、あらかじめ多めにセルを用意しておくための措置。
+ const _data: DataSource[] = data.value;
+ const _rows: GridRow[] = (_data.length > rowSetting.minimumDefinitionCount)
+ ? _data.map((_, index) => createRow(index, true, rowSetting))
+ : Array.from({ length: rowSetting.minimumDefinitionCount }, (_, index) => createRow(index, index < _data.length, rowSetting));
+ const _cols: GridColumn[] = columns.value;
+
+ // 行・列の定義から、元データの配列より値を取得してセルを作成する。
+ // 行・列の定義はそれぞれインデックスを持っており、そのインデックスは元データの配列番地に対応している。
+ const _cells: RowHolder[] = _rows.map(row => {
+ const newCells = row.using
+ ? _cols.map(col => createCell(col, row, _data[row.index][col.setting.bindTo], cellSettings))
+ : _cols.map(col => createCell(col, row, undefined, cellSettings));
+
+ return { row, cells: newCells, origin: _data[row.index] };
+ });
+
+ rows.value = _rows;
+ cells.value = _cells;
+
+ const allCells = _cells.filter(it => it.row.using).flatMap(it => it.cells);
+ for (const cell of allCells) {
+ cell.violation = cellValidation(allCells, cell, cell.value);
+ }
+
+ applyRowRules(allCells);
+
+ if (_DEV_) {
+ console.log('[grid][refresh-data][end]');
+ }
+}
+
+/**
+ * セル値を部分更新する。この関数は、外部起因でデータが変更された場合に呼ばれる。
+ *
+ * 外部起因でデータが変更された場合は{@link data}の値が変更されるが、何処の番地がどのように変わったのかまでは検知できない。
+ * セルをすべて作り直せばいいが、その手法だと以下のデメリットがある。
+ * - 描画負荷がかかる
+ * - 各セルが持つ個別の状態(選択中状態やバリデーション結果など)が失われる
+ *
+ * そこで、新しい値とセルが持つ値を突き合わせ、変更があった場合のみ値を更新し、セルそのものは使いまわしつつ値を最新化する。
+ */
+function patchData(newItems: DataSource[]) {
+ if (_DEV_) {
+ console.log('[grid][patch-data][begin]');
+ }
+
+ const _cols = columns.value;
+
+ if (rows.value.length < newItems.length) {
+ const newRows = Array.of<GridRow>();
+ const newCells = Array.of<RowHolder>();
+
+ // 未使用の行を含めても足りないので新しい行を追加する
+ for (let rowIdx = rows.value.length; rowIdx < newItems.length; rowIdx++) {
+ const newRow = createRow(rowIdx, true, rowSetting);
+ newRows.push(newRow);
+ newCells.push({
+ row: newRow,
+ cells: _cols.map(col => createCell(col, newRow, newItems[rowIdx][col.setting.bindTo], cellSettings)),
+ origin: newItems[rowIdx],
+ });
+ }
+
+ rows.value.push(...newRows);
+ cells.value.push(...newCells);
+
+ applyRowRules(newCells.flatMap(it => it.cells));
+ }
+
+ // 行数の上限が欲しい場合はここに設けてもいいかもしれない
+
+ const usingRows = rows.value.filter(it => it.using);
+ if (usingRows.length > newItems.length) {
+ // 行数が減っているので古い行をクリアする(再マウント・再レンダリングが重いので要素そのものは消さない)
+ for (let rowIdx = newItems.length; rowIdx < usingRows.length; rowIdx++) {
+ resetRow(rows.value[rowIdx]);
+ for (let colIdx = 0; colIdx < _cols.length; colIdx++) {
+ const holder = cells.value[rowIdx];
+ holder.origin = {};
+ resetCell(holder.cells[colIdx]);
+ }
+ }
+ }
+
+ // 新しい値と既に設定されていた値を入れ替える
+ const changedCells = Array.of<GridCell>();
+ for (let rowIdx = 0; rowIdx < newItems.length; rowIdx++) {
+ const holder = cells.value[rowIdx];
+ holder.row.using = true;
+
+ const oldCells = holder.cells;
+ const newItem = newItems[rowIdx];
+ for (let colIdx = 0; colIdx < oldCells.length; colIdx++) {
+ const _col = columns.value[colIdx];
+
+ const oldCell = oldCells[colIdx];
+ const newValue = newItem[_col.setting.bindTo];
+ if (oldCell.value !== newValue) {
+ oldCell.value = _col.setting.valueTransformer
+ ? _col.setting.valueTransformer(holder.row, _col, newValue)
+ : newValue;
+ changedCells.push(oldCell);
+ }
+ }
+ }
+
+ if (changedCells.length > 0) {
+ const allCells = cells.value.slice(0, newItems.length).flatMap(it => it.cells);
+ for (const cell of allCells) {
+ cell.violation = cellValidation(allCells, cell, cell.value);
+ }
+
+ applyRowRules(changedCells);
+
+ // セル値が書き換わっており、バリデーションの結果も変わっているので外部に通知する必要がある
+ emitGridEvent({
+ type: 'cell-validation',
+ all: cells.value
+ .filter(it => it.row.using)
+ .flatMap(it => it.cells)
+ .map(it => it.violation)
+ .filter(it => !it.valid),
+ });
+ }
+
+ if (_DEV_) {
+ console.log('[grid][patch-data][end]');
+ }
+}
+
+// endregion
+// #endregion
+
+onMounted(() => {
+ state.value = 'normal';
+
+ const bindToList = columnSettings.map(it => it.bindTo);
+ if (new Set(bindToList).size !== columnSettings.length) {
+ // 取得元のプロパティ名重複は許容したくない
+ throw new Error(`Duplicate bindTo setting : [${bindToList.join(',')}]}]`);
+ }
+
+ if (rootEl.value) {
+ resizeObserver.observe(rootEl.value);
+
+ // 初期表示時にコンテンツが表示されていない場合はhidden状態にしておく。
+ // コンテンツ表示時にresizeイベントが発生するが、そのときにhidden状態にしておかないとサイズの再計算が走らないので
+ const bounds = rootEl.value.getBoundingClientRect();
+ if (bounds.width === 0 || bounds.height === 0) {
+ state.value = 'hidden';
+ }
+ }
+
+ refreshData();
+});
+</script>
+
+<style module lang="scss">
+.grid {
+ font-size: 90%;
+ overflow-x: scroll;
+ // firefoxだとスクロールバーがセルに重なって見づらくなってしまうのでスペースを空けておく
+ padding-bottom: 8px;
+}
+</style>
+
+<style lang="scss">
+$borderSetting: solid 0.5px var(--MI_THEME-divider);
+$borderRadius: var(--MI-radius);
+
+// 配下コンポーネントを含めて一括してコントロールするため、scopedもmoduleも使用できない
+.mk_grid_border {
+ border-spacing: 0;
+
+ .mk_grid_thead {
+ .mk_grid_tr {
+ .mk_grid_th {
+ border-left: $borderSetting;
+ border-top: $borderSetting;
+
+ &:first-child {
+ // 左上セル
+ border-top-left-radius: $borderRadius;
+ }
+
+ &:last-child {
+ // 右上セル
+ border-top-right-radius: $borderRadius;
+ border-right: $borderSetting;
+ }
+ }
+ }
+ }
+
+ .mk_grid_tbody {
+ .mk_grid_tr {
+ .mk_grid_td, .mk_grid_th {
+ border-left: $borderSetting;
+ border-top: $borderSetting;
+
+ &:last-child {
+ // 一番右端の列
+ border-right: $borderSetting;
+ }
+ }
+ }
+
+ .last_row {
+ .mk_grid_td, .mk_grid_th {
+ // 一番下の行
+ border-bottom: $borderSetting;
+
+ &:first-child {
+ // 左下セル
+ border-bottom-left-radius: $borderRadius;
+ }
+
+ &:last-child {
+ // 右下セル
+ border-bottom-right-radius: $borderRadius;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkHeaderCell.vue b/packages/frontend/src/components/grid/MkHeaderCell.vue
new file mode 100644
index 0000000000..605d27c6d6
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkHeaderCell.vue
@@ -0,0 +1,216 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ ref="rootEl"
+ class="mk_grid_th"
+ :class="$style.cell"
+ :style="[{ maxWidth: column.width, minWidth: column.width, width: column.width }]"
+ data-grid-cell
+ :data-grid-cell-row="-1"
+ :data-grid-cell-col="column.index"
+>
+ <div :class="$style.root">
+ <div :class="$style.left"/>
+ <div :class="$style.wrapper">
+ <div ref="contentEl" :class="$style.contentArea">
+ <span v-if="column.setting.icon" class="ti" :class="column.setting.icon" style="line-height: normal"/>
+ <span v-else>{{ text }}</span>
+ </div>
+ </div>
+ <div
+ :class="$style.right"
+ @mousedown="onHandleMouseDown"
+ @dblclick="onHandleDoubleClick"
+ />
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue';
+import { GridEventEmitter, Size } from '@/components/grid/grid.js';
+import { GridColumn } from '@/components/grid/column.js';
+
+const emit = defineEmits<{
+ (ev: 'operation:beginWidthChange', sender: GridColumn): void;
+ (ev: 'operation:endWidthChange', sender: GridColumn): void;
+ (ev: 'operation:widthLargest', sender: GridColumn): void;
+ (ev: 'change:width', sender: GridColumn, width: string): void;
+ (ev: 'change:contentSize', sender: GridColumn, newSize: Size): void;
+}>();
+const props = defineProps<{
+ column: GridColumn,
+ bus: GridEventEmitter,
+}>();
+
+const { column, bus } = toRefs(props);
+
+const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>();
+const contentEl = ref<InstanceType<typeof HTMLDivElement>>();
+
+const resizing = ref<boolean>(false);
+
+const text = computed(() => {
+ const result = column.value.setting.title ?? column.value.setting.bindTo;
+ return result.length > 0 ? result : ' ';
+});
+
+watch(column, () => {
+ // 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する
+ nextTick(emitContentSizeChanged);
+}, { immediate: true });
+
+function onHandleDoubleClick(ev: MouseEvent) {
+ switch (ev.type) {
+ case 'dblclick': {
+ emit('operation:widthLargest', column.value);
+ break;
+ }
+ }
+}
+
+function onHandleMouseDown(ev: MouseEvent) {
+ switch (ev.type) {
+ case 'mousedown': {
+ if (!resizing.value) {
+ registerHandleMouseUp();
+ registerHandleMouseMove();
+ resizing.value = true;
+ emit('operation:beginWidthChange', column.value);
+ }
+ break;
+ }
+ }
+}
+
+function onHandleMouseMove(ev: MouseEvent) {
+ if (!rootEl.value) {
+ // 型ガード
+ return;
+ }
+
+ switch (ev.type) {
+ case 'mousemove': {
+ if (resizing.value) {
+ const bounds = rootEl.value.getBoundingClientRect();
+ const clientWidth = rootEl.value.clientWidth;
+ const clientRight = bounds.left + clientWidth;
+ const nextWidth = clientWidth + (ev.clientX - clientRight);
+ emit('change:width', column.value, `${nextWidth}px`);
+ }
+ break;
+ }
+ }
+}
+
+function onHandleMouseUp(ev: MouseEvent) {
+ switch (ev.type) {
+ case 'mouseup': {
+ if (resizing.value) {
+ unregisterHandleMouseUp();
+ unregisterHandleMouseMove();
+ resizing.value = false;
+ emit('operation:endWidthChange', column.value);
+ }
+ break;
+ }
+ }
+}
+
+function onForceRefreshContentSize() {
+ emitContentSizeChanged();
+}
+
+function registerHandleMouseMove() {
+ unregisterHandleMouseMove();
+ addEventListener('mousemove', onHandleMouseMove);
+}
+
+function unregisterHandleMouseMove() {
+ removeEventListener('mousemove', onHandleMouseMove);
+}
+
+function registerHandleMouseUp() {
+ unregisterHandleMouseUp();
+ addEventListener('mouseup', onHandleMouseUp);
+}
+
+function unregisterHandleMouseUp() {
+ removeEventListener('mouseup', onHandleMouseUp);
+}
+
+function emitContentSizeChanged() {
+ const clientWidth = contentEl.value?.clientWidth ?? 0;
+ const clientHeight = contentEl.value?.clientHeight ?? 0;
+ emit('change:contentSize', column.value, {
+ // バーの横幅も考慮したいので、+3px
+ width: clientWidth + 3 + 3,
+ height: clientHeight,
+ });
+}
+
+onMounted(() => {
+ bus.value.on('forceRefreshContentSize', onForceRefreshContentSize);
+});
+
+onUnmounted(() => {
+ bus.value.off('forceRefreshContentSize', onForceRefreshContentSize);
+});
+
+</script>
+
+<style module lang="scss">
+$handleWidth: 5px;
+$cellHeight: 28px;
+
+.cell {
+ cursor: pointer;
+}
+
+.root {
+ display: flex;
+ flex-direction: row;
+ height: $cellHeight;
+ max-height: $cellHeight;
+ min-height: $cellHeight;
+
+ .wrapper {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ justify-content: center;
+ }
+
+ .contentArea {
+ display: flex;
+ padding: 6px 4px;
+ box-sizing: border-box;
+ overflow: hidden;
+ white-space: nowrap;
+ text-align: center;
+ }
+
+ .left {
+ // rightのぶんだけズレるのでそれを相殺するためのネガティブマージン
+ margin-left: -$handleWidth;
+ margin-right: auto;
+ width: $handleWidth;
+ min-width: $handleWidth;
+ }
+
+ .right {
+ margin-left: auto;
+ // 判定を罫線の上に重ねたいのでネガティブマージンを使う
+ margin-right: -$handleWidth;
+ width: $handleWidth;
+ min-width: $handleWidth;
+ cursor: w-resize;
+ z-index: 1;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkHeaderRow.vue b/packages/frontend/src/components/grid/MkHeaderRow.vue
new file mode 100644
index 0000000000..8affa08fd5
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkHeaderRow.vue
@@ -0,0 +1,60 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ class="mk_grid_tr"
+ :class="$style.root"
+ :data-grid-row="-1"
+>
+ <MkNumberCell
+ v-if="gridSetting.showNumber"
+ content="#"
+ :top="true"
+ />
+ <MkHeaderCell
+ v-for="column in columns"
+ :key="column.index"
+ :column="column"
+ :bus="bus"
+ @operation:beginWidthChange="(sender) => emit('operation:beginWidthChange', sender)"
+ @operation:endWidthChange="(sender) => emit('operation:endWidthChange', sender)"
+ @operation:widthLargest="(sender) => emit('operation:widthLargest', sender)"
+ @change:width="(sender, width) => emit('change:width', sender, width)"
+ @change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
+ />
+</div>
+</template>
+
+<script setup lang="ts">
+import { GridEventEmitter, Size } from '@/components/grid/grid.js';
+import MkHeaderCell from '@/components/grid/MkHeaderCell.vue';
+import MkNumberCell from '@/components/grid/MkNumberCell.vue';
+import { GridColumn } from '@/components/grid/column.js';
+import { GridRowSetting } from '@/components/grid/row.js';
+
+const emit = defineEmits<{
+ (ev: 'operation:beginWidthChange', sender: GridColumn): void;
+ (ev: 'operation:endWidthChange', sender: GridColumn): void;
+ (ev: 'operation:widthLargest', sender: GridColumn): void;
+ (ev: 'operation:selectionColumn', sender: GridColumn): void;
+ (ev: 'change:width', sender: GridColumn, width: string): void;
+ (ev: 'change:contentSize', sender: GridColumn, newSize: Size): void;
+}>();
+
+defineProps<{
+ columns: GridColumn[],
+ gridSetting: GridRowSetting,
+ bus: GridEventEmitter,
+}>();
+</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+</style>
diff --git a/packages/frontend/src/components/grid/MkNumberCell.vue b/packages/frontend/src/components/grid/MkNumberCell.vue
new file mode 100644
index 0000000000..674bba96bc
--- /dev/null
+++ b/packages/frontend/src/components/grid/MkNumberCell.vue
@@ -0,0 +1,61 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ class="mk_grid_th"
+ :class="[$style.cell]"
+ :tabindex="-1"
+ data-grid-cell
+ :data-grid-cell-row="row?.index ?? -1"
+ :data-grid-cell-col="-1"
+>
+ <div :class="[$style.root]">
+ {{ content }}
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+
+import { GridRow } from '@/components/grid/row.js';
+
+defineProps<{
+ content: string,
+ row?: GridRow,
+}>();
+
+</script>
+
+<style module lang="scss">
+$cellHeight: 28px;
+$cellWidth: 34px;
+
+.cell {
+ overflow: hidden;
+ white-space: nowrap;
+ height: $cellHeight;
+ max-height: $cellHeight;
+ min-height: $cellHeight;
+ min-width: $cellWidth;
+ width: $cellWidth;
+ cursor: pointer;
+}
+
+.root {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ box-sizing: border-box;
+ padding: 0 8px;
+ height: 100%;
+ border: solid 0.5px transparent;
+
+ &.selected {
+ background-color: var(--MI_THEME-accentedBg);
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/grid/cell-validators.ts b/packages/frontend/src/components/grid/cell-validators.ts
new file mode 100644
index 0000000000..949cab2ec6
--- /dev/null
+++ b/packages/frontend/src/components/grid/cell-validators.ts
@@ -0,0 +1,110 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridColumn } from '@/components/grid/column.js';
+import { GridRow } from '@/components/grid/row.js';
+import { i18n } from '@/i18n.js';
+
+export type ValidatorParams = {
+ column: GridColumn;
+ row: GridRow;
+ value: CellValue;
+ allCells: GridCell[];
+};
+
+export type ValidatorResult = {
+ valid: boolean;
+ message?: string;
+}
+
+export type GridCellValidator = {
+ name?: string;
+ ignoreViolation?: boolean;
+ validate: (params: ValidatorParams) => ValidatorResult;
+}
+
+export type ValidateViolation = {
+ valid: boolean;
+ params: ValidatorParams;
+ violations: ValidateViolationItem[];
+}
+
+export type ValidateViolationItem = {
+ valid: boolean;
+ validator: GridCellValidator;
+ result: ValidatorResult;
+}
+
+export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation {
+ const { column, row } = cell;
+ const validators = column.setting.validators ?? [];
+
+ const params: ValidatorParams = {
+ column,
+ row,
+ value: newValue,
+ allCells,
+ };
+
+ const violations: ValidateViolationItem[] = validators.map(validator => {
+ const result = validator.validate(params);
+ return {
+ valid: result.valid,
+ validator,
+ result,
+ };
+ });
+
+ return {
+ valid: violations.every(v => v.result.valid),
+ params,
+ violations,
+ };
+}
+
+class ValidatorPreset {
+ required(): GridCellValidator {
+ return {
+ name: 'required',
+ validate: ({ value }): ValidatorResult => {
+ return {
+ valid: value !== null && value !== undefined && value !== '',
+ message: i18n.ts._gridComponent._error.requiredValue,
+ };
+ },
+ };
+ }
+
+ regex(pattern: RegExp): GridCellValidator {
+ return {
+ name: 'regex',
+ validate: ({ value }): ValidatorResult => {
+ return {
+ valid: (typeof value !== 'string') || pattern.test(value.toString() ?? ''),
+ message: i18n.tsx._gridComponent._error.patternNotMatch({ pattern: pattern.source }),
+ };
+ },
+ };
+ }
+
+ unique(): GridCellValidator {
+ return {
+ name: 'unique',
+ validate: ({ column, row, value, allCells }): ValidatorResult => {
+ const bindTo = column.setting.bindTo;
+ const isUnique = allCells
+ .filter(it => it.column.setting.bindTo === bindTo && it.row.index !== row.index)
+ .every(cell => cell.value !== value);
+ return {
+ valid: isUnique,
+ message: i18n.ts._gridComponent._error.notUnique,
+ };
+ },
+ };
+ }
+}
+
+export const validators = new ValidatorPreset();
diff --git a/packages/frontend/src/components/grid/cell.ts b/packages/frontend/src/components/grid/cell.ts
new file mode 100644
index 0000000000..71b7a3e3f1
--- /dev/null
+++ b/packages/frontend/src/components/grid/cell.ts
@@ -0,0 +1,88 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { ValidateViolation } from '@/components/grid/cell-validators.js';
+import { Size } from '@/components/grid/grid.js';
+import { GridColumn } from '@/components/grid/column.js';
+import { GridRow } from '@/components/grid/row.js';
+import { MenuItem } from '@/types/menu.js';
+import { GridContext } from '@/components/grid/grid-event.js';
+
+export type CellValue = string | boolean | number | undefined | null | Array<unknown> | NonNullable<unknown>;
+
+export type CellAddress = {
+ row: number;
+ col: number;
+}
+
+export const CELL_ADDRESS_NONE: CellAddress = {
+ row: -1,
+ col: -1,
+};
+
+export type GridCell = {
+ address: CellAddress;
+ value: CellValue;
+ column: GridColumn;
+ row: GridRow;
+ selected: boolean;
+ ranged: boolean;
+ contentSize: Size;
+ setting: GridCellSetting;
+ violation: ValidateViolation;
+}
+
+export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[];
+
+export type GridCellSetting = {
+ contextMenuFactory?: GridCellContextMenuFactory;
+}
+
+export function createCell(
+ column: GridColumn,
+ row: GridRow,
+ value: CellValue,
+ setting: GridCellSetting,
+): GridCell {
+ const newValue = (row.using && column.setting.valueTransformer)
+ ? column.setting.valueTransformer(row, column, value)
+ : value;
+
+ return {
+ address: { row: row.index, col: column.index },
+ value: newValue,
+ column,
+ row,
+ selected: false,
+ ranged: false,
+ contentSize: { width: 0, height: 0 },
+ violation: {
+ valid: true,
+ params: {
+ column,
+ row,
+ value,
+ allCells: [],
+ },
+ violations: [],
+ },
+ setting,
+ };
+}
+
+export function resetCell(cell: GridCell): void {
+ cell.selected = false;
+ cell.ranged = false;
+ cell.violation = {
+ valid: true,
+ params: {
+ column: cell.column,
+ row: cell.row,
+ value: cell.value,
+ allCells: [],
+ },
+ violations: [],
+ };
+}
diff --git a/packages/frontend/src/components/grid/column.ts b/packages/frontend/src/components/grid/column.ts
new file mode 100644
index 0000000000..2f505756fe
--- /dev/null
+++ b/packages/frontend/src/components/grid/column.ts
@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { GridCellValidator } from '@/components/grid/cell-validators.js';
+import { Size, SizeStyle } from '@/components/grid/grid.js';
+import { calcCellWidth } from '@/components/grid/grid-utils.js';
+import { CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridRow } from '@/components/grid/row.js';
+import { MenuItem } from '@/types/menu.js';
+import { GridContext } from '@/components/grid/grid-event.js';
+
+export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden';
+
+export type CustomValueEditor = (row: GridRow, col: GridColumn, value: CellValue, cellElement: HTMLElement) => Promise<CellValue>;
+export type CellValueTransformer = (row: GridRow, col: GridColumn, value: CellValue) => CellValue;
+export type GridColumnContextMenuFactory = (col: GridColumn, context: GridContext) => MenuItem[];
+
+export type GridColumnSetting = {
+ bindTo: string;
+ title?: string;
+ icon?: string;
+ type: ColumnType;
+ width: SizeStyle;
+ editable?: boolean;
+ validators?: GridCellValidator[];
+ customValueEditor?: CustomValueEditor;
+ valueTransformer?: CellValueTransformer;
+ contextMenuFactory?: GridColumnContextMenuFactory;
+ events?: {
+ copy?: (value: CellValue) => string;
+ paste?: (text: string) => CellValue;
+ delete?: (cell: GridCell, context: GridContext) => void;
+ }
+};
+
+export type GridColumn = {
+ index: number;
+ setting: GridColumnSetting;
+ width: string;
+ contentSize: Size;
+}
+
+export function createColumn(setting: GridColumnSetting, index: number): GridColumn {
+ return {
+ index,
+ setting,
+ width: calcCellWidth(setting.width),
+ contentSize: { width: 0, height: 0 },
+ };
+}
+
diff --git a/packages/frontend/src/components/grid/grid-event.ts b/packages/frontend/src/components/grid/grid-event.ts
new file mode 100644
index 0000000000..074b72b956
--- /dev/null
+++ b/packages/frontend/src/components/grid/grid-event.ts
@@ -0,0 +1,46 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridState } from '@/components/grid/grid.js';
+import { ValidateViolation } from '@/components/grid/cell-validators.js';
+import { GridColumn } from '@/components/grid/column.js';
+import { GridRow } from '@/components/grid/row.js';
+
+export type GridContext = {
+ selectedCell?: GridCell;
+ rangedCells: GridCell[];
+ rangedRows: GridRow[];
+ randedBounds: {
+ leftTop: CellAddress;
+ rightBottom: CellAddress;
+ };
+ availableBounds: {
+ leftTop: CellAddress;
+ rightBottom: CellAddress;
+ };
+ state: GridState;
+ rows: GridRow[];
+ columns: GridColumn[];
+};
+
+export type GridEvent =
+ GridCellValueChangeEvent |
+ GridCellValidationEvent
+ ;
+
+export type GridCellValueChangeEvent = {
+ type: 'cell-value-change';
+ column: GridColumn;
+ row: GridRow;
+ oldValue: CellValue;
+ newValue: CellValue;
+};
+
+export type GridCellValidationEvent = {
+ type: 'cell-validation';
+ violation?: ValidateViolation;
+ all: ValidateViolation[];
+};
diff --git a/packages/frontend/src/components/grid/grid-utils.ts b/packages/frontend/src/components/grid/grid-utils.ts
new file mode 100644
index 0000000000..a45bc88926
--- /dev/null
+++ b/packages/frontend/src/components/grid/grid-utils.ts
@@ -0,0 +1,215 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { isRef, Ref } from 'vue';
+import { DataSource, SizeStyle } from '@/components/grid/grid.js';
+import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
+import { GridRow } from '@/components/grid/row.js';
+import { GridContext } from '@/components/grid/grid-event.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { GridColumn, GridColumnSetting } from '@/components/grid/column.js';
+
+export function isCellElement(elem: HTMLElement): boolean {
+ return elem.hasAttribute('data-grid-cell');
+}
+
+export function isRowElement(elem: HTMLElement): boolean {
+ return elem.hasAttribute('data-grid-row');
+}
+
+export function calcCellWidth(widthSetting: SizeStyle): string {
+ switch (widthSetting) {
+ case undefined:
+ case 'auto': {
+ return 'auto';
+ }
+ default: {
+ return `${widthSetting}px`;
+ }
+ }
+}
+
+function getCellRowByAttribute(elem: HTMLElement): number {
+ const row = elem.getAttribute('data-grid-cell-row');
+ if (row === null) {
+ throw new Error('data-grid-cell-row attribute not found');
+ }
+ return Number(row);
+}
+
+function getCellColByAttribute(elem: HTMLElement): number {
+ const col = elem.getAttribute('data-grid-cell-col');
+ if (col === null) {
+ throw new Error('data-grid-cell-col attribute not found');
+ }
+ return Number(col);
+}
+
+export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress {
+ let node = elem;
+ for (let i = 0; i < parentNodeCount; i++) {
+ if (!node.parentElement) {
+ break;
+ }
+
+ if (isCellElement(node) && isRowElement(node.parentElement)) {
+ const row = getCellRowByAttribute(node);
+ const col = getCellColByAttribute(node);
+
+ return { row, col };
+ }
+
+ node = node.parentElement;
+ }
+
+ return CELL_ADDRESS_NONE;
+}
+
+export function getCellElement(elem: HTMLElement, parentNodeCount = 10): HTMLElement | null {
+ let node = elem;
+ for (let i = 0; i < parentNodeCount; i++) {
+ if (isCellElement(node)) {
+ return node;
+ }
+
+ if (!node.parentElement) {
+ break;
+ }
+
+ node = node.parentElement;
+ }
+
+ return null;
+}
+
+export function equalCellAddress(a: CellAddress, b: CellAddress): boolean {
+ return a.row === b.row && a.col === b.col;
+}
+
+/**
+ * グリッドの選択範囲の内容をタブ区切り形式テキストに変換してクリップボードにコピーする。
+ */
+export function copyGridDataToClipboard(
+ gridItems: Ref<DataSource[]> | DataSource[],
+ context: GridContext,
+) {
+ const items = isRef(gridItems) ? gridItems.value : gridItems;
+ const lines = Array.of<string>();
+ const bounds = context.randedBounds;
+
+ for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
+ const rowItems = Array.of<string>();
+ for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
+ const { bindTo, events } = context.columns[col].setting;
+ const value = items[row][bindTo];
+ const transformValue = events?.copy
+ ? events.copy(value)
+ : typeof value === 'object' || Array.isArray(value)
+ ? JSON.stringify(value)
+ : value?.toString() ?? '';
+ rowItems.push(transformValue);
+ }
+ lines.push(rowItems.join('\t'));
+ }
+
+ const text = lines.join('\n');
+ copyToClipboard(text);
+
+ if (_DEV_) {
+ console.log(`Copied to clipboard: ${text}`);
+ }
+}
+
+/**
+ * クリップボードからタブ区切りテキストとして値を読み取り、グリッドの選択範囲に貼り付けるためのユーティリティ関数。
+ * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。
+ */
+export async function pasteToGridFromClipboard(
+ context: GridContext,
+ callback: (row: GridRow, col: GridColumn, parsedValue: CellValue) => void,
+) {
+ function parseValue(value: string, setting: GridColumnSetting): CellValue {
+ if (setting.events?.paste) {
+ return setting.events.paste(value);
+ } else {
+ switch (setting.type) {
+ case 'number': {
+ return Number(value);
+ }
+ case 'boolean': {
+ return value === 'true';
+ }
+ default: {
+ return value;
+ }
+ }
+ }
+ }
+
+ const clipBoardText = await navigator.clipboard.readText();
+ if (_DEV_) {
+ console.log(`Paste from clipboard: ${clipBoardText}`);
+ }
+
+ const bounds = context.randedBounds;
+ const lines = clipBoardText.replace(/\r/g, '')
+ .split('\n')
+ .map(it => it.split('\t'));
+
+ if (lines.length === 1 && lines[0].length === 1) {
+ // 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける
+ const ranges = context.rangedCells;
+ for (const cell of ranges) {
+ if (cell.column.setting.editable) {
+ callback(cell.row, cell.column, parseValue(lines[0][0], cell.column.setting));
+ }
+ }
+ } else {
+ // 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける
+ const offsetRow = bounds.leftTop.row;
+ const offsetCol = bounds.leftTop.col;
+ const { columns, rows } = context;
+ for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
+ const rowIdx = row - offsetRow;
+ if (lines.length <= rowIdx) {
+ // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る
+ break;
+ }
+
+ const items = lines[rowIdx];
+ for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
+ const colIdx = col - offsetCol;
+ if (items.length <= colIdx) {
+ // クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る
+ break;
+ }
+
+ if (columns[col].setting.editable) {
+ callback(rows[row], columns[col], parseValue(items[colIdx], columns[col].setting));
+ }
+ }
+ }
+ }
+}
+
+/**
+ * グリッドの選択範囲にあるデータを削除するためのユーティリティ関数。
+ * …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。
+ */
+export function removeDataFromGrid(
+ context: GridContext,
+ callback: (cell: GridCell) => void,
+) {
+ for (const cell of context.rangedCells) {
+ const { editable, events } = cell.column.setting;
+ if (editable) {
+ if (events?.delete) {
+ events.delete(cell, context);
+ } else {
+ callback(cell);
+ }
+ }
+ }
+}
diff --git a/packages/frontend/src/components/grid/grid.ts b/packages/frontend/src/components/grid/grid.ts
new file mode 100644
index 0000000000..0cb3b6f28b
--- /dev/null
+++ b/packages/frontend/src/components/grid/grid.ts
@@ -0,0 +1,44 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { EventEmitter } from 'eventemitter3';
+import { CellValue, GridCellSetting } from '@/components/grid/cell.js';
+import { GridColumnSetting } from '@/components/grid/column.js';
+import { GridRowSetting } from '@/components/grid/row.js';
+
+export type GridSetting = {
+ row?: GridRowSetting;
+ cols: GridColumnSetting[];
+ cells?: GridCellSetting;
+};
+
+export type DataSource = Record<string, CellValue>;
+
+export type GridState =
+ 'normal' |
+ 'cellSelecting' |
+ 'cellEditing' |
+ 'colResizing' |
+ 'colSelecting' |
+ 'rowSelecting' |
+ 'hidden'
+ ;
+
+export type Size = {
+ width: number;
+ height: number;
+}
+
+export type SizeStyle = number | 'auto' | undefined;
+
+export type AdditionalStyle = {
+ className?: string;
+ style?: Record<string, string | number>;
+}
+
+export class GridEventEmitter extends EventEmitter<{
+ 'forceRefreshContentSize': void;
+}> {
+}
diff --git a/packages/frontend/src/components/grid/row.ts b/packages/frontend/src/components/grid/row.ts
new file mode 100644
index 0000000000..e0a317c9d3
--- /dev/null
+++ b/packages/frontend/src/components/grid/row.ts
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { AdditionalStyle } from '@/components/grid/grid.js';
+import { GridCell } from '@/components/grid/cell.js';
+import { GridColumn } from '@/components/grid/column.js';
+import { MenuItem } from '@/types/menu.js';
+import { GridContext } from '@/components/grid/grid-event.js';
+
+export const defaultGridRowSetting: Required<GridRowSetting> = {
+ showNumber: true,
+ selectable: true,
+ minimumDefinitionCount: 100,
+ styleRules: [],
+ contextMenuFactory: () => [],
+ events: {},
+};
+
+export type GridRowStyleRuleConditionParams = {
+ row: GridRow,
+ targetCols: GridColumn[],
+ cells: GridCell[]
+};
+
+export type GridRowStyleRule = {
+ condition: (params: GridRowStyleRuleConditionParams) => boolean;
+ applyStyle: AdditionalStyle;
+}
+
+export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[];
+
+export type GridRowSetting = {
+ showNumber?: boolean;
+ selectable?: boolean;
+ minimumDefinitionCount?: number;
+ styleRules?: GridRowStyleRule[];
+ contextMenuFactory?: GridRowContextMenuFactory;
+ events?: {
+ delete?: (rows: GridRow[]) => void;
+ }
+}
+
+export type GridRow = {
+ index: number;
+ ranged: boolean;
+ using: boolean;
+ setting: GridRowSetting;
+ additionalStyles: AdditionalStyle[];
+}
+
+export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow {
+ return {
+ index,
+ ranged: false,
+ using: using,
+ setting,
+ additionalStyles: [],
+ };
+}
+
+export function resetRow(row: GridRow): void {
+ row.ranged = false;
+ row.using = false;
+ row.additionalStyles = [];
+}
+
diff --git a/packages/frontend/src/components/hook/useLoading.ts b/packages/frontend/src/components/hook/useLoading.ts
new file mode 100644
index 0000000000..6c6ff6ae0d
--- /dev/null
+++ b/packages/frontend/src/components/hook/useLoading.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { computed, h, ref } from 'vue';
+import MkLoading from '@/components/global/MkLoading.vue';
+
+export const useLoading = (props?: {
+ static?: boolean;
+ inline?: boolean;
+ colored?: boolean;
+ mini?: boolean;
+ em?: boolean;
+}) => {
+ const showingCnt = ref(0);
+
+ const show = () => {
+ showingCnt.value++;
+ };
+
+ const close = (force?: boolean) => {
+ if (force) {
+ showingCnt.value = 0;
+ } else {
+ showingCnt.value = Math.max(0, showingCnt.value - 1);
+ }
+ };
+
+ const scope = <T>(fn: () => T) => {
+ show();
+
+ const result = fn();
+ if (result instanceof Promise) {
+ return result.finally(() => close());
+ } else {
+ close();
+ return result;
+ }
+ };
+
+ const showing = computed(() => showingCnt.value > 0);
+ const component = computed(() => showing.value ? h(MkLoading, props) : null);
+
+ return {
+ show,
+ close,
+ scope,
+ component,
+ showing,
+ };
+};
diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html
index 0be589262f..84ba9dfabc 100644
--- a/packages/frontend/src/index.html
+++ b/packages/frontend/src/index.html
@@ -20,6 +20,7 @@
worker-src 'self';
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://*.recaptcha.net https://*.gstatic.com https://challenges.cloudflare.com https://esm.sh;
style-src 'self' 'unsafe-inline';
+ font-src 'self' data:;
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com;
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 589ace0155..18c7464d2e 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -602,6 +602,27 @@ export async function selectDriveFolder(multiple: boolean): Promise<Misskey.enti
});
}
+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, {
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts
new file mode 100644
index 0000000000..de2b2aca8c
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type RequestLogItem = {
+ failed: boolean;
+ url: string;
+ name: string;
+ error?: string;
+};
+
+export const gridSortOrderKeys = [
+ 'name',
+ 'category',
+ 'aliases',
+ 'type',
+ 'license',
+ 'host',
+ 'uri',
+ 'publicUrl',
+ 'isSensitive',
+ 'localOnly',
+ 'updatedAt',
+];
+export type GridSortOrderKey = typeof gridSortOrderKeys[number];
+
+export function emptyStrToUndefined(value: string | null) {
+ return value ? value : undefined;
+}
+
+export function emptyStrToNull(value: string) {
+ return value === '' ? null : value;
+}
+
+export function emptyStrToEmptyArray(value: string) {
+ return value === '' ? [] : value.split(' ').map(it => it.trim());
+}
+
+export function roleIdsParser(text: string): { id: string, name: string }[] {
+ // idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない
+ try {
+ const obj = JSON.parse(text);
+ if (!Array.isArray(obj)) {
+ return [];
+ }
+ if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) {
+ return [];
+ }
+
+ return obj.map(it => ({ id: it.id, name: it.name }));
+ } catch (ex) {
+ console.warn(ex);
+ return [];
+ }
+}
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
new file mode 100644
index 0000000000..55f9632ce4
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
@@ -0,0 +1,757 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <template #default>
+ <div class="_gaps">
+ <MkFolder>
+ <template #icon><i class="ti ti-search"></i></template>
+ <template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchSettings }}</template>
+ <template #caption>
+ {{ i18n.ts._customEmojisManager._gridCommon.searchSettingCaption }}
+ </template>
+
+ <div class="_gaps">
+ <div :class="[[spMode ? $style.searchAreaSp : $style.searchArea]]">
+ <MkInput
+ v-model="queryName"
+ type="search"
+ autocapitalize="off"
+ :class="[$style.col1, $style.row1]"
+ @enter="onSearchRequest"
+ >
+ <template #label>name</template>
+ </MkInput>
+ <MkInput
+ v-model="queryCategory"
+ type="search"
+ autocapitalize="off"
+ :class="[$style.col2, $style.row1]"
+ @enter="onSearchRequest"
+ >
+ <template #label>category</template>
+ </MkInput>
+ <MkInput
+ v-model="queryAliases"
+ type="search"
+ autocapitalize="off"
+ :class="[$style.col3, $style.row1]"
+ @enter="onSearchRequest"
+ >
+ <template #label>aliases</template>
+ </MkInput>
+
+ <MkInput
+ v-model="queryType"
+ type="search"
+ autocapitalize="off"
+ :class="[$style.col1, $style.row2]"
+ @enter="onSearchRequest"
+ >
+ <template #label>type</template>
+ </MkInput>
+ <MkInput
+ v-model="queryLicense"
+ type="search"
+ autocapitalize="off"
+ :class="[$style.col2, $style.row2]"
+ @enter="onSearchRequest"
+ >
+ <template #label>license</template>
+ </MkInput>
+ <MkSelect
+ v-model="querySensitive"
+ :class="[$style.col3, $style.row2]"
+ >
+ <template #label>sensitive</template>
+ <option :value="null">-</option>
+ <option :value="true">true</option>
+ <option :value="false">false</option>
+ </MkSelect>
+
+ <MkSelect
+ v-model="queryLocalOnly"
+ :class="[$style.col1, $style.row3]"
+ >
+ <template #label>localOnly</template>
+ <option :value="null">-</option>
+ <option :value="true">true</option>
+ <option :value="false">false</option>
+ </MkSelect>
+ <MkInput
+ v-model="queryUpdatedAtFrom"
+ type="date"
+ autocapitalize="off"
+ :class="[$style.col2, $style.row3]"
+ @enter="onSearchRequest"
+ >
+ <template #label>updatedAt(from)</template>
+ </MkInput>
+ <MkInput
+ v-model="queryUpdatedAtTo"
+ type="date"
+ autocapitalize="off"
+ :class="[$style.col3, $style.row3]"
+ @enter="onSearchRequest"
+ >
+ <template #label>updatedAt(to)</template>
+ </MkInput>
+
+ <MkInput
+ v-model="queryRolesText"
+ type="text"
+ readonly
+ autocapitalize="off"
+ :class="[$style.col1, $style.row4]"
+ @click="onQueryRolesEditClicked"
+ >
+ <template #label>role</template>
+ <template #suffix><span class="ti ti-pencil"/></template>
+ </MkInput>
+ </div>
+
+ <MkFolder :spacerMax="8" :spacerMin="8">
+ <template #icon><i class="ti ti-arrows-sort"></i></template>
+ <template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
+ <MkSortOrderEditor
+ :baseOrderKeyNames="gridSortOrderKeys"
+ :currentOrders="sortOrders"
+ @update="onSortOrderUpdate"
+ />
+ </MkFolder>
+
+ <div :class="[[spMode ? $style.searchButtonsSp : $style.searchButtons]]">
+ <MkButton primary @click="onSearchRequest">
+ {{ i18n.ts.search }}
+ </MkButton>
+ <MkButton @click="onQueryResetButtonClicked">
+ {{ i18n.ts.reset }}
+ </MkButton>
+ </div>
+ </div>
+ </MkFolder>
+
+ <XRegisterLogsFolder :logs="requestLogs"/>
+
+ <component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
+ <template v-else>
+ <div v-if="gridItems.length === 0" style="text-align: center">
+ {{ i18n.ts._customEmojisManager._local._list.emojisNothing }}
+ </div>
+
+ <template v-else>
+ <div :class="$style.gridArea">
+ <MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/>
+ </div>
+
+ <div :class="$style.footer">
+ <div :class="$style.left">
+ <MkButton danger style="margin-right: auto" @click="onDeleteButtonClicked">
+ {{ i18n.ts.delete }} ({{ deleteItemsCount }})
+ </MkButton>
+ </div>
+
+ <div :class="$style.center">
+ <MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
+ </div>
+
+ <div :class="$style.right">
+ <MkButton primary :disabled="updateButtonDisabled" @click="onUpdateButtonClicked">
+ {{ i18n.ts.update }} ({{ updatedItemsCount }})
+ </MkButton>
+ <MkButton @click="onGridResetButtonClicked">{{ i18n.ts.reset }}</MkButton>
+ </div>
+ </div>
+ </template>
+ </template>
+ </div>
+ </template>
+</MkStickyContainer>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, useCssModule } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as os from '@/os.js';
+import {
+ emptyStrToEmptyArray,
+ emptyStrToNull,
+ emptyStrToUndefined,
+ GridSortOrderKey,
+ gridSortOrderKeys,
+ RequestLogItem,
+ roleIdsParser,
+} from '@/pages/admin/custom-emojis-manager.impl.js';
+import MkGrid from '@/components/grid/MkGrid.vue';
+import { i18n } from '@/i18n.js';
+import MkInput from '@/components/MkInput.vue';
+import MkButton from '@/components/MkButton.vue';
+import { validators } from '@/components/grid/cell-validators.js';
+import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import MkPagingButtons from '@/components/MkPagingButtons.vue';
+import XRegisterLogsFolder from '@/pages/admin/custom-emojis-manager.logs-folder.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import { deviceKind } from '@/scripts/device-kind.js';
+import { GridSetting } from '@/components/grid/grid.js';
+import { selectFile } from '@/scripts/select-file.js';
+import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js';
+import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
+import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
+import { useLoading } from "@/components/hook/useLoading.js";
+
+type GridItem = {
+ checked: boolean;
+ id: string;
+ url: string;
+ name: string;
+ host: string;
+ category: string;
+ aliases: string;
+ license: string;
+ isSensitive: boolean;
+ localOnly: boolean;
+ roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[];
+ fileId?: string;
+ updatedAt: string | null;
+ publicUrl?: string | null;
+ originalUrl?: string | null;
+ type: string | null;
+}
+
+function setupGrid(): GridSetting {
+ const $style = useCssModule();
+
+ const required = validators.required();
+ const regex = validators.regex(/^[a-zA-Z0-9_]+$/);
+ const unique = validators.unique();
+ return {
+ row: {
+ showNumber: true,
+ selectable: true,
+ // グリッドの行数をあらかじめ100行確保する
+ minimumDefinitionCount: 100,
+ styleRules: [
+ {
+ // 初期値から変わっていたら背景色を変更
+ condition: ({ row }) => JSON.stringify(gridItems.value[row.index]) !== JSON.stringify(originGridItems.value[row.index]),
+ applyStyle: { className: $style.changedRow },
+ },
+ {
+ // バリデーションに引っかかっていたら背景色を変更
+ condition: ({ cells }) => cells.some(it => !it.violation.valid),
+ applyStyle: { className: $style.violationRow },
+ },
+ ],
+ // 行のコンテキストメニュー設定
+ contextMenuFactory: (row, context) => {
+ return [
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
+ icon: 'ti ti-copy',
+ action: () => copyGridDataToClipboard(gridItems, context),
+ },
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRows,
+ icon: 'ti ti-trash',
+ action: () => {
+ for (const rangedRow of context.rangedRows) {
+ gridItems.value[rangedRow.index].checked = true;
+ }
+ },
+ },
+ ];
+ },
+ events: {
+ delete(rows) {
+ // 行削除時は元データの行を消さず、削除対象としてマークするのみにする
+ for (const row of rows) {
+ gridItems.value[row.index].checked = true;
+ }
+ },
+ },
+ },
+ cols: [
+ { bindTo: 'checked', icon: 'ti-trash', type: 'boolean', editable: true, width: 34 },
+ {
+ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required],
+ async customValueEditor(row, col, value, cellElement) {
+ const file = await selectFile(cellElement);
+ gridItems.value[row.index].url = file.url;
+ gridItems.value[row.index].fileId = file.id;
+
+ return file.url;
+ },
+ },
+ {
+ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140,
+ validators: [required, regex, unique],
+ },
+ { bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
+ { bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
+ { bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
+ { bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
+ { bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
+ {
+ bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140,
+ valueTransformer(row) {
+ // バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする
+ return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction
+ .map((it) => it.name)
+ .join(',');
+ },
+ async customValueEditor(row) {
+ // ID直記入は体験的に最悪なのでモーダルを使って入力する
+ const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction;
+ const result = await os.selectRole({
+ initialRoleIds: current.map(it => it.id),
+ title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction,
+ infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription,
+ publicOnly: true,
+ });
+ if (result.canceled) {
+ return current;
+ }
+
+ const transform = result.result.map(it => ({ id: it.id, name: it.name }));
+ gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform;
+
+ return transform;
+ },
+ events: {
+ paste: roleIdsParser,
+ delete(cell) {
+ // デフォルトはundefinedになるが、このプロパティは空配列にしたい
+ gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = [];
+ },
+ },
+ },
+ { bindTo: 'type', type: 'text', editable: false, width: 90 },
+ { bindTo: 'updatedAt', type: 'text', editable: false, width: 'auto' },
+ { bindTo: 'publicUrl', type: 'text', editable: false, width: 180 },
+ { bindTo: 'originalUrl', type: 'text', editable: false, width: 180 },
+ ],
+ cells: {
+ // セルのコンテキストメニュー設定
+ contextMenuFactory(col, row, value, context) {
+ return [
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
+ icon: 'ti ti-copy',
+ action: () => {
+ return copyGridDataToClipboard(gridItems, context);
+ },
+ },
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges,
+ icon: 'ti ti-trash',
+ action: () => {
+ removeDataFromGrid(context, (cell) => {
+ gridItems.value[cell.row.index][cell.column.setting.bindTo] = undefined;
+ });
+ },
+ },
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRanges,
+ icon: 'ti ti-trash',
+ action: () => {
+ for (const rowIdx of [...new Set(context.rangedCells.map(it => it.row.index)).values()]) {
+ gridItems.value[rowIdx].checked = true;
+ }
+ },
+ },
+ ];
+ },
+ },
+ };
+}
+
+const loadingHandler = useLoading();
+
+const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]);
+const allPages = ref<number>(0);
+const currentPage = ref<number>(0);
+
+const queryName = ref<string | null>(null);
+const queryCategory = ref<string | null>(null);
+const queryAliases = ref<string | null>(null);
+const queryType = ref<string | null>(null);
+const queryLicense = ref<string | null>(null);
+const queryUpdatedAtFrom = ref<string | null>(null);
+const queryUpdatedAtTo = ref<string | null>(null);
+const querySensitive = ref<string | null>(null);
+const queryLocalOnly = ref<string | null>(null);
+const queryRoles = ref<{ id: string, name: string }[]>([]);
+const previousQuery = ref<string | undefined>(undefined);
+const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
+const requestLogs = ref<RequestLogItem[]>([]);
+
+const gridItems = ref<GridItem[]>([]);
+const originGridItems = ref<GridItem[]>([]);
+const updateButtonDisabled = ref<boolean>(false);
+
+const spMode = computed(() => ['smartphone', 'tablet'].includes(deviceKind));
+const queryRolesText = computed(() => queryRoles.value.map(it => it.name).join(','));
+const updatedItemsCount = computed(() => {
+ return gridItems.value.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(originGridItems.value[idx])).length;
+});
+const deleteItemsCount = computed(() => gridItems.value.filter(it => it.checked).length);
+
+async function onUpdateButtonClicked() {
+ const _items = gridItems.value;
+ const _originItems = originGridItems.value;
+ if (_items.length !== _originItems.length) {
+ throw new Error('The number of items has been changed. Please refresh the page and try again.');
+ }
+
+ const updatedItems = _items.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(_originItems[idx]));
+ if (updatedItems.length === 0) {
+ await os.alert({
+ type: 'info',
+ text: i18n.ts._customEmojisManager._local._list.alertUpdateEmojisNothingDescription,
+ });
+ return;
+ }
+
+ const confirm = await os.confirm({
+ type: 'info',
+ title: i18n.ts._customEmojisManager._local._list.confirmUpdateEmojisTitle,
+ text: i18n.tsx._customEmojisManager._local._list.confirmUpdateEmojisDescription({ count: updatedItems.length }),
+ });
+ if (confirm.canceled) {
+ return;
+ }
+
+ const action = () => {
+ return updatedItems.map(item =>
+ misskeyApi(
+ 'admin/emoji/update',
+ {
+ // eslint-disable-next-line
+ id: item.id!,
+ name: item.name,
+ category: emptyStrToNull(item.category),
+ aliases: emptyStrToEmptyArray(item.aliases),
+ license: emptyStrToNull(item.license),
+ isSensitive: item.isSensitive,
+ localOnly: item.localOnly,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id),
+ fileId: item.fileId,
+ })
+ .then(() => ({ item, success: true, err: undefined }))
+ .catch(err => ({ item, success: false, err })),
+ );
+ };
+
+ const result = await os.promiseDialog(Promise.all(action()));
+ const failedItems = result.filter(it => !it.success);
+
+ if (failedItems.length > 0) {
+ await os.alert({
+ type: 'error',
+ title: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedTitle,
+ text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
+ });
+ }
+
+ requestLogs.value = result.map(it => ({
+ failed: !it.success,
+ url: it.item.url,
+ name: it.item.name,
+ error: it.err ? JSON.stringify(it.err) : undefined,
+ }));
+
+ await refreshCustomEmojis();
+}
+
+async function onDeleteButtonClicked() {
+ const _items = gridItems.value;
+ const _originItems = originGridItems.value;
+ if (_items.length !== _originItems.length) {
+ throw new Error('The number of items has been changed. Please refresh the page and try again.');
+ }
+
+ const deleteItems = _items.filter((it) => it.checked);
+ if (deleteItems.length === 0) {
+ await os.alert({
+ type: 'info',
+ text: i18n.ts._customEmojisManager._local._list.alertDeleteEmojisNothingDescription,
+ });
+ return;
+ }
+
+ const confirm = await os.confirm({
+ type: 'info',
+ title: i18n.ts._customEmojisManager._local._list.confirmDeleteEmojisTitle,
+ text: i18n.tsx._customEmojisManager._local._list.confirmDeleteEmojisDescription({ count: deleteItems.length }),
+ });
+ if (confirm.canceled) {
+ return;
+ }
+
+ async function action() {
+ const deleteIds = deleteItems.map(it => it.id!);
+ await misskeyApi('admin/emoji/delete-bulk', { ids: deleteIds });
+ }
+
+ await os.promiseDialog(
+ action(),
+ );
+}
+
+function onGridResetButtonClicked() {
+ refreshGridItems();
+}
+
+async function onQueryRolesEditClicked() {
+ const result = await os.selectRole({
+ initialRoleIds: queryRoles.value.map(it => it.id),
+ title: i18n.ts._customEmojisManager._local._list.dialogSelectRoleTitle,
+ publicOnly: true,
+ });
+ if (result.canceled) {
+ return;
+ }
+
+ queryRoles.value = result.result;
+}
+
+function onSortOrderUpdate(_sortOrders: SortOrder<GridSortOrderKey>[]) {
+ sortOrders.value = _sortOrders;
+}
+
+async function onSearchRequest() {
+ await refreshCustomEmojis();
+}
+
+function onQueryResetButtonClicked() {
+ queryName.value = null;
+ queryCategory.value = null;
+ queryAliases.value = null;
+ queryType.value = null;
+ queryLicense.value = null;
+ queryUpdatedAtFrom.value = null;
+ queryUpdatedAtTo.value = null;
+ querySensitive.value = null;
+ queryLocalOnly.value = null;
+ queryRoles.value = [];
+}
+
+async function onPageChanged(pageNumber: number) {
+ currentPage.value = pageNumber;
+ await refreshCustomEmojis();
+}
+
+function onGridEvent(event: GridEvent) {
+ switch (event.type) {
+ case 'cell-validation':
+ onGridCellValidation(event);
+ break;
+ case 'cell-value-change':
+ onGridCellValueChange(event);
+ break;
+ }
+}
+
+function onGridCellValidation(event: GridCellValidationEvent) {
+ updateButtonDisabled.value = event.all.filter(it => !it.valid).length > 0;
+}
+
+function onGridCellValueChange(event: GridCellValueChangeEvent) {
+ const { row, column, newValue } = event;
+ if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
+ gridItems.value[row.index][column.setting.bindTo] = newValue;
+ }
+}
+
+async function refreshCustomEmojis() {
+ const limit = 100;
+
+ const query: Misskey.entities.V2AdminEmojiListRequest['query'] = {
+ name: emptyStrToUndefined(queryName.value),
+ type: emptyStrToUndefined(queryType.value),
+ aliases: emptyStrToUndefined(queryAliases.value),
+ category: emptyStrToUndefined(queryCategory.value),
+ license: emptyStrToUndefined(queryLicense.value),
+ isSensitive: querySensitive.value ? Boolean(querySensitive.value).valueOf() : undefined,
+ localOnly: queryLocalOnly.value ? Boolean(queryLocalOnly.value).valueOf() : undefined,
+ updatedAtFrom: emptyStrToUndefined(queryUpdatedAtFrom.value),
+ updatedAtTo: emptyStrToUndefined(queryUpdatedAtTo.value),
+ roleIds: queryRoles.value.map(it => it.id),
+ hostType: 'local',
+ };
+
+ if (JSON.stringify(query) !== previousQuery.value) {
+ currentPage.value = 1;
+ }
+
+ const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', {
+ query: query,
+ limit: limit,
+ page: currentPage.value,
+ sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}` as any),
+ }));
+
+ customEmojis.value = result.emojis;
+ allPages.value = result.allPages;
+
+ previousQuery.value = JSON.stringify(query);
+
+ refreshGridItems();
+}
+
+function refreshGridItems() {
+ gridItems.value = customEmojis.value.map(it => ({
+ checked: false,
+ id: it.id,
+ fileId: undefined,
+ url: it.publicUrl,
+ name: it.name,
+ host: it.host ?? '',
+ category: it.category ?? '',
+ aliases: it.aliases.join(','),
+ license: it.license ?? '',
+ isSensitive: it.isSensitive,
+ localOnly: it.localOnly,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction,
+ updatedAt: it.updatedAt,
+ publicUrl: it.publicUrl,
+ originalUrl: it.originalUrl,
+ type: it.type,
+ }));
+ originGridItems.value = JSON.parse(JSON.stringify(gridItems.value));
+}
+
+onMounted(async () => {
+ await refreshCustomEmojis();
+});
+
+</script>
+
+<style module lang="scss">
+.violationRow {
+ background-color: var(--MI_THEME-infoWarnBg);
+}
+
+.changedRow {
+ background-color: var(--MI_THEME-infoBg);
+}
+
+.editedRow {
+ background-color: var(--MI_THEME-infoBg);
+}
+
+.row1 {
+ grid-row: 1 / 2;
+}
+
+.row2 {
+ grid-row: 2 / 3;
+}
+
+.row3 {
+ grid-row: 3 / 4;
+}
+
+.row4 {
+ grid-row: 4 / 5;
+}
+
+.col1 {
+ grid-column: 1 / 2;
+}
+
+.col2 {
+ grid-column: 2 / 3;
+}
+
+.col3 {
+ grid-column: 3 / 4;
+}
+
+.searchArea {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 16px;
+}
+
+.searchAreaSp {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.searchButtons {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+ gap: 8px;
+}
+
+.searchButtonsSp {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+}
+
+.gridArea {
+ padding-top: 8px;
+ padding-bottom: 8px;
+}
+
+.footer {
+ background-color: var(--MI_THEME-bg);
+
+ position: sticky;
+ left:0;
+ bottom:0;
+ z-index: 1;
+ // stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
+ margin-top: calc(var(--MI-margin) * -1);
+ margin-bottom: calc(var(--MI-margin) * -1);
+ padding-top: var(--MI-margin);
+ padding-bottom: var(--MI-margin);
+
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 8px;
+
+ & .left {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 8px;
+ }
+
+ & .center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ }
+
+ & .right {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-direction: row;
+ gap: 8px;
+ }
+}
+
+.divider {
+ margin: 8px 0;
+ border-top: solid 0.5px var(--MI_THEME-divider);
+}
+
+</style>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
new file mode 100644
index 0000000000..a3de5de569
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
@@ -0,0 +1,477 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <MkFolder>
+ <template #icon><i class="ti ti-settings"></i></template>
+ <template #label>{{ i18n.ts._customEmojisManager._local._register.uploadSettingTitle }}</template>
+ <template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template>
+
+ <div class="_gaps">
+ <MkSelect v-model="selectedFolderId">
+ <template #label>{{ i18n.ts.uploadFolder }}</template>
+ <option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id">
+ {{ folder.name }}
+ </option>
+ </MkSelect>
+
+ <MkSwitch v-model="keepOriginalUploading">
+ <template #label>{{ i18n.ts.keepOriginalUploading }}</template>
+ <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
+ </MkSwitch>
+
+ <MkSwitch v-model="directoryToCategory">
+ <template #label>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryLabel }}</template>
+ <template #caption>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryCaption }}</template>
+ </MkSwitch>
+ </div>
+ </MkFolder>
+
+ <XRegisterLogsFolder :logs="requestLogs"/>
+
+ <div
+ :class="[$style.uploadBox, [isDragOver ? $style.dragOver : {}]]"
+ @dragover.prevent="isDragOver = true"
+ @dragleave.prevent="isDragOver = false"
+ @drop.prevent.stop="onDrop"
+ >
+ <div style="margin-top: 1em">
+ {{ i18n.ts._customEmojisManager._local._register.emojiInputAreaCaption }}
+ </div>
+ <ul>
+ <li>{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList1 }}</li>
+ <li><a @click.prevent="onFileSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList2 }}</a></li>
+ <li><a @click.prevent="onDriveSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList3 }}</a></li>
+ </ul>
+ </div>
+
+ <div v-if="gridItems.length > 0" :class="$style.gridArea">
+ <MkGrid
+ :data="gridItems"
+ :settings="setupGrid()"
+ @event="onGridEvent"
+ />
+ </div>
+
+ <div v-if="gridItems.length > 0" :class="$style.footer">
+ <MkButton primary :disabled="registerButtonDisabled" @click="onRegistryClicked">
+ {{ i18n.ts.registration }}
+ </MkButton>
+ <MkButton @click="onClearClicked">
+ {{ i18n.ts.clear }}
+ </MkButton>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import * as Misskey from 'misskey-js';
+import { onMounted, ref, useCssModule } from 'vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import {
+ emptyStrToEmptyArray,
+ emptyStrToNull,
+ RequestLogItem,
+ roleIdsParser,
+} from '@/pages/admin/custom-emojis-manager.impl.js';
+import MkGrid from '@/components/grid/MkGrid.vue';
+import { i18n } from '@/i18n.js';
+import MkSelect from '@/components/MkSelect.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import { defaultStore } from '@/store.js';
+import MkFolder from '@/components/MkFolder.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os.js';
+import { validators } from '@/components/grid/cell-validators.js';
+import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js';
+import { uploadFile } from '@/scripts/upload.js';
+import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
+import { DroppedFile, extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js';
+import XRegisterLogsFolder from '@/pages/admin/custom-emojis-manager.logs-folder.vue';
+import { GridSetting } from '@/components/grid/grid.js';
+import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
+import { GridRow } from '@/components/grid/row.js';
+
+const MAXIMUM_EMOJI_REGISTER_COUNT = 100;
+
+type FolderItem = {
+ id?: string;
+ name: string;
+};
+
+type GridItem = {
+ fileId: string;
+ url: string;
+ name: string;
+ host: string;
+ category: string;
+ aliases: string;
+ license: string;
+ isSensitive: boolean;
+ localOnly: boolean;
+ roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[];
+ type: string | null;
+}
+
+function setupGrid(): GridSetting {
+ const $style = useCssModule();
+
+ const required = validators.required();
+ const regex = validators.regex(/^[a-zA-Z0-9_]+$/);
+ const unique = validators.unique();
+
+ function removeRows(rows: GridRow[]) {
+ const idxes = [...new Set(rows.map(it => it.index))];
+ gridItems.value = gridItems.value.filter((_, i) => !idxes.includes(i));
+ }
+
+ return {
+ row: {
+ showNumber: true,
+ selectable: true,
+ minimumDefinitionCount: 100,
+ styleRules: [
+ {
+ // 1つでもバリデーションエラーがあれば行全体をエラー表示する
+ condition: ({ cells }) => cells.some(it => !it.violation.valid),
+ applyStyle: { className: $style.violationRow },
+ },
+ ],
+ // 行のコンテキストメニュー設定
+ contextMenuFactory: (row, context) => {
+ return [
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
+ icon: 'ti ti-copy',
+ action: () => copyGridDataToClipboard(gridItems, context),
+ },
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRows,
+ icon: 'ti ti-trash',
+ action: () => removeRows(context.rangedRows),
+ },
+ ];
+ },
+ events: {
+ delete(rows) {
+ removeRows(rows);
+ },
+ },
+ },
+ cols: [
+ { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto', validators: [required] },
+ {
+ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140,
+ validators: [required, regex, unique],
+ },
+ { bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
+ { bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
+ { bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
+ { bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
+ { bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
+ {
+ bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140,
+ valueTransformer: (row) => {
+ // バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする
+ return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction
+ .map((it) => it.name)
+ .join(',');
+ },
+ customValueEditor: async (row) => {
+ // ID直記入は体験的に最悪なのでモーダルを使って入力する
+ const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction;
+ const result = await os.selectRole({
+ initialRoleIds: current.map(it => it.id),
+ title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction,
+ infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription,
+ publicOnly: true,
+ });
+ if (result.canceled) {
+ return current;
+ }
+
+ const transform = result.result.map(it => ({ id: it.id, name: it.name }));
+ gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform;
+
+ return transform;
+ },
+ events: {
+ paste: roleIdsParser,
+ delete(cell) {
+ // デフォルトはundefinedになるが、このプロパティは空配列にしたい
+ gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = [];
+ },
+ },
+ },
+ { bindTo: 'type', type: 'text', editable: false, width: 90 },
+ ],
+ cells: {
+ // セルのコンテキストメニュー設定
+ contextMenuFactory: (col, row, value, context) => {
+ return [
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
+ icon: 'ti ti-copy',
+ action: () => copyGridDataToClipboard(gridItems, context),
+ },
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges,
+ icon: 'ti ti-trash',
+ action: () => removeRows(context.rangedCells.map(it => it.row)),
+ },
+ ];
+ },
+ },
+ };
+}
+
+const uploadFolders = ref<FolderItem[]>([]);
+const gridItems = ref<GridItem[]>([]);
+const selectedFolderId = ref(defaultStore.state.uploadFolder);
+const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading);
+const directoryToCategory = ref<boolean>(false);
+const registerButtonDisabled = ref<boolean>(false);
+const requestLogs = ref<RequestLogItem[]>([]);
+const isDragOver = ref<boolean>(false);
+
+async function onRegistryClicked() {
+ const dialogSelection = await os.confirm({
+ type: 'info',
+ title: i18n.ts._customEmojisManager._local._register.confirmRegisterEmojisTitle,
+ text: i18n.tsx._customEmojisManager._local._register.confirmRegisterEmojisDescription({ count: MAXIMUM_EMOJI_REGISTER_COUNT }),
+ });
+
+ if (dialogSelection.canceled) {
+ return;
+ }
+
+ const items = gridItems.value;
+ const upload = () => {
+ return items.slice(0, MAXIMUM_EMOJI_REGISTER_COUNT)
+ .map(item =>
+ misskeyApi(
+ 'admin/emoji/add', {
+ name: item.name,
+ category: emptyStrToNull(item.category),
+ aliases: emptyStrToEmptyArray(item.aliases),
+ license: emptyStrToNull(item.license),
+ isSensitive: item.isSensitive,
+ localOnly: item.localOnly,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id),
+ fileId: item.fileId!,
+ })
+ .then(() => ({ item, success: true, err: undefined }))
+ .catch(err => ({ item, success: false, err })),
+ );
+ };
+
+ const result = await os.promiseDialog(Promise.all(upload()));
+ const failedItems = result.filter(it => !it.success);
+
+ if (failedItems.length > 0) {
+ await os.alert({
+ type: 'error',
+ title: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedTitle,
+ text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
+ });
+ }
+
+ requestLogs.value = result.map(it => ({
+ failed: !it.success,
+ url: it.item.url,
+ name: it.item.name,
+ error: it.err ? JSON.stringify(it.err) : undefined,
+ }));
+
+ // 登録に成功したものは一覧から除く
+ const successItems = result.filter(it => it.success).map(it => it.item);
+ gridItems.value = gridItems.value.filter(it => !successItems.includes(it));
+}
+
+async function onClearClicked() {
+ const result = await os.confirm({
+ type: 'warning',
+ title: i18n.ts._customEmojisManager._local._register.confirmClearEmojisTitle,
+ text: i18n.ts._customEmojisManager._local._register.confirmClearEmojisDescription,
+ });
+
+ if (!result.canceled) {
+ gridItems.value = [];
+ }
+}
+
+async function onDrop(ev: DragEvent) {
+ isDragOver.value = false;
+
+ const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it));
+ const confirm = await os.confirm({
+ type: 'info',
+ title: i18n.ts._customEmojisManager._local._register.confirmUploadEmojisTitle,
+ text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }),
+ });
+ if (confirm.canceled) {
+ return;
+ }
+
+ const uploadedItems = Array.of<{ droppedFile: DroppedFile, driveFile: Misskey.entities.DriveFile }>();
+ try {
+ uploadedItems.push(
+ ...await os.promiseDialog(
+ Promise.all(
+ droppedFiles.map(async (it) => ({
+ droppedFile: it,
+ driveFile: await uploadFile(
+ it.file,
+ selectedFolderId.value,
+ it.file.name.replace(/\.[^.]+$/, ''),
+ keepOriginalUploading.value,
+ ),
+ }),
+ ),
+ ),
+ () => {
+ },
+ () => {
+ },
+ ),
+ );
+ } catch (err) {
+ // ダイアログは共通部品側で出ているはずなので何もしない
+ return;
+ }
+
+ const items = uploadedItems.map(({ droppedFile, driveFile }) => {
+ const item = fromDriveFile(driveFile);
+ if (directoryToCategory.value) {
+ item.category = droppedFile.path
+ .replace(/^\//, '')
+ .replace(/\/[^/]+$/, '')
+ .replace(droppedFile.file.name, '');
+ }
+ return item;
+ });
+
+ gridItems.value.push(...items);
+}
+
+async function onFileSelectClicked() {
+ const driveFiles = await chooseFileFromPc(
+ true,
+ {
+ uploadFolder: selectedFolderId.value,
+ keepOriginal: keepOriginalUploading.value,
+ // 拡張子は消す
+ nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''),
+ },
+ );
+
+ gridItems.value.push(...driveFiles.map(fromDriveFile));
+}
+
+async function onDriveSelectClicked() {
+ const driveFiles = await chooseFileFromDrive(true);
+ gridItems.value.push(...driveFiles.map(fromDriveFile));
+}
+
+function onGridEvent(event: GridEvent) {
+ switch (event.type) {
+ case 'cell-validation':
+ onGridCellValidation(event);
+ break;
+ case 'cell-value-change':
+ onGridCellValueChange(event);
+ break;
+ }
+}
+
+function onGridCellValidation(event: GridCellValidationEvent) {
+ registerButtonDisabled.value = event.all.filter(it => !it.valid).length > 0;
+}
+
+function onGridCellValueChange(event: GridCellValueChangeEvent) {
+ const { row, column, newValue } = event;
+ if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
+ gridItems.value[row.index][column.setting.bindTo] = newValue;
+ }
+}
+
+function fromDriveFile(it: Misskey.entities.DriveFile): GridItem {
+ return {
+ fileId: it.id,
+ url: it.url,
+ name: it.name.replace(/(\.[a-zA-Z0-9]+)+$/, ''),
+ host: '',
+ category: '',
+ aliases: '',
+ license: '',
+ isSensitive: it.isSensitive,
+ localOnly: false,
+ roleIdsThatCanBeUsedThisEmojiAsReaction: [],
+ type: it.type,
+ };
+}
+
+async function refreshUploadFolders() {
+ const result = await misskeyApi('drive/folders', {});
+ uploadFolders.value = Array.of<FolderItem>({ name: '-' }, ...result);
+}
+
+onMounted(async () => {
+ await refreshUploadFolders();
+});
+</script>
+
+<style module lang="scss">
+.violationRow {
+ background-color: var(--MI_THEME-infoWarnBg);
+}
+
+.uploadBox {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: auto;
+ border: 0.5px dotted var(--MI_THEME-accentedBg);
+ border-radius: var(--MI-radius);
+ background-color: var(--MI_THEME-accentedBg);
+ box-sizing: border-box;
+
+ &.dragOver {
+ cursor: copy;
+ }
+}
+
+.gridArea {
+ padding-top: 8px;
+ padding-bottom: 8px;
+}
+
+.footer {
+ background-color: var(--MI_THEME-bg);
+
+ position: sticky;
+ left:0;
+ bottom:0;
+ z-index: 1;
+ // stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
+ margin-top: calc(var(--MI-margin) * -1);
+ margin-bottom: calc(var(--MI-margin) * -1);
+ padding-top: var(--MI-margin);
+ padding-bottom: var(--MI-margin);
+
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue
new file mode 100644
index 0000000000..ea4303f342
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue
@@ -0,0 +1,36 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps" :class="$style.root">
+ <MkTab v-model="modeTab" style="margin-bottom: var(--margin);">
+ <option value="list">{{ i18n.ts._customEmojisManager._local.tabTitleList }}</option>
+ <option value="register">{{ i18n.ts._customEmojisManager._local.tabTitleRegister }}</option>
+ </MkTab>
+
+ <div>
+ <XListComponent v-if="modeTab === 'list'"/>
+ <XRegisterComponent v-else/>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { i18n } from '@/i18n.js';
+import MkTab from '@/components/MkTab.vue';
+import XListComponent from '@/pages/admin/custom-emojis-manager.local.list.vue';
+import XRegisterComponent from '@/pages/admin/custom-emojis-manager.local.register.vue';
+
+type PageMode = 'list' | 'register';
+
+const modeTab = ref<PageMode>('list');
+</script>
+
+<style module lang="scss">
+.root {
+ padding: var(--MI-margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue
new file mode 100644
index 0000000000..f75f6c0da5
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue
@@ -0,0 +1,102 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkFolder>
+ <template #icon><i class="ti ti-notes"></i></template>
+ <template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template>
+ <template #caption>
+ {{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }}
+ </template>
+
+ <div>
+ <div v-if="logs.length > 0" style="display:flex; flex-direction: column; overflow-y: scroll; gap: 16px;">
+ <MkSwitch v-model="showingSuccessLogs">
+ <template #label>{{ i18n.ts._customEmojisManager._logs.showSuccessLogSwitch }}</template>
+ </MkSwitch>
+ <div>
+ <div v-if="filteredLogs.length > 0">
+ <MkGrid
+ :data="filteredLogs"
+ :settings="setupGrid()"
+ />
+ </div>
+ <div v-else>
+ {{ i18n.ts._customEmojisManager._logs.failureLogNothing }}
+ </div>
+ </div>
+ </div>
+ <div v-else>
+ {{ i18n.ts._customEmojisManager._logs.logNothing }}
+ </div>
+ </div>
+</MkFolder>
+</template>
+
+<script setup lang="ts">
+
+import { computed, ref, toRefs } from 'vue';
+import { i18n } from '@/i18n.js';
+import { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
+import MkGrid from '@/components/grid/MkGrid.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import { GridSetting } from '@/components/grid/grid.js';
+import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
+import MkFolder from '@/components/MkFolder.vue';
+
+function setupGrid(): GridSetting {
+ return {
+ row: {
+ showNumber: false,
+ selectable: false,
+ contextMenuFactory: (row, context) => {
+ return [
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
+ icon: 'ti ti-copy',
+ action: () => copyGridDataToClipboard(logs, context),
+ },
+ ];
+ },
+ },
+ cols: [
+ { bindTo: 'failed', title: 'failed', type: 'boolean', editable: false, width: 50 },
+ { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' },
+ { bindTo: 'name', title: 'name', type: 'text', editable: false, width: 140 },
+ { bindTo: 'error', title: 'log', type: 'text', editable: false, width: 'auto' },
+ ],
+ cells: {
+ contextMenuFactory: (col, row, value, context) => {
+ return [
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
+ icon: 'ti ti-copy',
+ action: () => copyGridDataToClipboard(logs, context),
+ },
+ ];
+ },
+ },
+ };
+}
+
+const props = defineProps<{
+ logs: RequestLogItem[];
+}>();
+
+const { logs } = toRefs(props);
+const showingSuccessLogs = ref<boolean>(false);
+
+const filteredLogs = computed(() => {
+ const forceShowing = showingSuccessLogs.value;
+ return logs.value.filter((log) => forceShowing || log.failed);
+});
+
+</script>
+
+<style module lang="scss">
+
+</style>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
new file mode 100644
index 0000000000..9a9d2990ba
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
@@ -0,0 +1,441 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <template #default>
+ <div :class="$style.root" class="_gaps">
+ <MkFolder>
+ <template #icon><i class="ti ti-search"></i></template>
+ <template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchSettings }}</template>
+ <template #caption>
+ {{ i18n.ts._customEmojisManager._gridCommon.searchSettingCaption }}
+ </template>
+
+ <div class="_gaps">
+ <div :class="[[spMode ? $style.searchAreaSp : $style.searchArea]]">
+ <MkInput
+ v-model="queryName"
+ type="search"
+ autocapitalize="off"
+ :class="[$style.col1, $style.row1]"
+ @enter="onSearchRequest"
+ >
+ <template #label>name</template>
+ </MkInput>
+ <MkInput
+ v-model="queryHost"
+ type="search"
+ autocapitalize="off"
+ :class="[$style.col2, $style.row1]"
+ @enter="onSearchRequest"
+ >
+ <template #label>host</template>
+ </MkInput>
+ <MkInput
+ v-model="queryUri"
+ type="search"
+ autocapitalize="off"
+ :class="[$style.col1, $style.row2]"
+ @enter="onSearchRequest"
+ >
+ <template #label>uri</template>
+ </MkInput>
+ <MkInput
+ v-model="queryPublicUrl"
+ type="search"
+ autocapitalize="off"
+ :class="[$style.col2, $style.row2]"
+ @enter="onSearchRequest"
+ >
+ <template #label>publicUrl</template>
+ </MkInput>
+ </div>
+
+ <MkFolder :spacerMax="8" :spacerMin="8">
+ <template #icon><i class="ti ti-arrows-sort"></i></template>
+ <template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
+ <MkSortOrderEditor
+ :baseOrderKeyNames="gridSortOrderKeys"
+ :currentOrders="sortOrders"
+ @update="onSortOrderUpdate"
+ />
+ </MkFolder>
+
+ <div :class="[[spMode ? $style.searchButtonsSp : $style.searchButtons]]">
+ <MkButton primary @click="onSearchRequest">
+ {{ i18n.ts.search }}
+ </MkButton>
+ <MkButton @click="onQueryResetButtonClicked">
+ {{ i18n.ts.reset }}
+ </MkButton>
+ </div>
+ </div>
+ </MkFolder>
+
+ <XRegisterLogsFolder :logs="requestLogs"/>
+
+ <component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
+ <template v-else>
+ <div v-if="gridItems.length === 0" style="text-align: center">
+ {{ i18n.ts._customEmojisManager._local._list.emojisNothing }}
+ </div>
+
+ <template v-else>
+ <div v-if="gridItems.length > 0" :class="$style.gridArea">
+ <MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/>
+ </div>
+
+ <div :class="$style.footer">
+ <div>
+ <!-- レイアウト調整用のスペース -->
+ </div>
+
+ <div :class="$style.center">
+ <MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
+ </div>
+
+ <div :class="$style.right">
+ <MkButton primary @click="onImportClicked">
+ {{
+ i18n.ts._customEmojisManager._remote.importEmojisButton
+ }} ({{ checkedItemsCount }})
+ </MkButton>
+ </div>
+ </div>
+ </template>
+ </template>
+ </div>
+ </template>
+</MkStickyContainer>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, useCssModule } from 'vue';
+import * as Misskey from 'misskey-js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkGrid from '@/components/grid/MkGrid.vue';
+import {
+ emptyStrToUndefined,
+ GridSortOrderKey,
+ gridSortOrderKeys,
+ RequestLogItem,
+} from '@/pages/admin/custom-emojis-manager.impl.js';
+import { GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
+import MkFolder from '@/components/MkFolder.vue';
+import XRegisterLogsFolder from '@/pages/admin/custom-emojis-manager.logs-folder.vue';
+import * as os from '@/os.js';
+import { GridSetting } from '@/components/grid/grid.js';
+import { deviceKind } from '@/scripts/device-kind.js';
+import MkPagingButtons from '@/components/MkPagingButtons.vue';
+import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
+import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
+import { useLoading } from "@/components/hook/useLoading.js";
+
+type GridItem = {
+ checked: boolean;
+ id: string;
+ url: string;
+ name: string;
+ host: string;
+}
+
+function setupGrid(): GridSetting {
+ const $style = useCssModule();
+
+ return {
+ row: {
+ // グリッドの行数をあらかじめ100行確保する
+ minimumDefinitionCount: 100,
+ styleRules: [
+ {
+ // チェックされたら背景色を変える
+ condition: ({ row }) => gridItems.value[row.index].checked,
+ applyStyle: { className: $style.changedRow },
+ },
+ ],
+ contextMenuFactory: (row, context) => {
+ return [
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._remote.importSelectionRows,
+ icon: 'ti ti-download',
+ action: async () => {
+ const targets = context.rangedRows.map(it => gridItems.value[it.index]);
+ await importEmojis(targets);
+ },
+ },
+ ];
+ },
+ },
+ cols: [
+ { bindTo: 'checked', icon: 'ti-download', type: 'boolean', editable: true, width: 34 },
+ { bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' },
+ { bindTo: 'name', title: 'name', type: 'text', editable: false, width: 'auto' },
+ { bindTo: 'host', title: 'host', type: 'text', editable: false, width: 'auto' },
+ { bindTo: 'uri', title: 'uri', type: 'text', editable: false, width: 'auto' },
+ { bindTo: 'publicUrl', title: 'publicUrl', type: 'text', editable: false, width: 'auto' },
+ ],
+ cells: {
+ contextMenuFactory: (col, row, value, context) => {
+ return [
+ {
+ type: 'button',
+ text: i18n.ts._customEmojisManager._remote.importSelectionRangesRows,
+ icon: 'ti ti-download',
+ action: async () => {
+ const targets = context.rangedCells.map(it => gridItems.value[it.row.index]);
+ await importEmojis(targets);
+ },
+ },
+ ];
+ },
+ },
+ };
+}
+
+const loadingHandler = useLoading();
+
+const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]);
+const allPages = ref<number>(0);
+const currentPage = ref<number>(0);
+
+const queryName = ref<string | null>(null);
+const queryHost = ref<string | null>(null);
+const queryUri = ref<string | null>(null);
+const queryPublicUrl = ref<string | null>(null);
+const previousQuery = ref<string | undefined>(undefined);
+const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
+const requestLogs = ref<RequestLogItem[]>([]);
+
+const gridItems = ref<GridItem[]>([]);
+
+const spMode = computed(() => ['smartphone', 'tablet'].includes(deviceKind));
+const checkedItemsCount = computed(() => gridItems.value.filter(it => it.checked).length);
+
+function onSortOrderUpdate(_sortOrders: SortOrder<GridSortOrderKey>[]) {
+ sortOrders.value = _sortOrders;
+}
+
+async function onSearchRequest() {
+ await refreshCustomEmojis();
+}
+
+function onQueryResetButtonClicked() {
+ queryName.value = null;
+ queryHost.value = null;
+ queryUri.value = null;
+ queryPublicUrl.value = null;
+}
+
+async function onPageChanged(pageNumber: number) {
+ currentPage.value = pageNumber;
+ await refreshCustomEmojis();
+}
+
+async function onImportClicked() {
+ const targets = gridItems.value.filter(it => it.checked);
+ await importEmojis(targets);
+}
+
+function onGridEvent(event: GridEvent) {
+ switch (event.type) {
+ case 'cell-value-change':
+ onGridCellValueChange(event);
+ break;
+ }
+}
+
+function onGridCellValueChange(event: GridCellValueChangeEvent) {
+ const { row, column, newValue } = event;
+ if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
+ gridItems.value[row.index][column.setting.bindTo] = newValue;
+ }
+}
+
+async function importEmojis(targets: GridItem[]) {
+ const confirm = await os.confirm({
+ type: 'info',
+ title: i18n.ts._customEmojisManager._remote.confirmImportEmojisTitle,
+ text: i18n.tsx._customEmojisManager._remote.confirmImportEmojisDescription({ count: targets.length }),
+ });
+
+ if (confirm.canceled) {
+ return;
+ }
+
+ const result = await os.promiseDialog(
+ Promise.all(
+ targets.map(item =>
+ misskeyApi(
+ 'admin/emoji/copy',
+ {
+ emojiId: item.id!,
+ })
+ .then(() => ({ item, success: true, err: undefined }))
+ .catch(err => ({ item, success: false, err })),
+ ),
+ ),
+ );
+ const failedItems = result.filter(it => !it.success);
+
+ if (failedItems.length > 0) {
+ await os.alert({
+ type: 'error',
+ title: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedTitle,
+ text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
+ });
+ }
+
+ requestLogs.value = result.map(it => ({
+ failed: !it.success,
+ url: it.item.url,
+ name: it.item.name,
+ error: it.err ? JSON.stringify(it.err) : undefined,
+ }));
+
+ await refreshCustomEmojis();
+}
+
+async function refreshCustomEmojis() {
+ const query: Misskey.entities.V2AdminEmojiListRequest['query'] = {
+ name: emptyStrToUndefined(queryName.value),
+ host: emptyStrToUndefined(queryHost.value),
+ uri: emptyStrToUndefined(queryUri.value),
+ publicUrl: emptyStrToUndefined(queryPublicUrl.value),
+ hostType: 'remote',
+ };
+
+ if (JSON.stringify(query) !== previousQuery.value) {
+ currentPage.value = 1;
+ }
+
+ const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', {
+ limit: 100,
+ query: query,
+ page: currentPage.value,
+ sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}`) as never[],
+ }));
+
+ customEmojis.value = result.emojis;
+ allPages.value = result.allPages;
+ previousQuery.value = JSON.stringify(query);
+ gridItems.value = customEmojis.value.map(it => ({
+ checked: false,
+ id: it.id,
+ url: it.publicUrl,
+ name: it.name,
+ host: it.host!,
+ }));
+}
+
+onMounted(async () => {
+ await refreshCustomEmojis();
+});
+</script>
+
+<style module lang="scss">
+.row1 {
+ grid-row: 1 / 2;
+}
+
+.row2 {
+ grid-row: 2 / 3;
+}
+
+.col1 {
+ grid-column: 1 / 2;
+}
+
+.col2 {
+ grid-column: 2 / 3;
+}
+
+.root {
+ padding: 16px;
+}
+
+.changedRow {
+ background-color: var(--MI_THEME-infoBg);
+}
+
+.searchArea {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+.searchButtons {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+ gap: 8px;
+}
+
+.searchButtonsSp {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+}
+
+.searchAreaSp {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.gridArea {
+ padding-top: 8px;
+ padding-bottom: 8px;
+}
+
+.pages {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ button {
+ background-color: var(--MI_THEME-buttonBg);
+ border-radius: 9999px;
+ border: none;
+ margin: 0 4px;
+ padding: 8px;
+ }
+}
+
+.footer {
+ background-color: var(--MI_THEME-bg);
+
+ position: sticky;
+ left:0;
+ bottom:0;
+ z-index: 1;
+ // stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
+ margin-top: calc(var(--MI-margin) * -1);
+ margin-bottom: calc(var(--MI-margin) * -1);
+ padding-top: var(--MI-margin);
+ padding-bottom: var(--MI-margin);
+
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 8px;
+
+ & .center {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ & .right {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts
new file mode 100644
index 0000000000..f62304277a
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts
@@ -0,0 +1,160 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { delay, http, HttpResponse } from 'msw';
+import { StoryObj } from '@storybook/vue3';
+import { entities } from 'misskey-js';
+import { commonHandlers } from '../../../.storybook/mocks.js';
+import { emoji } from '../../../.storybook/fakes.js';
+import { fakeId } from '../../../.storybook/fake-utils.js';
+import custom_emojis_manager2 from './custom-emojis-manager2.vue';
+
+function createRender(params: {
+ emojis: entities.EmojiDetailedAdmin[];
+}) {
+ const storedEmojis: entities.EmojiDetailedAdmin[] = [...params.emojis];
+ const storedDriveFiles: entities.DriveFile[] = [];
+
+ return {
+ render(args) {
+ return {
+ components: {
+ custom_emojis_manager2,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<custom_emojis_manager2 v-bind="props" />',
+ };
+ },
+ args: {
+
+ },
+ parameters: {
+ layout: 'fullscreen',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ http.post('/api/v2/admin/emoji/list', async ({ request }) => {
+ await delay(100);
+
+ const bodyStream = request.body as ReadableStream;
+ const body = await new Response(bodyStream).json() as entities.V2AdminEmojiListRequest;
+
+ const emojis = storedEmojis;
+ const limit = body.limit ?? 10;
+ const page = body.page ?? 1;
+ const result = emojis.slice((page - 1) * limit, page * limit);
+
+ return HttpResponse.json({
+ emojis: result,
+ count: Math.min(emojis.length, limit),
+ allCount: emojis.length,
+ allPages: Math.ceil(emojis.length / limit),
+ });
+ }),
+ http.post('/api/drive/folders', () => {
+ return HttpResponse.json([]);
+ }),
+ http.post('/api/drive/files', () => {
+ return HttpResponse.json(storedDriveFiles);
+ }),
+ http.post('/api/drive/files/create', async ({ request }) => {
+ const data = await request.formData();
+ const file = data.get('file');
+ if (!file || !(file instanceof File)) {
+ return HttpResponse.json({ error: 'file is required' }, {
+ status: 400,
+ });
+ }
+
+ // FIXME: ファイルのバイナリに0xEF 0xBF 0xBDが混入してしまい、うまく画像ファイルとして表示できない問題がある
+ const base64 = await new Promise<string>((resolve) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ resolve(reader.result as string);
+ };
+ reader.readAsDataURL(new Blob([file], { type: 'image/webp' }));
+ });
+
+ const driveFile: entities.DriveFile = {
+ id: fakeId(file.name),
+ createdAt: new Date().toISOString(),
+ name: file.name,
+ type: file.type,
+ md5: '',
+ size: file.size,
+ isSensitive: false,
+ blurhash: null,
+ properties: {},
+ url: base64,
+ thumbnailUrl: null,
+ comment: null,
+ folderId: null,
+ folder: null,
+ userId: null,
+ user: null,
+ };
+
+ storedDriveFiles.push(driveFile);
+
+ return HttpResponse.json(driveFile);
+ }),
+ http.post('api/admin/emoji/add', async ({ request }) => {
+ await delay(100);
+
+ const bodyStream = request.body as ReadableStream;
+ const body = await new Response(bodyStream).json() as entities.AdminEmojiAddRequest;
+
+ const fileId = body.fileId;
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const file = storedDriveFiles.find(f => f.id === fileId)!;
+
+ const em = emoji({
+ id: fakeId(file.name),
+ name: body.name,
+ publicUrl: file.url,
+ originalUrl: file.url,
+ type: file.type,
+ aliases: body.aliases,
+ category: body.category ?? undefined,
+ license: body.license ?? undefined,
+ localOnly: body.localOnly,
+ isSensitive: body.isSensitive,
+ });
+ storedEmojis.push(em);
+
+ return HttpResponse.json(null);
+ }),
+ ],
+ },
+ },
+ } satisfies StoryObj<typeof custom_emojis_manager2>;
+}
+
+export const Default = createRender({
+ emojis: [],
+});
+
+export const List10 = createRender({
+ emojis: Array.from({ length: 10 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
+});
+
+export const List100 = createRender({
+ emojis: Array.from({ length: 100 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
+});
+
+export const List1000 = createRender({
+ emojis: Array.from({ length: 1000 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
+});
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
new file mode 100644
index 0000000000..a952a5a3d1
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
@@ -0,0 +1,44 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+ <!-- コンテナが入れ子になるのでz-indexが被らないよう大きめの数値を設定する-->
+ <MkStickyContainer :headerZIndex="2000">
+ <template #header>
+ <MkPageHeader v-model:tab="headerTab" :tabs="headerTabs"/>
+ </template>
+ <XGridLocalComponent v-if="headerTab === 'local'"/>
+ <XGridRemoteComponent v-else/>
+ </MkStickyContainer>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { i18n } from '@/i18n.js';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+import XGridLocalComponent from '@/pages/admin/custom-emojis-manager.local.vue';
+import XGridRemoteComponent from '@/pages/admin/custom-emojis-manager.remote.vue';
+import MkPageHeader from '@/components/global/MkPageHeader.vue';
+import MkStickyContainer from '@/components/global/MkStickyContainer.vue';
+
+type PageMode = 'local' | 'remote';
+
+const headerTab = ref<PageMode>('local');
+
+const headerTabs = computed(() => [{
+ key: 'local',
+ title: i18n.ts.local,
+}, {
+ key: 'remote',
+ title: i18n.ts.remote,
+}]);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.customEmojis,
+ icon: 'ti ti-icons',
+})));
+</script>
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index fd15ae1d66..969ca8b9e8 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -122,6 +122,11 @@ const menuDef = computed(() => [{
to: '/admin/emojis',
active: currentPage.value?.route.name === 'emojis',
}, {
+ icon: 'ti ti-icons',
+ text: i18n.ts.customEmojis + '(beta)',
+ to: '/admin/emojis2',
+ active: currentPage.value?.route.name === 'emojis2',
+ }, {
icon: 'ti ti-sparkles',
text: i18n.ts.avatarDecorations,
to: '/admin/avatar-decorations',
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index e98e0b59b1..732b209a36 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -383,6 +383,10 @@ const routes: RouteDef[] = [{
name: 'emojis',
component: page(() => import('@/pages/custom-emojis-manager.vue')),
}, {
+ path: '/emojis2',
+ name: 'emojis2',
+ component: page(() => import('@/pages/admin/custom-emojis-manager2.vue')),
+ }, {
path: '/avatar-decorations',
name: 'avatarDecorations',
component: page(() => import('@/pages/avatar-decorations.vue')),
diff --git a/packages/frontend/src/scripts/file-drop.ts b/packages/frontend/src/scripts/file-drop.ts
new file mode 100644
index 0000000000..c2e863c0dc
--- /dev/null
+++ b/packages/frontend/src/scripts/file-drop.ts
@@ -0,0 +1,121 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export type DroppedItem = DroppedFile | DroppedDirectory;
+
+export type DroppedFile = {
+ isFile: true;
+ path: string;
+ file: File;
+};
+
+export type DroppedDirectory = {
+ isFile: false;
+ path: string;
+ children: DroppedItem[];
+}
+
+export async function extractDroppedItems(ev: DragEvent): Promise<DroppedItem[]> {
+ const dropItems = ev.dataTransfer?.items;
+ if (!dropItems || dropItems.length === 0) {
+ return [];
+ }
+
+ const apiTestItem = dropItems[0];
+ if ('webkitGetAsEntry' in apiTestItem) {
+ return readDataTransferItems(dropItems);
+ } else {
+ // webkitGetAsEntryに対応していない場合はfilesから取得する(ディレクトリのサポートは出来ない)
+ const dropFiles = ev.dataTransfer.files;
+ if (dropFiles.length === 0) {
+ return [];
+ }
+
+ const droppedFiles = Array.of<DroppedFile>();
+ for (let i = 0; i < dropFiles.length; i++) {
+ const file = dropFiles.item(i);
+ if (file) {
+ droppedFiles.push({
+ isFile: true,
+ path: file.name,
+ file,
+ });
+ }
+ }
+
+ return droppedFiles;
+ }
+}
+
+/**
+ * ドラッグ&ドロップされたファイルのリストからディレクトリ構造とファイルへの参照({@link File})を取得する。
+ */
+export async function readDataTransferItems(itemList: DataTransferItemList): Promise<DroppedItem[]> {
+ async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> {
+ if (entry.isFile) {
+ return {
+ isFile: true,
+ path: entry.fullPath,
+ file: await readFile(entry as FileSystemFileEntry),
+ };
+ } else {
+ return {
+ isFile: false,
+ path: entry.fullPath,
+ children: await readDirectory(entry as FileSystemDirectoryEntry),
+ };
+ }
+ }
+
+ function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> {
+ return new Promise((resolve, reject) => {
+ fileSystemFileEntry.file(resolve, reject);
+ });
+ }
+
+ function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> {
+ return new Promise(async (resolve) => {
+ const allEntries = Array.of<FileSystemEntry>();
+ const reader = fileSystemDirectoryEntry.createReader();
+ while (true) {
+ const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej));
+ if (entries.length === 0) {
+ break;
+ }
+ allEntries.push(...entries);
+ }
+
+ resolve(await Promise.all(allEntries.map(readEntry)));
+ });
+ }
+
+ // 扱いにくいので配列に変換
+ const items = Array.of<DataTransferItem>();
+ for (let i = 0; i < itemList.length; i++) {
+ items.push(itemList[i]);
+ }
+
+ return Promise.all(
+ items
+ .map(it => it.webkitGetAsEntry())
+ .filter(it => it)
+ .map(it => readEntry(it!)),
+ );
+}
+
+/**
+ * {@link DroppedItem}のリストからディレクトリを再帰的に検索し、ファイルのリストを取得する。
+ */
+export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] {
+ const result = Array.of<DroppedFile>();
+ for (const item of items) {
+ if (item.isFile) {
+ result.push(item);
+ } else {
+ result.push(...flattenDroppedFiles(item.children));
+ }
+ }
+ return result;
+}
diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/scripts/key-event.ts
new file mode 100644
index 0000000000..a72776d48c
--- /dev/null
+++ b/packages/frontend/src/scripts/key-event.ts
@@ -0,0 +1,153 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
+ */
+export type KeyCode =
+ | 'Backspace'
+ | 'Tab'
+ | 'Enter'
+ | 'Shift'
+ | 'Control'
+ | 'Alt'
+ | 'Pause'
+ | 'CapsLock'
+ | 'Escape'
+ | 'Space'
+ | 'PageUp'
+ | 'PageDown'
+ | 'End'
+ | 'Home'
+ | 'ArrowLeft'
+ | 'ArrowUp'
+ | 'ArrowRight'
+ | 'ArrowDown'
+ | 'Insert'
+ | 'Delete'
+ | 'Digit0'
+ | 'Digit1'
+ | 'Digit2'
+ | 'Digit3'
+ | 'Digit4'
+ | 'Digit5'
+ | 'Digit6'
+ | 'Digit7'
+ | 'Digit8'
+ | 'Digit9'
+ | 'KeyA'
+ | 'KeyB'
+ | 'KeyC'
+ | 'KeyD'
+ | 'KeyE'
+ | 'KeyF'
+ | 'KeyG'
+ | 'KeyH'
+ | 'KeyI'
+ | 'KeyJ'
+ | 'KeyK'
+ | 'KeyL'
+ | 'KeyM'
+ | 'KeyN'
+ | 'KeyO'
+ | 'KeyP'
+ | 'KeyQ'
+ | 'KeyR'
+ | 'KeyS'
+ | 'KeyT'
+ | 'KeyU'
+ | 'KeyV'
+ | 'KeyW'
+ | 'KeyX'
+ | 'KeyY'
+ | 'KeyZ'
+ | 'MetaLeft'
+ | 'MetaRight'
+ | 'ContextMenu'
+ | 'F1'
+ | 'F2'
+ | 'F3'
+ | 'F4'
+ | 'F5'
+ | 'F6'
+ | 'F7'
+ | 'F8'
+ | 'F9'
+ | 'F10'
+ | 'F11'
+ | 'F12'
+ | 'NumLock'
+ | 'ScrollLock'
+ | 'Semicolon'
+ | 'Equal'
+ | 'Comma'
+ | 'Minus'
+ | 'Period'
+ | 'Slash'
+ | 'Backquote'
+ | 'BracketLeft'
+ | 'Backslash'
+ | 'BracketRight'
+ | 'Quote'
+ | 'Meta'
+ | 'AltGraph'
+ ;
+
+/**
+ * 修飾キーを表す文字列。不足分は適宜追加する。
+ */
+export type KeyModifier =
+ | 'Shift'
+ | 'Control'
+ | 'Alt'
+ | 'Meta'
+ ;
+
+/**
+ * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。
+ */
+export type KeyState =
+ | 'composing'
+ | 'repeat'
+ ;
+
+export type KeyEventHandler = {
+ modifiers?: KeyModifier[];
+ states?: KeyState[];
+ code: KeyCode | 'any';
+ handler: (event: KeyboardEvent) => void;
+}
+
+export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) {
+ function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) {
+ if (modifiers) {
+ return modifiers.every(modifier => ev.getModifierState(modifier));
+ }
+ return true;
+ }
+
+ function checkState(ev: KeyboardEvent, states?: KeyState[]) {
+ if (states) {
+ return states.every(state => ev.getModifierState(state));
+ }
+ return true;
+ }
+
+ let hit = false;
+ for (const handler of handlers.filter(it => it.code === event.code)) {
+ if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) {
+ handler.handler(event);
+ hit = true;
+ break;
+ }
+ }
+
+ if (!hit) {
+ for (const handler of handlers.filter(it => it.code === 'any')) {
+ handler.handler(event);
+ }
+ }
+}
diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts
index b037aa8acc..c25b4d73bd 100644
--- a/packages/frontend/src/scripts/select-file.ts
+++ b/packages/frontend/src/scripts/select-file.ts
@@ -12,14 +12,28 @@ import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { uploadFile } from '@/scripts/upload.js';
-export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise<Misskey.entities.DriveFile[]> {
+export function chooseFileFromPc(
+ multiple: boolean,
+ options?: {
+ uploadFolder?: string | null;
+ keepOriginal?: boolean;
+ nameConverter?: (file: File) => string | undefined;
+ },
+): Promise<Misskey.entities.DriveFile[]> {
+ const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder;
+ const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading;
+ const nameConverter = options?.nameConverter ?? (() => undefined);
+
return new Promise((res, rej) => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = multiple;
input.onchange = () => {
if (!input.files) return res([]);
- const promises = Array.from(input.files, file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal));
+ const promises = Array.from(
+ input.files,
+ file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
+ );
Promise.all(promises).then(driveFiles => {
res(driveFiles);
@@ -94,7 +108,7 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
- action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)),
+ action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)),
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 211ddb8287..7098b52205 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1118,6 +1118,9 @@ type EmojiDeleted = {
type EmojiDetailed = components['schemas']['EmojiDetailed'];
// @public (undocumented)
+type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin'];
+
+// @public (undocumented)
type EmojiRequest = operations['emoji']['requestBody']['content']['application/json'];
// @public (undocumented)
@@ -1294,6 +1297,8 @@ declare namespace entities {
AdminEmojiSetCategoryBulkRequest,
AdminEmojiSetLicenseBulkRequest,
AdminEmojiUpdateRequest,
+ V2AdminEmojiListRequest,
+ V2AdminEmojiListResponse,
AdminFederationDeleteAllFilesRequest,
AdminFederationRefreshRemoteInstanceMetadataRequest,
AdminFederationRemoveAllFollowingRequest,
@@ -1847,6 +1852,7 @@ declare namespace entities {
GalleryPost,
EmojiSimple,
EmojiDetailed,
+ EmojiDetailedAdmin,
Flash,
Signin,
RoleCondFormulaLogics,
@@ -3420,6 +3426,12 @@ type UsersShowResponse = operations['users___show']['responses']['200']['content
// @public (undocumented)
type UsersUpdateMemoRequest = operations['users___update-memo']['requestBody']['content']['application/json'];
+// @public (undocumented)
+type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json'];
+
// Warnings were encountered during analysis:
//
// src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index 3bcdae6a4a..edaa0498e9 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -496,6 +496,17 @@ declare module '../api.js' {
/**
* No description provided.
*
+ * **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
+ */
+ request<E extends 'v2/admin/emoji/list', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
* **Credential required**: *Yes* / **Permission**: *write:admin:federation*
*/
request<E extends 'admin/federation/delete-all-files', P extends Endpoints[E]['req']>(
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index b016d5bbcf..982717597b 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -62,6 +62,8 @@ import type {
AdminEmojiSetCategoryBulkRequest,
AdminEmojiSetLicenseBulkRequest,
AdminEmojiUpdateRequest,
+ V2AdminEmojiListRequest,
+ V2AdminEmojiListResponse,
AdminFederationDeleteAllFilesRequest,
AdminFederationRefreshRemoteInstanceMetadataRequest,
AdminFederationRemoveAllFollowingRequest,
@@ -628,6 +630,7 @@ export type Endpoints = {
'admin/emoji/set-category-bulk': { req: AdminEmojiSetCategoryBulkRequest; res: EmptyResponse };
'admin/emoji/set-license-bulk': { req: AdminEmojiSetLicenseBulkRequest; res: EmptyResponse };
'admin/emoji/update': { req: AdminEmojiUpdateRequest; res: EmptyResponse };
+ 'v2/admin/emoji/list': { req: V2AdminEmojiListRequest; res: V2AdminEmojiListResponse };
'admin/federation/delete-all-files': { req: AdminFederationDeleteAllFilesRequest; res: EmptyResponse };
'admin/federation/refresh-remote-instance-metadata': { req: AdminFederationRefreshRemoteInstanceMetadataRequest; res: EmptyResponse };
'admin/federation/remove-all-following': { req: AdminFederationRemoveAllFollowingRequest; res: EmptyResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index 02be4848c7..e4299d62c7 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -65,6 +65,8 @@ export type AdminEmojiSetAliasesBulkRequest = operations['admin___emoji___set-al
export type AdminEmojiSetCategoryBulkRequest = operations['admin___emoji___set-category-bulk']['requestBody']['content']['application/json'];
export type AdminEmojiSetLicenseBulkRequest = operations['admin___emoji___set-license-bulk']['requestBody']['content']['application/json'];
export type AdminEmojiUpdateRequest = operations['admin___emoji___update']['requestBody']['content']['application/json'];
+export type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json'];
+export type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json'];
export type AdminFederationDeleteAllFilesRequest = operations['admin___federation___delete-all-files']['requestBody']['content']['application/json'];
export type AdminFederationRefreshRemoteInstanceMetadataRequest = operations['admin___federation___refresh-remote-instance-metadata']['requestBody']['content']['application/json'];
export type AdminFederationRemoveAllFollowingRequest = operations['admin___federation___remove-all-following']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index 04574849d4..1a30da4437 100644
--- a/packages/misskey-js/src/autogen/models.ts
+++ b/packages/misskey-js/src/autogen/models.ts
@@ -33,6 +33,7 @@ export type FederationInstance = components['schemas']['FederationInstance'];
export type GalleryPost = components['schemas']['GalleryPost'];
export type EmojiSimple = components['schemas']['EmojiSimple'];
export type EmojiDetailed = components['schemas']['EmojiDetailed'];
+export type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin'];
export type Flash = components['schemas']['Flash'];
export type Signin = components['schemas']['Signin'];
export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index ada685604d..75a99263d0 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -414,6 +414,15 @@ export type paths = {
*/
post: operations['admin___emoji___update'];
};
+ '/v2/admin/emoji/list': {
+ /**
+ * v2/admin/emoji/list
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
+ */
+ post: operations['v2___admin___emoji___list'];
+ };
'/admin/federation/delete-all-files': {
/**
* admin/federation/delete-all-files
@@ -4749,6 +4758,29 @@ export type components = {
localOnly: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction: string[];
};
+ EmojiDetailedAdmin: {
+ /** Format: id */
+ id: string;
+ /** Format: date-time */
+ updatedAt: string | null;
+ name: string;
+ /** @description The local host is represented with `null`. */
+ host: string | null;
+ publicUrl: string;
+ originalUrl: string;
+ uri: string | null;
+ type: string | null;
+ aliases: string[];
+ category: string | null;
+ license: string | null;
+ localOnly: boolean;
+ isSensitive: boolean;
+ roleIdsThatCanBeUsedThisEmojiAsReaction: {
+ /** Format: misskey:id */
+ id: string;
+ name: string;
+ }[];
+ };
Flash: {
/**
* Format: id
@@ -7873,6 +7905,97 @@ export type operations = {
};
};
/**
+ * v2/admin/emoji/list
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
+ */
+ v2___admin___emoji___list: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ query?: ({
+ updatedAtFrom?: string;
+ updatedAtTo?: string;
+ name?: string;
+ host?: string;
+ uri?: string;
+ publicUrl?: string;
+ originalUrl?: string;
+ type?: string;
+ aliases?: string;
+ category?: string;
+ license?: string;
+ isSensitive?: boolean;
+ localOnly?: boolean;
+ /**
+ * @default all
+ * @enum {string}
+ */
+ hostType?: 'local' | 'remote' | 'all';
+ roleIds?: string[];
+ }) | null;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ /** @default 10 */
+ limit?: number;
+ page?: number;
+ /**
+ * @default [
+ * "-id"
+ * ]
+ */
+ sortKeys?: ('+id' | '-id' | '+updatedAt' | '-updatedAt' | '+name' | '-name' | '+host' | '-host' | '+uri' | '-uri' | '+publicUrl' | '-publicUrl' | '+type' | '-type' | '+aliases' | '-aliases' | '+category' | '-category' | '+license' | '-license' | '+isSensitive' | '-isSensitive' | '+localOnly' | '-localOnly' | '+roleIdsThatCanBeUsedThisEmojiAsReaction' | '-roleIdsThatCanBeUsedThisEmojiAsReaction')[];
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': {
+ emojis: components['schemas']['EmojiDetailedAdmin'][];
+ count: number;
+ allCount: number;
+ allPages: number;
+ };
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
* admin/federation/delete-all-files
* @description No description provided.
*