From f9ad127aaf7875bad8fdf55f5ac98bff05997525 Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Mon, 20 Jan 2025 20:35:37 +0900 Subject: feat: 新カスタム絵文字管理画面(β)の追加 (#13473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- packages/frontend/.storybook/fake-utils.ts | 154 +++ packages/frontend/.storybook/fakes.ts | 91 ++ packages/frontend/.storybook/generate.tsx | 4 + packages/frontend/src/components/MkFolder.vue | 6 +- packages/frontend/src/components/MkModal.vue | 31 +- .../frontend/src/components/MkPagingButtons.vue | 124 ++ .../components/MkRoleSelectDialog.stories.impl.ts | 106 ++ .../frontend/src/components/MkRoleSelectDialog.vue | 200 +++ .../src/components/MkSortOrderEditor.define.ts | 11 + .../frontend/src/components/MkSortOrderEditor.vue | 112 ++ .../src/components/MkTagItem.stories.impl.ts | 70 + packages/frontend/src/components/MkTagItem.vue | 76 ++ .../frontend/src/components/grid/MkCellTooltip.vue | 35 + .../frontend/src/components/grid/MkDataCell.vue | 391 ++++++ .../frontend/src/components/grid/MkDataRow.vue | 72 ++ .../src/components/grid/MkGrid.stories.impl.ts | 223 ++++ packages/frontend/src/components/grid/MkGrid.vue | 1342 ++++++++++++++++++++ .../frontend/src/components/grid/MkHeaderCell.vue | 216 ++++ .../frontend/src/components/grid/MkHeaderRow.vue | 60 + .../frontend/src/components/grid/MkNumberCell.vue | 61 + .../src/components/grid/cell-validators.ts | 110 ++ packages/frontend/src/components/grid/cell.ts | 88 ++ packages/frontend/src/components/grid/column.ts | 53 + .../frontend/src/components/grid/grid-event.ts | 46 + .../frontend/src/components/grid/grid-utils.ts | 215 ++++ packages/frontend/src/components/grid/grid.ts | 44 + packages/frontend/src/components/grid/row.ts | 68 + .../frontend/src/components/hook/useLoading.ts | 52 + packages/frontend/src/index.html | 1 + packages/frontend/src/os.ts | 21 + .../src/pages/admin/custom-emojis-manager.impl.ts | 56 + .../admin/custom-emojis-manager.local.list.vue | 757 +++++++++++ .../admin/custom-emojis-manager.local.register.vue | 477 +++++++ .../pages/admin/custom-emojis-manager.local.vue | 36 + .../admin/custom-emojis-manager.logs-folder.vue | 102 ++ .../pages/admin/custom-emojis-manager.remote.vue | 441 +++++++ .../admin/custom-emojis-manager2.stories.impl.ts | 160 +++ .../src/pages/admin/custom-emojis-manager2.vue | 44 + packages/frontend/src/pages/admin/index.vue | 5 + packages/frontend/src/router/definition.ts | 4 + packages/frontend/src/scripts/file-drop.ts | 121 ++ packages/frontend/src/scripts/key-event.ts | 153 +++ packages/frontend/src/scripts/select-file.ts | 20 +- 43 files changed, 6441 insertions(+), 18 deletions(-) create mode 100644 packages/frontend/.storybook/fake-utils.ts create mode 100644 packages/frontend/src/components/MkPagingButtons.vue create mode 100644 packages/frontend/src/components/MkRoleSelectDialog.stories.impl.ts create mode 100644 packages/frontend/src/components/MkRoleSelectDialog.vue create mode 100644 packages/frontend/src/components/MkSortOrderEditor.define.ts create mode 100644 packages/frontend/src/components/MkSortOrderEditor.vue create mode 100644 packages/frontend/src/components/MkTagItem.stories.impl.ts create mode 100644 packages/frontend/src/components/MkTagItem.vue create mode 100644 packages/frontend/src/components/grid/MkCellTooltip.vue create mode 100644 packages/frontend/src/components/grid/MkDataCell.vue create mode 100644 packages/frontend/src/components/grid/MkDataRow.vue create mode 100644 packages/frontend/src/components/grid/MkGrid.stories.impl.ts create mode 100644 packages/frontend/src/components/grid/MkGrid.vue create mode 100644 packages/frontend/src/components/grid/MkHeaderCell.vue create mode 100644 packages/frontend/src/components/grid/MkHeaderRow.vue create mode 100644 packages/frontend/src/components/grid/MkNumberCell.vue create mode 100644 packages/frontend/src/components/grid/cell-validators.ts create mode 100644 packages/frontend/src/components/grid/cell.ts create mode 100644 packages/frontend/src/components/grid/column.ts create mode 100644 packages/frontend/src/components/grid/grid-event.ts create mode 100644 packages/frontend/src/components/grid/grid-utils.ts create mode 100644 packages/frontend/src/components/grid/grid.ts create mode 100644 packages/frontend/src/components/grid/row.ts create mode 100644 packages/frontend/src/components/hook/useLoading.ts create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.local.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.logs-folder.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts create mode 100644 packages/frontend/src/pages/admin/custom-emojis-manager2.vue create mode 100644 packages/frontend/src/scripts/file-drop.ts create mode 100644 packages/frontend/src/scripts/key-event.ts (limited to 'packages/frontend') 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(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 { 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 >
- +
@@ -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(); 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; - - // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する - 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 }); + // 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 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 @@ + + + + + + + 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: '', + }; + }, + 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: '
', + })], +} satisfies StoryObj; + +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; + +export const InfoMessage = { + ...Default, + args: { + ...Default.args, + infoMessage: 'This is a message.', + }, +} satisfies StoryObj; + +export const Title = { + ...Default, + args: { + ...Default.args, + title: 'Select roles', + }, +} satisfies StoryObj; + +export const Full = { + ...Default, + args: { + ...Default.args, + initialRoleIds: roles.map(it => it.id), + infoMessage: InfoMessage.args.infoMessage, + title: Title.args.title, + }, +} satisfies StoryObj; + +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; 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 @@ + + + + + + + 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 = { + 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 @@ + + + + + + + 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: '', + }; + }, + args: { + content: 'name', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; + +export const Icon = { + ...Default, + args: { + ...Default.args, + iconClass: 'ti ti-arrow-up', + }, +} satisfies StoryObj; + +export const ExButton = { + ...Default, + args: { + ...Default.args, + exButtonIconClass: 'ti ti-x', + }, +} satisfies StoryObj; + +export const IconExButton = { + ...Default, + args: { + ...Default.args, + iconClass: 'ti ti-arrow-up', + exButtonIconClass: 'ti ti-x', + }, +} satisfies StoryObj; 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 @@ + + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + + + 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, data?: DataSource[] }) { + const refData = ref[]>([]); + 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: '
', + }; + }, + args: { + ...params, + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + ], + }, + }, + } satisfies StoryObj; +} + +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 @@ + + + + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + + + 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 | NonNullable; + +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; +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[], + context: GridContext, +) { + const items = isRef(gridItems) ? gridItems.value : gridItems; + const lines = Array.of(); + const bounds = context.randedBounds; + + for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) { + const rowItems = Array.of(); + 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; + +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; +} + +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 = { + 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 = (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 { + 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): Promise { 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 @@ + + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + + + 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: '', + }; + }, + 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((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; +} + +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 @@ + + + + + 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 @@ -121,6 +121,11 @@ const menuDef = computed(() => [{ text: i18n.ts.customEmojis, 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, 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 @@ -382,6 +382,10 @@ const routes: RouteDef[] = [{ path: '/emojis', 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', 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 { + 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(); + 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 { + async function readEntry(entry: FileSystemEntry): Promise { + 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 { + return new Promise((resolve, reject) => { + fileSystemFileEntry.file(resolve, reject); + }); + } + + function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise { + return new Promise(async (resolve) => { + const allEntries = Array.of(); + const reader = fileSystemDirectoryEntry.createReader(); + while (true) { + const entries = await new Promise((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(); + 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(); + 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 { +export function chooseFileFromPc( + multiple: boolean, + options?: { + uploadFolder?: string | null; + keepOriginal?: boolean; + nameConverter?: (file: File) => string | undefined; + }, +): Promise { + 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', -- cgit v1.2.3-freya