summaryrefslogtreecommitdiff
path: root/packages/frontend
diff options
context:
space:
mode:
authorおさむのひと <46447427+samunohito@users.noreply.github.com>2025-01-20 20:35:37 +0900
committerGitHub <noreply@github.com>2025-01-20 11:35:37 +0000
commitf9ad127aaf7875bad8fdf55f5ac98bff05997525 (patch)
treecd8a4a3be870fee215e2940cb3c7571531c71385 /packages/frontend
parentfix(client): MkSubNoteContentに対するnoteの指定が誤っていたの... (diff)
downloadmisskey-f9ad127aaf7875bad8fdf55f5ac98bff05997525.tar.gz
misskey-f9ad127aaf7875bad8fdf55f5ac98bff05997525.tar.bz2
misskey-f9ad127aaf7875bad8fdf55f5ac98bff05997525.zip
feat: 新カスタム絵文字管理画面(β)の追加 (#13473)
* wip * wip * wip * wip * wip * wip * wip * wip * fix * fix * fix * fix size * fix register logs * fix img autosize * fix row selection * support delete * fix border rendering * fix display:none * tweak comments * support choose pc file and drive file * support directory drag-drop * fix * fix comment * support context menu on data area * fix autogen * wip イベント整理 * イベントの整理 * refactor grid * fix cell re-render bugs * fix row remove * fix comment * fix validation * fix utils * list maximum * add mimetype check * fix * fix number cell focus * fix over 100 file drop * remove log * fix patchData * fix performance * fix * support update and delete * support remote import * fix layout * heightやめる * fix performance * add list v2 endpoint * support pagination * fix api call * fix no clickable input text * fix limit * fix paging * fix * fix * support search * tweak logs * tweak cell selection * fix range select * block delete * add comment * fix * support import log * fix dialog * refactor * add confirm dialog * fix name * fix autogen * wip * support image change and highlight row * add columns * wip * support sort * add role name * add index to emoji * refine context menu setting * support role select * remove unused buttons * fix url * fix MkRoleSelectDialog.vue * add route * refine remote page * enter key search * fix paste bugs * fix copy/paste * fix keyEvent * fix copy/paste and delete * fix comment * fix MkRoleSelectDialog.vue and storybook scenario * fix MkRoleSelectDialog.vue and storybook scenario * add MkGrid.stories.impl.ts * fix * [wip] add custom-emojis-manager2.stories.impl.ts * [wip] add custom-emojis-manager2.stories.impl.ts * wip * 課題はまだ残っているが、ひとまず完了 * fix validation and register roles * fix upload * optimize import * patch from dev * i18n * revert excess fixes * separate sort order component * add SPDX * revert excess fixes * fix pre test * fix bugs * add type column * fix types * fix CHANGELOG.md * fix lit * lint * tweak style * refactor * fix ci * autogen * Update types.ts * CSS Module化 * fix log * 縦スクロールを無効化 * MkStickyContainer化 * regenerate locales index.d.ts * fix * fix * テスト * ランダム値によるUI変更の抑制 * テスト * tableタグやめる * fix last-child css * fix overflow css * fix endpoint.ts * tweak css * 最新への追従とレイアウト微調整 * ソートキーの指定方法を他と合わせた * fix focus * fix layout * v2エンドポイントのルールに対応 * 表示条件などを微調整 * fix MkDataCell.vue * fix error code * fix error * add comment to MkModal.vue * Update index.d.ts * fix CHANGELOG.md * fix color theme * fix CHANGELOG.md * fix CHANGELOG.md * fix center * fix: テーブルにフォーカスがあり、通常状態であるときはキーイベントの伝搬を止める * fix: ロール選択用のダイアログにてコンディショナルロールを×ボタンで除外できなかったのを修正 * fix remote list folder * sticky footers * chore: fix ci error(just single line-break diff) * fix loading * fix like * comma to space * fix ci * fix ci * removed align-center --------- Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>
Diffstat (limited to 'packages/frontend')
-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
43 files changed, 6440 insertions, 17 deletions
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',