From f9ad127aaf7875bad8fdf55f5ac98bff05997525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Mon, 20 Jan 2025 20:35:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E3=82=AB=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=A0=E7=B5=B5=E6=96=87=E5=AD=97=E7=AE=A1=E7=90=86=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=EF=BC=88=CE=B2=EF=BC=89=E3=81=AE=E8=BF=BD=E5=8A=A0=20?= =?UTF-8?q?(#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> --- CHANGELOG.md | 3 +- locales/index.d.ts | 224 +++ locales/ja-JP.yml | 64 + .../1709126576000-optimize-emoji-index.js | 18 + packages/backend/src/const.ts | 12 + .../backend/src/core/CustomEmojiService.ts | 224 ++- .../src/core/entities/EmojiEntityService.ts | 90 +- packages/backend/src/misc/json-schema.ts | 7 +- .../backend/src/models/json-schema/emoji.ts | 83 + .../ImportCustomEmojisProcessorService.ts | 5 +- .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../server/api/endpoints/admin/emoji/add.ts | 31 +- .../server/api/endpoints/admin/emoji/copy.ts | 4 +- .../api/endpoints/admin/emoji/update.ts | 6 +- .../api/endpoints/v2/admin/emoji/list.ts | 126 ++ .../backend/test/unit/CustomEmojiService.ts | 817 ++++++++++ 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 +- .../src/components/MkPagingButtons.vue | 124 ++ .../MkRoleSelectDialog.stories.impl.ts | 106 ++ .../src/components/MkRoleSelectDialog.vue | 200 +++ .../components/MkSortOrderEditor.define.ts | 11 + .../src/components/MkSortOrderEditor.vue | 112 ++ .../src/components/MkTagItem.stories.impl.ts | 70 + .../frontend/src/components/MkTagItem.vue | 76 + .../src/components/grid/MkCellTooltip.vue | 35 + .../src/components/grid/MkDataCell.vue | 391 +++++ .../src/components/grid/MkDataRow.vue | 72 + .../components/grid/MkGrid.stories.impl.ts | 223 +++ .../frontend/src/components/grid/MkGrid.vue | 1342 +++++++++++++++++ .../src/components/grid/MkHeaderCell.vue | 216 +++ .../src/components/grid/MkHeaderRow.vue | 60 + .../src/components/grid/MkNumberCell.vue | 61 + .../src/components/grid/cell-validators.ts | 110 ++ packages/frontend/src/components/grid/cell.ts | 88 ++ .../frontend/src/components/grid/column.ts | 53 + .../src/components/grid/grid-event.ts | 46 + .../src/components/grid/grid-utils.ts | 215 +++ packages/frontend/src/components/grid/grid.ts | 44 + packages/frontend/src/components/grid/row.ts | 68 + .../src/components/hook/useLoading.ts | 52 + packages/frontend/src/index.html | 1 + packages/frontend/src/os.ts | 21 + .../pages/admin/custom-emojis-manager.impl.ts | 56 + .../custom-emojis-manager.local.list.vue | 757 ++++++++++ .../custom-emojis-manager.local.register.vue | 477 ++++++ .../admin/custom-emojis-manager.local.vue | 36 + .../custom-emojis-manager.logs-folder.vue | 102 ++ .../admin/custom-emojis-manager.remote.vue | 441 ++++++ .../custom-emojis-manager2.stories.impl.ts | 160 ++ .../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 +- packages/misskey-js/etc/misskey-js.api.md | 12 + .../misskey-js/src/autogen/apiClientJSDoc.ts | 11 + packages/misskey-js/src/autogen/endpoint.ts | 3 + packages/misskey-js/src/autogen/entities.ts | 2 + packages/misskey-js/src/autogen/models.ts | 1 + packages/misskey-js/src/autogen/types.ts | 123 ++ 66 files changed, 8274 insertions(+), 57 deletions(-) create mode 100644 packages/backend/migration/1709126576000-optimize-emoji-index.js create mode 100644 packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts create mode 100644 packages/backend/test/unit/CustomEmojiService.ts 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 55833c59a1..2fa49e3456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ - 詳細㯠#14730 ãŠã‚ˆã³ `.config/example.yml` ã¾ãŸã¯ `.config/docker_example.yml`ã®'Fulltext search configuration'ã‚’ã”å‚照願ã„ã¾ã™. ### General -- +- Feat: カスタム絵文å—管ç†ç”»é¢ã‚’リニューアル #10996 + * β版ã¨ã—ã¦å…¬é–‹ã®ãŸã‚ã€æ—§ç”»é¢ã‚‚引ã続ã利用å¯èƒ½ã§ã™ ### Client - Enhance: PCç”»é¢ã§ãƒãƒ£ãƒ³ãƒãƒ«ãŒè¤‡æ•°åˆ—ã§è¡¨ç¤ºã•ã‚Œã‚‹ã‚ˆã†ã« diff --git a/locales/index.d.ts b/locales/index.d.ts index 8bd0e647b1..b98fd5d423 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -36,6 +36,10 @@ export interface Locale extends ILocale { * 検索 */ "search": string; + /** + * リセット + */ + "reset": string; /** * 通知 */ @@ -10543,6 +10547,226 @@ export interface Locale extends ILocale { */ "native": string; }; + "_gridComponent": { + "_error": { + /** + * ã“ã®å€¤ã¯å¿…é ˆé …ç›®ã§ã™ + */ + "requiredValue": string; + /** + * æ£è¦è¡¨ç¾ã«ã‚ˆã‚‹ãƒãƒªãƒ‡ãƒ¼ã‚·ãƒ§ãƒ³ã¯type:textã®ã‚«ãƒ©ãƒ ã®ã¿ã‚µãƒãƒ¼ãƒˆã—ã¾ã™ã€‚ + */ + "columnTypeNotSupport": string; + /** + * ã“ã®å€¤ã¯{pattern}ã®ãƒ‘ターンã«ä¸€è‡´ã—ã¾ã›ã‚“ + */ + "patternNotMatch": ParameterizedString<"pattern">; + /** + * ã“ã®å€¤ã¯ä¸€æ„ã§ã‚ã‚‹å¿…è¦ãŒã‚ã‚Šã¾ã™ + */ + "notUnique": string; + }; + }; + "_roleSelectDialog": { + /** + * é¸æŠžã•ã‚Œã¦ã„ã¾ã›ã‚“ + */ + "notSelected": string; + }; + "_customEmojisManager": { + "_gridCommon": { + /** + * é¸æŠžè¡Œã‚’コピー + */ + "copySelectionRows": string; + /** + * é¸æŠžç¯„囲をコピー + */ + "copySelectionRanges": string; + /** + * é¸æŠžè¡Œã‚’削除 + */ + "deleteSelectionRows": string; + /** + * é¸æŠžç¯„囲ã®è¡Œã‚’削除 + */ + "deleteSelectionRanges": string; + /** + * 検索è¨å®š + */ + "searchSettings": string; + /** + * 検索æ¡ä»¶ã‚’詳細ã«è¨å®šã—ã¾ã™ã€‚ + */ + "searchSettingCaption": string; + /** + * 並ã³é † + */ + "sortOrder": string; + /** + * 登録ãƒã‚° + */ + "registrationLogs": string; + /** + * 絵文å—更新・削除時ã®ãƒã‚°ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚更新・削除æ“作を行ã£ãŸã‚Šã€ãƒšãƒ¼ã‚¸ã‚’é·ç§»ãƒ»ãƒªãƒãƒ¼ãƒ‰ã™ã‚‹ã¨æ¶ˆãˆã¾ã™ã€‚ + */ + "registrationLogsCaption": string; + /** + * エラー + */ + "alertEmojisRegisterFailedTitle": string; + /** + * 絵文å—ã®æ›´æ–°ãƒ»å‰Šé™¤ã«å¤±æ•—ã—ã¾ã—ãŸã€‚詳細ã¯ç™»éŒ²ãƒã‚°ã‚’ã”確èªãã ã•ã„。 + */ + "alertEmojisRegisterFailedDescription": string; + }; + "_logs": { + /** + * æˆåŠŸãƒã‚°ã‚’表示 + */ + "showSuccessLogSwitch": string; + /** + * 失敗ãƒã‚°ã¯ã‚ã‚Šã¾ã›ã‚“。 + */ + "failureLogNothing": string; + /** + * ãƒã‚°ã¯ã‚ã‚Šã¾ã›ã‚“。 + */ + "logNothing": string; + }; + "_remote": { + /** + * é¸æŠžè¡Œã‚’インãƒãƒ¼ãƒˆ + */ + "importSelectionRows": string; + /** + * é¸æŠžç¯„囲ã®è¡Œã‚’インãƒãƒ¼ãƒˆ + */ + "importSelectionRangesRows": string; + /** + * ãƒã‚§ãƒƒã‚¯ã•ã‚ŒãŸçµµæ–‡å—をインãƒãƒ¼ãƒˆ + */ + "importEmojisButton": string; + /** + * 絵文å—ã®ã‚¤ãƒ³ãƒãƒ¼ãƒˆ + */ + "confirmImportEmojisTitle": string; + /** + * リモートã‹ã‚‰å—ä¿¡ã—ãŸ{count}個ã®çµµæ–‡å—ã®ã‚¤ãƒ³ãƒãƒ¼ãƒˆã‚’è¡Œã„ã¾ã™ã€‚絵文å—ã®ãƒ©ã‚¤ã‚»ãƒ³ã‚¹ã«å分ãªæ³¨æ„を払ã£ã¦ãã ã•ã„。実行ã—ã¾ã™ã‹ï¼Ÿ + */ + "confirmImportEmojisDescription": ParameterizedString<"count">; + }; + "_local": { + /** + * 登録済ã¿çµµæ–‡å—一覧 + */ + "tabTitleList": string; + /** + * 絵文å—ã®ç™»éŒ² + */ + "tabTitleRegister": string; + "_list": { + /** + * 登録ã•ã‚ŒãŸçµµæ–‡å—ã¯ã‚ã‚Šã¾ã›ã‚“。 + */ + "emojisNothing": string; + /** + * é¸æŠžè¡Œã‚’削除対象ã«ã™ã‚‹ + */ + "markAsDeleteTargetRows": string; + /** + * é¸æŠžç¯„囲ã®è¡Œã‚’削除対象ã«ã™ã‚‹ + */ + "markAsDeleteTargetRanges": string; + /** + * 変更ã•ã‚ŒãŸçµµæ–‡å—ã¯ã‚ã‚Šã¾ã›ã‚“。 + */ + "alertUpdateEmojisNothingDescription": string; + /** + * 削除対象ã®çµµæ–‡å—ã¯ã‚ã‚Šã¾ã›ã‚“。 + */ + "alertDeleteEmojisNothingDescription": string; + /** + * ç¢ºèª + */ + "confirmUpdateEmojisTitle": string; + /** + * {count}個ã®çµµæ–‡å—ã‚’æ›´æ–°ã—ã¾ã™ã€‚実行ã—ã¾ã™ã‹ï¼Ÿ + */ + "confirmUpdateEmojisDescription": ParameterizedString<"count">; + /** + * ç¢ºèª + */ + "confirmDeleteEmojisTitle": string; + /** + * ãƒã‚§ãƒƒã‚¯ãŒã¤ã‘られãŸ{count}個ã®çµµæ–‡å—を削除ã—ã¾ã™ã€‚実行ã—ã¾ã™ã‹ï¼Ÿ + */ + "confirmDeleteEmojisDescription": ParameterizedString<"count">; + /** + * 絵文å—ã«è¨å®šã•ã‚ŒãŸãƒãƒ¼ãƒ«ã§æ¤œç´¢ + */ + "dialogSelectRoleTitle": string; + }; + "_register": { + /** + * アップãƒãƒ¼ãƒ‰è¨å®š + */ + "uploadSettingTitle": string; + /** + * ã“ã®ç”»é¢ã§çµµæ–‡å—アップãƒãƒ¼ãƒ‰ã‚’è¡Œã†éš›ã®å‹•ä½œã‚’è¨å®šã§ãã¾ã™ã€‚ + */ + "uploadSettingDescription": string; + /** + * ディレクトリåã‚’"category"ã«å…¥åŠ›ã™ã‚‹ + */ + "directoryToCategoryLabel": string; + /** + * ディレクトリをドラッグ・ドãƒãƒƒãƒ—ã—ãŸæ™‚ã«ã€ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªåã‚’"category"ã«å…¥åŠ›ã—ã¾ã™ã€‚ + */ + "directoryToCategoryCaption": string; + /** + * ã„ãšã‚Œã‹ã®æ–¹æ³•ã§ç™»éŒ²ã™ã‚‹çµµæ–‡å—ã‚’é¸æŠžã—ã¦ãã ã•ã„。 + */ + "emojiInputAreaCaption": string; + /** + * ã“ã®æž ã«ç”»åƒãƒ•ã‚¡ã‚¤ãƒ«ã¾ãŸã¯ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªã‚’ドラッグ&ドãƒãƒƒãƒ— + */ + "emojiInputAreaList1": string; + /** + * ã“ã®ãƒªãƒ³ã‚¯ã‚’クリックã—ã¦PCã‹ã‚‰é¸æŠžã™ã‚‹ + */ + "emojiInputAreaList2": string; + /** + * ã“ã®ãƒªãƒ³ã‚¯ã‚’クリックã—ã¦ãƒ‰ãƒ©ã‚¤ãƒ–ã‹ã‚‰é¸æŠžã™ã‚‹ + */ + "emojiInputAreaList3": string; + /** + * ç¢ºèª + */ + "confirmRegisterEmojisTitle": string; + /** + * リストã«è¡¨ç¤ºã•ã‚Œã¦ã„る絵文å—ã‚’æ–°ãŸãªã‚«ã‚¹ã‚¿ãƒ 絵文å—ã¨ã—ã¦ç™»éŒ²ã—ã¾ã™ã€‚よã‚ã—ã„ã§ã™ã‹ï¼Ÿï¼ˆè² è·ã‚’é¿ã‘ã‚‹ãŸã‚ã€ä¸€åº¦ã®æ“作ã§ç™»éŒ²å¯èƒ½ãªçµµæ–‡å—ã¯{count}件ã¾ã§ã§ã™ï¼‰ + */ + "confirmRegisterEmojisDescription": ParameterizedString<"count">; + /** + * ç¢ºèª + */ + "confirmClearEmojisTitle": string; + /** + * ç·¨é›†å†…å®¹ã‚’ç ´æ£„ã—ã€ãƒªã‚¹ãƒˆã«è¡¨ç¤ºã•ã‚Œã¦ã„る絵文å—をクリアã—ã¾ã™ã€‚よã‚ã—ã„ã§ã™ã‹ï¼Ÿ + */ + "confirmClearEmojisDescription": string; + /** + * ç¢ºèª + */ + "confirmUploadEmojisTitle": string; + /** + * ドラッグ&ドãƒãƒƒãƒ—ã•ã‚ŒãŸ{count}個ã®ãƒ•ã‚¡ã‚¤ãƒ«ã‚’ドライブã«ã‚¢ãƒƒãƒ—ãƒãƒ¼ãƒ‰ã—ã¾ã™ã€‚実行ã—ã¾ã™ã‹ï¼Ÿ + */ + "confirmUploadEmojisDescription": ParameterizedString<"count">; + }; + }; + }; "_embedCodeGen": { /** * 埋ã‚è¾¼ã¿ã‚³ãƒ¼ãƒ‰ã‚’カスタマイズ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2a8fd94522..638f2a69c3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -5,6 +5,7 @@ introMisskey: "よã†ã“ãï¼Misskeyã¯ã€ã‚ªãƒ¼ãƒ—ンソースã®åˆ†æ•£åž‹ãƒž poweredByMisskeyDescription: "{name}ã¯ã€ã‚ªãƒ¼ãƒ—ンソースã®ãƒ—ラットフォーム<b>Misskey</b>ã®ã‚µãƒ¼ãƒãƒ¼ã®ã²ã¨ã¤ã§ã™ã€‚" monthAndDay: "{month}月 {day}æ—¥" search: "検索" +reset: "リセット" notifications: "通知" username: "ユーザーå" password: "パスワード" @@ -2808,6 +2809,69 @@ _contextMenu: appWithShift: "Shiftã‚ーã§ã‚¢ãƒ—リケーション" native: "ブラウザã®UI" +_gridComponent: + _error: + requiredValue: "ã“ã®å€¤ã¯å¿…é ˆé …ç›®ã§ã™" + columnTypeNotSupport: "æ£è¦è¡¨ç¾ã«ã‚ˆã‚‹ãƒãƒªãƒ‡ãƒ¼ã‚·ãƒ§ãƒ³ã¯type:textã®ã‚«ãƒ©ãƒ ã®ã¿ã‚µãƒãƒ¼ãƒˆã—ã¾ã™ã€‚" + patternNotMatch: "ã“ã®å€¤ã¯{pattern}ã®ãƒ‘ターンã«ä¸€è‡´ã—ã¾ã›ã‚“" + notUnique: "ã“ã®å€¤ã¯ä¸€æ„ã§ã‚ã‚‹å¿…è¦ãŒã‚ã‚Šã¾ã™" + +_roleSelectDialog: + notSelected: "é¸æŠžã•ã‚Œã¦ã„ã¾ã›ã‚“" + +_customEmojisManager: + _gridCommon: + copySelectionRows: "é¸æŠžè¡Œã‚’コピー" + copySelectionRanges: "é¸æŠžç¯„囲をコピー" + deleteSelectionRows: "é¸æŠžè¡Œã‚’削除" + deleteSelectionRanges: "é¸æŠžç¯„囲ã®è¡Œã‚’削除" + searchSettings: "検索è¨å®š" + searchSettingCaption: "検索æ¡ä»¶ã‚’詳細ã«è¨å®šã—ã¾ã™ã€‚" + sortOrder: "並ã³é †" + registrationLogs: "登録ãƒã‚°" + registrationLogsCaption: "絵文å—更新・削除時ã®ãƒã‚°ãŒè¡¨ç¤ºã•ã‚Œã¾ã™ã€‚更新・削除æ“作を行ã£ãŸã‚Šã€ãƒšãƒ¼ã‚¸ã‚’é·ç§»ãƒ»ãƒªãƒãƒ¼ãƒ‰ã™ã‚‹ã¨æ¶ˆãˆã¾ã™ã€‚" + alertEmojisRegisterFailedTitle: "エラー" + alertEmojisRegisterFailedDescription: "絵文å—ã®æ›´æ–°ãƒ»å‰Šé™¤ã«å¤±æ•—ã—ã¾ã—ãŸã€‚詳細ã¯ç™»éŒ²ãƒã‚°ã‚’ã”確èªãã ã•ã„。" + _logs: + showSuccessLogSwitch: "æˆåŠŸãƒã‚°ã‚’表示" + failureLogNothing: "失敗ãƒã‚°ã¯ã‚ã‚Šã¾ã›ã‚“。" + logNothing: "ãƒã‚°ã¯ã‚ã‚Šã¾ã›ã‚“。" + _remote: + importSelectionRows: "é¸æŠžè¡Œã‚’インãƒãƒ¼ãƒˆ" + importSelectionRangesRows: "é¸æŠžç¯„囲ã®è¡Œã‚’インãƒãƒ¼ãƒˆ" + importEmojisButton: "ãƒã‚§ãƒƒã‚¯ã•ã‚ŒãŸçµµæ–‡å—をインãƒãƒ¼ãƒˆ" + confirmImportEmojisTitle: "絵文å—ã®ã‚¤ãƒ³ãƒãƒ¼ãƒˆ" + confirmImportEmojisDescription: "リモートã‹ã‚‰å—ä¿¡ã—ãŸ{count}個ã®çµµæ–‡å—ã®ã‚¤ãƒ³ãƒãƒ¼ãƒˆã‚’è¡Œã„ã¾ã™ã€‚絵文å—ã®ãƒ©ã‚¤ã‚»ãƒ³ã‚¹ã«å分ãªæ³¨æ„を払ã£ã¦ãã ã•ã„。実行ã—ã¾ã™ã‹ï¼Ÿ" + _local: + tabTitleList: "登録済ã¿çµµæ–‡å—一覧" + tabTitleRegister: "絵文å—ã®ç™»éŒ²" + _list: + emojisNothing: "登録ã•ã‚ŒãŸçµµæ–‡å—ã¯ã‚ã‚Šã¾ã›ã‚“。" + markAsDeleteTargetRows: "é¸æŠžè¡Œã‚’削除対象ã«ã™ã‚‹" + markAsDeleteTargetRanges: "é¸æŠžç¯„囲ã®è¡Œã‚’削除対象ã«ã™ã‚‹" + alertUpdateEmojisNothingDescription: "変更ã•ã‚ŒãŸçµµæ–‡å—ã¯ã‚ã‚Šã¾ã›ã‚“。" + alertDeleteEmojisNothingDescription: "削除対象ã®çµµæ–‡å—ã¯ã‚ã‚Šã¾ã›ã‚“。" + confirmUpdateEmojisTitle: "確èª" + confirmUpdateEmojisDescription: "{count}個ã®çµµæ–‡å—ã‚’æ›´æ–°ã—ã¾ã™ã€‚実行ã—ã¾ã™ã‹ï¼Ÿ" + confirmDeleteEmojisTitle: "確èª" + confirmDeleteEmojisDescription: "ãƒã‚§ãƒƒã‚¯ãŒã¤ã‘られãŸ{count}個ã®çµµæ–‡å—を削除ã—ã¾ã™ã€‚実行ã—ã¾ã™ã‹ï¼Ÿ" + dialogSelectRoleTitle: "絵文å—ã«è¨å®šã•ã‚ŒãŸãƒãƒ¼ãƒ«ã§æ¤œç´¢" + _register: + uploadSettingTitle: "アップãƒãƒ¼ãƒ‰è¨å®š" + uploadSettingDescription: "ã“ã®ç”»é¢ã§çµµæ–‡å—アップãƒãƒ¼ãƒ‰ã‚’è¡Œã†éš›ã®å‹•ä½œã‚’è¨å®šã§ãã¾ã™ã€‚" + directoryToCategoryLabel: "ディレクトリåã‚’\"category\"ã«å…¥åŠ›ã™ã‚‹" + directoryToCategoryCaption: "ディレクトリをドラッグ・ドãƒãƒƒãƒ—ã—ãŸæ™‚ã«ã€ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªåã‚’\"category\"ã«å…¥åŠ›ã—ã¾ã™ã€‚" + emojiInputAreaCaption: "ã„ãšã‚Œã‹ã®æ–¹æ³•ã§ç™»éŒ²ã™ã‚‹çµµæ–‡å—ã‚’é¸æŠžã—ã¦ãã ã•ã„。" + emojiInputAreaList1: "ã“ã®æž ã«ç”»åƒãƒ•ã‚¡ã‚¤ãƒ«ã¾ãŸã¯ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªã‚’ドラッグ&ドãƒãƒƒãƒ—" + emojiInputAreaList2: "ã“ã®ãƒªãƒ³ã‚¯ã‚’クリックã—ã¦PCã‹ã‚‰é¸æŠžã™ã‚‹" + emojiInputAreaList3: "ã“ã®ãƒªãƒ³ã‚¯ã‚’クリックã—ã¦ãƒ‰ãƒ©ã‚¤ãƒ–ã‹ã‚‰é¸æŠžã™ã‚‹" + confirmRegisterEmojisTitle: "確èª" + confirmRegisterEmojisDescription: "リストã«è¡¨ç¤ºã•ã‚Œã¦ã„る絵文å—ã‚’æ–°ãŸãªã‚«ã‚¹ã‚¿ãƒ 絵文å—ã¨ã—ã¦ç™»éŒ²ã—ã¾ã™ã€‚よã‚ã—ã„ã§ã™ã‹ï¼Ÿï¼ˆè² è·ã‚’é¿ã‘ã‚‹ãŸã‚ã€ä¸€åº¦ã®æ“作ã§ç™»éŒ²å¯èƒ½ãªçµµæ–‡å—ã¯{count}件ã¾ã§ã§ã™ï¼‰" + confirmClearEmojisTitle: "確èª" + confirmClearEmojisDescription: "ç·¨é›†å†…å®¹ã‚’ç ´æ£„ã—ã€ãƒªã‚¹ãƒˆã«è¡¨ç¤ºã•ã‚Œã¦ã„る絵文å—をクリアã—ã¾ã™ã€‚よã‚ã—ã„ã§ã™ã‹ï¼Ÿ" + confirmUploadEmojisTitle: "確èª" + confirmUploadEmojisDescription: "ドラッグ&ドãƒãƒƒãƒ—ã•ã‚ŒãŸ{count}個ã®ãƒ•ã‚¡ã‚¤ãƒ«ã‚’ドライブã«ã‚¢ãƒƒãƒ—ãƒãƒ¼ãƒ‰ã—ã¾ã™ã€‚実行ã—ã¾ã™ã‹ï¼Ÿ" + _embedCodeGen: title: "埋ã‚è¾¼ã¿ã‚³ãƒ¼ãƒ‰ã‚’カスタマイズ" header: "ヘッダーを表示" diff --git a/packages/backend/migration/1709126576000-optimize-emoji-index.js b/packages/backend/migration/1709126576000-optimize-emoji-index.js new file mode 100644 index 0000000000..e4184895d0 --- /dev/null +++ b/packages/backend/migration/1709126576000-optimize-emoji-index.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class OptimizeEmojiIndex1709126576000 { + name = 'OptimizeEmojiIndex1709126576000' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_EMOJI_ROLE_IDS" ON "emoji" using gin ("roleIdsThatCanBeUsedThisEmojiAsReaction")`) + await queryRunner.query(`CREATE INDEX "IDX_EMOJI_CATEGORY" ON "emoji" ("category")`) + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_EMOJI_CATEGORY"`) + await queryRunner.query(`DROP INDEX "IDX_EMOJI_ROLE_IDS"`) + } +} diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index e3a61861f4..1ca0397206 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -26,6 +26,18 @@ export const DB_MAX_NOTE_TEXT_LENGTH = 8192; export const DB_MAX_IMAGE_COMMENT_LENGTH = 512; //#endregion +export const FILE_TYPE_IMAGE = [ + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/apng', + 'image/bmp', + 'image/tiff', + 'image/x-icon', +]; + // ブラウザã§ç›´æŽ¥è¡¨ç¤ºã™ã‚‹ã“ã¨ã‚’許å¯ã™ã‚‹ãƒ•ã‚¡ã‚¤ãƒ«ã®ç¨®é¡žã®ãƒªã‚¹ãƒˆ // ã“ã“ã«å«ã¾ã‚Œãªã„ã‚‚ã®ã¯ application/octet-stream ã¨ã—ã¦ãƒ¬ã‚¹ãƒãƒ³ã‚¹ã•ã‚Œã‚‹ // SVGã¯XSSを生むã®ã§è¨±å¯ã—ãªã„ diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 4566113449..da71a5de6f 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -4,24 +4,59 @@ */ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In, IsNull } from 'typeorm'; import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; +import { In, IsNull } from 'typeorm'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiEmoji } from '@/models/Emoji.js'; -import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { query } from '@/misc/prelude/url.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js'; +import type { MiEmoji } from '@/models/Emoji.js'; import type { Serialized } from '@/types.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; +export const fetchEmojisHostTypes = [ + 'local', + 'remote', + 'all', +] as const; +export type FetchEmojisHostTypes = typeof fetchEmojisHostTypes[number]; +export const fetchEmojisSortKeys = [ + '+id', + '-id', + '+updatedAt', + '-updatedAt', + '+name', + '-name', + '+host', + '-host', + '+uri', + '-uri', + '+publicUrl', + '-publicUrl', + '+type', + '-type', + '+aliases', + '-aliases', + '+category', + '-category', + '+license', + '-license', + '+isSensitive', + '-isSensitive', + '+localOnly', + '-localOnly', + '+roleIdsThatCanBeUsedThisEmojiAsReaction', + '-roleIdsThatCanBeUsedThisEmojiAsReaction', +] as const; +export type FetchEmojisSortKeys = typeof fetchEmojisSortKeys[number]; + @Injectable() export class CustomEmojiService implements OnApplicationShutdown { private emojisCache: MemoryKVCache<MiEmoji | null>; @@ -30,10 +65,8 @@ export class CustomEmojiService implements OnApplicationShutdown { constructor( @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, - private utilityService: UtilityService, private idService: IdService, private emojiEntityService: EmojiEntityService, @@ -58,7 +91,9 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public async add(data: { - driveFile: MiDriveFile; + originalUrl: string; + publicUrl: string; + fileType: string; name: string; category: string | null; aliases: string[]; @@ -75,9 +110,9 @@ export class CustomEmojiService implements OnApplicationShutdown { category: data.category, host: data.host, aliases: data.aliases, - originalUrl: data.driveFile.url, - publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, - type: data.driveFile.webpublicType ?? data.driveFile.type, + originalUrl: data.originalUrl, + publicUrl: data.publicUrl, + type: data.fileType, license: data.license, isSensitive: data.isSensitive, localOnly: data.localOnly, @@ -105,8 +140,10 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public async update(data: ( { id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], } - ) & { - driveFile?: MiDriveFile; + ) & { + originalUrl?: string; + publicUrl?: string; + fileType?: string; category?: string | null; aliases?: string[]; license?: string | null; @@ -139,9 +176,9 @@ export class CustomEmojiService implements OnApplicationShutdown { license: data.license, isSensitive: data.isSensitive, localOnly: data.localOnly, - originalUrl: data.driveFile != null ? data.driveFile.url : undefined, - publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined, - type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined, + originalUrl: data.originalUrl, + publicUrl: data.publicUrl, + type: data.fileType, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined, }); @@ -308,7 +345,7 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { - // クエリã«ä½¿ã†ãƒ›ã‚¹ãƒˆ + // クエリã«ä½¿ã†ãƒ›ã‚¹ãƒˆ let host = src === '.' ? null // .ã¯ãƒãƒ¼ã‚«ãƒ«ãƒ›ã‚¹ãƒˆ (ã“ã“ãŒãƒžãƒƒãƒã™ã‚‹ã®ã¯ãƒªã‚¢ã‚¯ã‚·ãƒ§ãƒ³ã®ã¿) : src === undefined ? noteUserHost // ノートãªã©ã§ãƒ›ã‚¹ãƒˆçœç•¥è¡¨è¨˜ã®å ´åˆã¯ãƒãƒ¼ã‚«ãƒ«ãƒ›ã‚¹ãƒˆ (ã“ã“ãŒãƒªã‚¢ã‚¯ã‚·ãƒ§ãƒ³ã«ãƒžãƒƒãƒã™ã‚‹ã“ã¨ã¯ãªã„) : this.utilityService.isSelfHost(src) ? null // 自ホスト指定 @@ -414,6 +451,151 @@ export class CustomEmojiService implements OnApplicationShutdown { return this.emojisRepository.findOneBy({ name, host: IsNull() }); } + @bindThis + public async fetchEmojis( + params?: { + query?: { + updatedAtFrom?: string; + updatedAtTo?: string; + name?: string; + host?: string; + uri?: string; + publicUrl?: string; + type?: string; + aliases?: string; + category?: string; + license?: string; + isSensitive?: boolean; + localOnly?: boolean; + hostType?: FetchEmojisHostTypes; + roleIds?: string[]; + }, + sinceId?: string; + untilId?: string; + }, + opts?: { + limit?: number; + page?: number; + sortKeys?: FetchEmojisSortKeys[] + }, + ) { + function multipleWordsToQuery(words: string) { + return words.split(/\s/).filter(x => x.length > 0).map(x => `%${sqlLikeEscape(x)}%`); + } + + const builder = this.emojisRepository.createQueryBuilder('emoji'); + if (params?.query) { + const q = params.query; + if (q.updatedAtFrom) { + // noIndexScan + builder.andWhere('CAST(emoji.updatedAt AS DATE) >= :updateAtFrom', { updateAtFrom: q.updatedAtFrom }); + } + if (q.updatedAtTo) { + // noIndexScan + builder.andWhere('CAST(emoji.updatedAt AS DATE) <= :updateAtTo', { updateAtTo: q.updatedAtTo }); + } + if (q.name) { + builder.andWhere('emoji.name ~~ ANY(ARRAY[:...name])', { name: multipleWordsToQuery(q.name) }); + } + + switch (true) { + case q.hostType === 'local': { + builder.andWhere('emoji.host IS NULL'); + break; + } + case q.hostType === 'remote': { + if (q.host) { + // noIndexScan + builder.andWhere('emoji.host ~~ ANY(ARRAY[:...host])', { host: multipleWordsToQuery(q.host) }); + } else { + builder.andWhere('emoji.host IS NOT NULL'); + } + break; + } + } + + if (q.uri) { + // noIndexScan + builder.andWhere('emoji.uri ~~ ANY(ARRAY[:...uri])', { uri: multipleWordsToQuery(q.uri) }); + } + if (q.publicUrl) { + // noIndexScan + builder.andWhere('emoji.publicUrl ~~ ANY(ARRAY[:...publicUrl])', { publicUrl: multipleWordsToQuery(q.publicUrl) }); + } + if (q.type) { + // noIndexScan + builder.andWhere('emoji.type ~~ ANY(ARRAY[:...type])', { type: multipleWordsToQuery(q.type) }); + } + if (q.aliases) { + // noIndexScan + const subQueryBuilder = builder.subQuery() + .select('COUNT(0)', 'count') + .from( + sq2 => sq2 + .select('unnest(subEmoji.aliases)', 'alias') + .addSelect('subEmoji.id', 'id') + .from('emoji', 'subEmoji'), + 'aliasTable', + ) + .where('"emoji"."id" = "aliasTable"."id"') + .andWhere('"aliasTable"."alias" ~~ ANY(ARRAY[:...aliases])', { aliases: multipleWordsToQuery(q.aliases) }); + + builder.andWhere(`(${subQueryBuilder.getQuery()}) > 0`); + } + if (q.category) { + builder.andWhere('emoji.category ~~ ANY(ARRAY[:...category])', { category: multipleWordsToQuery(q.category) }); + } + if (q.license) { + // noIndexScan + builder.andWhere('emoji.license ~~ ANY(ARRAY[:...license])', { license: multipleWordsToQuery(q.license) }); + } + if (q.isSensitive != null) { + // noIndexScan + builder.andWhere('emoji.isSensitive = :isSensitive', { isSensitive: q.isSensitive }); + } + if (q.localOnly != null) { + // noIndexScan + builder.andWhere('emoji.localOnly = :localOnly', { localOnly: q.localOnly }); + } + if (q.roleIds && q.roleIds.length > 0) { + builder.andWhere('emoji.roleIdsThatCanBeUsedThisEmojiAsReaction && ARRAY[:...roleIds]::VARCHAR[]', { roleIds: q.roleIds }); + } + } + + if (params?.sinceId) { + builder.andWhere('emoji.id > :sinceId', { sinceId: params.sinceId }); + } + if (params?.untilId) { + builder.andWhere('emoji.id < :untilId', { untilId: params.untilId }); + } + + if (opts?.sortKeys && opts.sortKeys.length > 0) { + for (const sortKey of opts.sortKeys) { + const direction = sortKey.startsWith('-') ? 'DESC' : 'ASC'; + const key = sortKey.replace(/^[+-]/, ''); + builder.addOrderBy(`emoji.${key}`, direction); + } + } else { + builder.addOrderBy('emoji.id', 'DESC'); + } + + const limit = opts?.limit ?? 10; + if (opts?.page) { + builder.skip((opts.page - 1) * limit); + } + + builder.take(limit); + + const [emojis, count] = await builder.getManyAndCount(); + + return { + emojis, + count: (count > limit ? emojis.length : count), + allCount: count, + allPages: Math.ceil(count / limit), + }; + } + @bindThis public dispose(): void { this.emojisCache.dispose(); diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 841bd731c0..490d3f2511 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -4,10 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository } from '@/models/_.js'; +import type { EmojisRepository, MiRole, RolesRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; import type { MiEmoji } from '@/models/Emoji.js'; import { bindThis } from '@/decorators.js'; @@ -16,6 +16,8 @@ export class EmojiEntityService { constructor( @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, ) { } @@ -68,8 +70,90 @@ export class EmojiEntityService { @bindThis public packDetailedMany( emojis: any[], - ) { + ): Promise<Packed<'EmojiDetailed'>[]> { return Promise.all(emojis.map(x => this.packDetailed(x))); } + + @bindThis + public async packDetailedAdmin( + src: MiEmoji['id'] | MiEmoji, + hint?: { + roles?: Map<MiRole['id'], MiRole> + }, + ): Promise<Packed<'EmojiDetailedAdmin'>> { + const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); + + const roles = Array.of<MiRole>(); + if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0) { + if (hint?.roles) { + const hintRoles = hint.roles; + roles.push( + ...emoji.roleIdsThatCanBeUsedThisEmojiAsReaction + .filter(x => hintRoles.has(x)) + .map(x => hintRoles.get(x)!), + ); + } else { + roles.push( + ...await this.rolesRepository.findBy({ id: In(emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) }), + ); + } + + roles.sort((a, b) => { + if (a.displayOrder !== b.displayOrder) { + return b.displayOrder - a.displayOrder; + } + + return a.id.localeCompare(b.id); + }); + } + + return { + id: emoji.id, + updatedAt: emoji.updatedAt?.toISOString() ?? null, + name: emoji.name, + host: emoji.host, + uri: emoji.uri, + type: emoji.type, + aliases: emoji.aliases, + category: emoji.category, + publicUrl: emoji.publicUrl, + originalUrl: emoji.originalUrl, + license: emoji.license, + localOnly: emoji.localOnly, + isSensitive: emoji.isSensitive, + roleIdsThatCanBeUsedThisEmojiAsReaction: roles.map(it => ({ id: it.id, name: it.name })), + }; + } + + @bindThis + public async packDetailedAdminMany( + emojis: MiEmoji['id'][] | MiEmoji[], + hint?: { + roles?: Map<MiRole['id'], MiRole> + }, + ): Promise<Packed<'EmojiDetailedAdmin'>[]> { + // IDã®ã¿ã®è¦ç´ をピックアップã—ã€DBã‹ã‚‰ãƒ¬ã‚³ãƒ¼ãƒ‰ã‚’å–り出ã—ã¦ä»–ã®å€¤ã‚’補完ã™ã‚‹ + const emojiEntities = emojis.filter(x => typeof x === 'object') as MiEmoji[]; + const emojiIdOnlyList = emojis.filter(x => typeof x === 'string') as string[]; + if (emojiIdOnlyList.length > 0) { + emojiEntities.push(...await this.emojisRepository.findBy({ id: In(emojiIdOnlyList) })); + } + + // 特定ãƒãƒ¼ãƒ«å°‚用ã®çµµæ–‡å—ã§ã‚ã‚‹å ´åˆã€ãã®ãƒãƒ¼ãƒ«æƒ…å ±ã‚’ã‚らã‹ã˜ã‚ã¾ã¨ã‚ã¦å–å¾—ã—ã¦ãŠã(packå´ã§éƒ½åº¦å–得も出æ¥ã‚‹ãŒè² è·ãŒé«˜ã„ã®ã§ï¼‰ + let hintRoles: Map<MiRole['id'], MiRole>; + if (hint?.roles) { + hintRoles = hint.roles; + } else { + const roles = Array.of<MiRole>(); + const roleIds = [...new Set(emojiEntities.flatMap(x => x.roleIdsThatCanBeUsedThisEmojiAsReaction))]; + if (roleIds.length > 0) { + roles.push(...await this.rolesRepository.findBy({ id: In(roleIds) })); + } + + hintRoles = new Map(roles.map(x => [x.id, x])); + } + + return Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles }))); + } } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 040e36228c..f612591eda 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -33,7 +33,11 @@ import { packedClipSchema } from '@/models/json-schema/clip.js'; import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { + packedEmojiDetailedAdminSchema, + packedEmojiDetailedSchema, + packedEmojiSimpleSchema, +} from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; @@ -95,6 +99,7 @@ export const refs = { GalleryPost: packedGalleryPostSchema, EmojiSimple: packedEmojiSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, + EmojiDetailedAdmin: packedEmojiDetailedAdminSchema, Flash: packedFlashSchema, Signin: packedSigninSchema, RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index 62686ad5ae..3cd263fa37 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -104,3 +104,86 @@ export const packedEmojiDetailedSchema = { }, }, } as const; + +export const packedEmojiDetailedAdminSchema = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: true, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + description: 'The local host is represented with `null`.', + }, + publicUrl: { + type: 'string', + optional: false, nullable: false, + }, + originalUrl: { + type: 'string', + optional: false, nullable: false, + }, + uri: { + type: 'string', + optional: false, nullable: true, + }, + type: { + type: 'string', + optional: false, nullable: true, + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + license: { + type: 'string', + optional: false, nullable: true, + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 9e1b8fee70..725e1c8ba2 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -87,6 +87,7 @@ export class ImportCustomEmojisProcessorService { await this.emojisRepository.delete({ name: emojiInfo.name, }); + try { const driveFile = await this.driveService.addFile({ user: null, @@ -95,11 +96,13 @@ export class ImportCustomEmojisProcessorService { force: true, }); await this.customEmojiService.add({ + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: emojiInfo.name, category: emojiInfo.category, host: null, aliases: emojiInfo.aliases, - driveFile, license: emojiInfo.license, isSensitive: emojiInfo.isSensitive, localOnly: emojiInfo.localOnly, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index c2462d8b3d..87c9841fd0 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -50,6 +50,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___v2_admin_emoji_list from './endpoints/v2/admin/emoji/list.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; @@ -440,6 +441,7 @@ const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-ali const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; +const $admin_emoji_v2_list: Provider = { provide: 'ep:v2/admin/emoji/list', useClass: ep___v2_admin_emoji_list.default }; const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default }; @@ -834,6 +836,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, $admin_emoji_update, + $admin_emoji_v2_list, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, @@ -1222,6 +1225,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_emoji_setCategoryBulk, $admin_emoji_setLicenseBulk, $admin_emoji_update, + $admin_emoji_v2_list, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, $admin_federation_removeAllFollowing, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 86728ef381..4d0c45cc91 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -55,6 +55,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; +import * as ep___v2_admin_emoji_list from './endpoints/v2/admin/emoji/list.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; @@ -444,6 +445,7 @@ const eps = [ ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], ['admin/emoji/update', ep___admin_emoji_update], + ['v2/admin/emoji/list', ep___v2_admin_emoji_list], ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], ['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 796f273330..53256565f6 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -9,6 +9,7 @@ import type { DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { FILE_TYPE_IMAGE } from '@/const.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -24,6 +25,11 @@ export const meta = { code: 'NO_SUCH_FILE', id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', }, + unsupportedFileType: { + message: 'Unsupported file type.', + code: 'UNSUPPORTED_FILE_TYPE', + id: 'f7599d96-8750-af68-1633-9575d625c1a7', + }, duplicateName: { message: 'Duplicate name.', code: 'DUPLICATE_NAME', @@ -47,15 +53,21 @@ export const paramDef = { nullable: true, description: 'Use `null` to reset the category.', }, - aliases: { type: 'array', items: { - type: 'string', - } }, + aliases: { + type: 'array', + items: { + type: 'string', + }, + }, license: { type: 'string', nullable: true }, isSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { - type: 'string', - } }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + items: { + type: 'string', + }, + }, }, required: ['name', 'fileId'], } as const; @@ -67,9 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private customEmojiService: CustomEmojiService, - private emojiEntityService: EmojiEntityService, ) { super(meta, paramDef, async (ps, me) => { @@ -77,9 +87,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); if (isDuplicate) throw new ApiError(meta.errors.duplicateName); + if (!FILE_TYPE_IMAGE.includes(driveFile.type)) throw new ApiError(meta.errors.unsupportedFileType); const emoji = await this.customEmojiService.add({ - driveFile, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: ps.name, category: ps.category ?? null, aliases: ps.aliases ?? [], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index 975f892df9..87b58ff6f6 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -86,7 +86,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (isDuplicate) throw new ApiError(meta.errors.duplicateName); const addedEmoji = await this.customEmojiService.add({ - driveFile, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: emoji.name, category: emoji.category, aliases: emoji.aliases, diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 212cba5c5d..e3aaa051c1 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -79,13 +79,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } // JSON schemeã®anyOfã®åž‹å¤‰æ›ãŒã†ã¾ãã„ã£ã¦ã„ãªã„らã—ã„ - const required = { id: ps.id, name: ps.name } as + const required = { id: ps.id, name: ps.name } as | { id: MiEmoji['id']; name?: string } | { id?: MiEmoji['id']; name: string }; const error = await this.customEmojiService.update({ ...required, - driveFile, + originalUrl: driveFile != null ? driveFile.url : undefined, + publicUrl: driveFile != null ? (driveFile.webpublicUrl ?? driveFile.url) : undefined, + fileType: driveFile != null ? (driveFile.webpublicType ?? driveFile.type) : undefined, category: ps.category, aliases: ps.aliases, license: ps.license, diff --git a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts new file mode 100644 index 0000000000..9426318e34 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { CustomEmojiService, fetchEmojisHostTypes, fetchEmojisSortKeys } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', + kind: 'read:admin:emoji', + + res: { + type: 'object', + properties: { + emojis: { + type: 'array', + items: { + type: 'object', + ref: 'EmojiDetailedAdmin', + }, + }, + count: { type: 'integer' }, + allCount: { type: 'integer' }, + allPages: { type: 'integer' }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { + type: 'object', + nullable: true, + properties: { + updatedAtFrom: { type: 'string' }, + updatedAtTo: { type: 'string' }, + name: { type: 'string' }, + host: { type: 'string' }, + uri: { type: 'string' }, + publicUrl: { type: 'string' }, + originalUrl: { type: 'string' }, + type: { type: 'string' }, + aliases: { type: 'string' }, + category: { type: 'string' }, + license: { type: 'string' }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + hostType: { + type: 'string', + enum: fetchEmojisHostTypes, + default: 'all', + }, + roleIds: { + type: 'array', + items: { type: 'string', format: 'misskey:id' }, + }, + }, + }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + page: { type: 'integer' }, + sortKeys: { + type: 'array', + default: ['-id'], + items: { + type: 'string', + enum: fetchEmojisSortKeys, + }, + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private customEmojiService: CustomEmojiService, + private emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const q = ps.query; + const result = await this.customEmojiService.fetchEmojis( + { + query: { + updatedAtFrom: q?.updatedAtFrom, + updatedAtTo: q?.updatedAtTo, + name: q?.name, + host: q?.host, + uri: q?.uri, + publicUrl: q?.publicUrl, + type: q?.type, + aliases: q?.aliases, + category: q?.category, + license: q?.license, + isSensitive: q?.isSensitive, + localOnly: q?.localOnly, + hostType: q?.hostType, + roleIds: q?.roleIds, + }, + sinceId: ps.sinceId, + untilId: ps.untilId, + }, + { + limit: ps.limit, + page: ps.page, + sortKeys: ps.sortKeys, + }, + ); + + return { + emojis: await this.emojiEntityService.packDetailedAdminMany(result.emojis), + count: result.count, + allCount: result.allCount, + allPages: result.allPages, + }; + }); + } +} diff --git a/packages/backend/test/unit/CustomEmojiService.ts b/packages/backend/test/unit/CustomEmojiService.ts new file mode 100644 index 0000000000..10b687c6a0 --- /dev/null +++ b/packages/backend/test/unit/CustomEmojiService.ts @@ -0,0 +1,817 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeAll, describe, test } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { EmojisRepository } from '@/models/_.js'; +import { MiEmoji } from '@/models/Emoji.js'; + +describe('CustomEmojiService', () => { + let app: TestingModule; + let service: CustomEmojiService; + + let emojisRepository: EmojisRepository; + let idService: IdService; + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CustomEmojiService, + UtilityService, + IdService, + EmojiEntityService, + ModerationLogService, + GlobalEventService, + ], + }) + .compile(); + app.enableShutdownHooks(); + + service = app.get<CustomEmojiService>(CustomEmojiService); + emojisRepository = app.get<EmojisRepository>(DI.emojisRepository); + idService = app.get<IdService>(IdService); + }); + + describe('fetchEmojis', () => { + async function insert(data: Partial<MiEmoji>[]) { + for (const d of data) { + const id = idService.gen(); + await emojisRepository.insert({ + id: id, + updatedAt: new Date(), + ...d, + }); + } + } + + function call(params: Parameters<CustomEmojiService['fetchEmojis']>['0']) { + return service.fetchEmojis( + params, + { + // テストå‘ã‘ã« + sortKeys: ['+id'], + }, + ); + } + + function defaultData(suffix: string, override?: Partial<MiEmoji>): Partial<MiEmoji> { + return { + name: `emoji${suffix}`, + host: null, + category: 'default', + originalUrl: `https://example.com/emoji${suffix}.png`, + publicUrl: `https://example.com/emoji${suffix}.png`, + type: 'image/png', + aliases: [`emoji${suffix}`], + license: 'CC0', + isSensitive: false, + localOnly: false, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + ...override, + }; + } + + afterEach(async () => { + await emojisRepository.delete({}); + }); + + describe('å˜ç‹¬', () => { + test('updatedAtFrom', async () => { + await insert([ + defaultData('001', { updatedAt: new Date('2021-01-01T00:00:00.000Z') }), + defaultData('002', { updatedAt: new Date('2021-01-02T00:00:00.000Z') }), + defaultData('003', { updatedAt: new Date('2021-01-03T00:00:00.000Z') }), + ]); + + const actual = await call({ + query: { + updatedAtFrom: '2021-01-02T00:00:00.000Z', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji002'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('updatedAtTo', async () => { + await insert([ + defaultData('001', { updatedAt: new Date('2021-01-01T00:00:00.000Z') }), + defaultData('002', { updatedAt: new Date('2021-01-02T00:00:00.000Z') }), + defaultData('003', { updatedAt: new Date('2021-01-03T00:00:00.000Z') }), + ]); + + const actual = await call({ + query: { + updatedAtTo: '2021-01-02T00:00:00.000Z', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + describe('name', () => { + test('single', async () => { + await insert([ + defaultData('001'), + defaultData('002'), + ]); + + const actual = await call({ + query: { + name: 'emoji001', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji001'); + }); + + test('multi', async () => { + await insert([ + defaultData('001'), + defaultData('002'), + ]); + + const actual = await call({ + query: { + name: 'emoji001 emoji002', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001'), + defaultData('002'), + defaultData('003', { name: 'em003' }), + ]); + + const actual = await call({ + query: { + name: 'oji', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + test('escape', async () => { + await insert([ + defaultData('001'), + ]); + + const actual = await call({ + query: { + name: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('host', () => { + test('single', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + defaultData('002', { host: 'example.com' }), + defaultData('003', { host: '1.example.com' }), + defaultData('004', { host: '2.example.com' }), + ]); + + const actual = await call({ + query: { + host: 'example.com', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(4); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + defaultData('002', { host: 'example.com' }), + defaultData('003', { host: '1.example.com' }), + defaultData('004', { host: '2.example.com' }), + ]); + + const actual = await call({ + query: { + host: '1.example.com 2.example.com', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji003'); + expect(actual.emojis[1].name).toBe('emoji004'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + defaultData('002', { host: 'example.com' }), + defaultData('003', { host: '1.example.com' }), + defaultData('004', { host: '2.example.com' }), + ]); + + const actual = await call({ + query: { + host: 'example', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(4); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + ]); + + const actual = await call({ + query: { + host: '%', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('uri', () => { + test('single', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + defaultData('002', { uri: 'uri002' }), + defaultData('003', { uri: 'uri003' }), + ]); + + const actual = await call({ + query: { + uri: 'uri002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + defaultData('002', { uri: 'uri002' }), + defaultData('003', { uri: 'uri003' }), + ]); + + const actual = await call({ + query: { + uri: 'uri001 uri003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + defaultData('002', { uri: 'uri002' }), + defaultData('003', { uri: 'uri003' }), + ]); + + const actual = await call({ + query: { + uri: 'ri', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + ]); + + const actual = await call({ + query: { + uri: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('publicUrl', () => { + test('single', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + defaultData('002', { publicUrl: 'publicUrl002' }), + defaultData('003', { publicUrl: 'publicUrl003' }), + ]); + + const actual = await call({ + query: { + publicUrl: 'publicUrl002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + defaultData('002', { publicUrl: 'publicUrl002' }), + defaultData('003', { publicUrl: 'publicUrl003' }), + ]); + + const actual = await call({ + query: { + publicUrl: 'publicUrl001 publicUrl003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + defaultData('002', { publicUrl: 'publicUrl002' }), + defaultData('003', { publicUrl: 'publicUrl003' }), + ]); + + const actual = await call({ + query: { + publicUrl: 'Url', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + ]); + + const actual = await call({ + query: { + publicUrl: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('type', () => { + test('single', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + defaultData('002', { type: 'type002' }), + defaultData('003', { type: 'type003' }), + ]); + + const actual = await call({ + query: { + type: 'type002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + defaultData('002', { type: 'type002' }), + defaultData('003', { type: 'type003' }), + ]); + + const actual = await call({ + query: { + type: 'type001 type003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + defaultData('002', { type: 'type002' }), + defaultData('003', { type: 'type003' }), + ]); + + const actual = await call({ + query: { + type: 'pe', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + ]); + + const actual = await call({ + query: { + type: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('aliases', () => { + test('single', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + defaultData('002', { aliases: ['alias002'] }), + defaultData('003', { aliases: ['alias003'] }), + ]); + + const actual = await call({ + query: { + aliases: 'alias002', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + defaultData('002', { aliases: ['alias002', 'alias004'] }), + defaultData('003', { aliases: ['alias003'] }), + defaultData('004', { aliases: ['alias004'] }), + ]); + + const actual = await call({ + query: { + aliases: 'alias001 alias004', + }, + }); + + expect(actual.allCount).toBe(3); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + expect(actual.emojis[2].name).toBe('emoji004'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + defaultData('002', { aliases: ['alias002', 'alias004'] }), + defaultData('003', { aliases: ['alias003'] }), + defaultData('004', { aliases: ['alias004'] }), + ]); + + const actual = await call({ + query: { + aliases: 'ias', + }, + }); + + expect(actual.allCount).toBe(4); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + ]); + + const actual = await call({ + query: { + aliases: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('category', () => { + test('single', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + defaultData('002', { category: 'category002' }), + defaultData('003', { category: 'category003' }), + ]); + + const actual = await call({ + query: { + category: 'category002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + defaultData('002', { category: 'category002' }), + defaultData('003', { category: 'category003' }), + ]); + + const actual = await call({ + query: { + category: 'category001 category003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + defaultData('002', { category: 'category002' }), + defaultData('003', { category: 'category003' }), + ]); + + const actual = await call({ + query: { + category: 'egory', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + ]); + + const actual = await call({ + query: { + category: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('license', () => { + test('single', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + defaultData('002', { license: 'license002' }), + defaultData('003', { license: 'license003' }), + ]); + + const actual = await call({ + query: { + license: 'license002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + defaultData('002', { license: 'license002' }), + defaultData('003', { license: 'license003' }), + ]); + + const actual = await call({ + query: { + license: 'license001 license003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + defaultData('002', { license: 'license002' }), + defaultData('003', { license: 'license003' }), + ]); + + const actual = await call({ + query: { + license: 'cense', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + ]); + + const actual = await call({ + query: { + license: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('isSensitive', () => { + test('true', async () => { + await insert([ + defaultData('001', { isSensitive: true }), + defaultData('002', { isSensitive: false }), + defaultData('003', { isSensitive: true }), + ]); + + const actual = await call({ + query: { + isSensitive: true, + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('false', async () => { + await insert([ + defaultData('001', { isSensitive: true }), + defaultData('002', { isSensitive: false }), + defaultData('003', { isSensitive: true }), + ]); + + const actual = await call({ + query: { + isSensitive: false, + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('null', async () => { + await insert([ + defaultData('001', { isSensitive: true }), + defaultData('002', { isSensitive: false }), + defaultData('003', { isSensitive: true }), + ]); + + const actual = await call({ + query: {}, + }); + + expect(actual.allCount).toBe(3); + }); + }); + + describe('localOnly', () => { + test('true', async () => { + await insert([ + defaultData('001', { localOnly: true }), + defaultData('002', { localOnly: false }), + defaultData('003', { localOnly: true }), + ]); + + const actual = await call({ + query: { + localOnly: true, + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('false', async () => { + await insert([ + defaultData('001', { localOnly: true }), + defaultData('002', { localOnly: false }), + defaultData('003', { localOnly: true }), + ]); + + const actual = await call({ + query: { + localOnly: false, + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('null', async () => { + await insert([ + defaultData('001', { localOnly: true }), + defaultData('002', { localOnly: false }), + defaultData('003', { localOnly: true }), + ]); + + const actual = await call({ + query: {}, + }); + + expect(actual.allCount).toBe(3); + }); + }); + + describe('roleId', () => { + test('single', async () => { + await insert([ + defaultData('001', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role001'] }), + defaultData('002', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role002'] }), + defaultData('003', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role003'] }), + ]); + + const actual = await call({ + query: { + roleIds: ['role002'], + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role001'] }), + defaultData('002', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role002', 'role003'] }), + defaultData('003', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role003'] }), + defaultData('004', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role004'] }), + ]); + + const actual = await call({ + query: { + roleIds: ['role001', 'role003'], + }, + }); + + expect(actual.allCount).toBe(3); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + expect(actual.emojis[2].name).toBe('emoji003'); + }); + }); + }); + }); +}); diff --git a/packages/frontend/.storybook/fake-utils.ts b/packages/frontend/.storybook/fake-utils.ts new file mode 100644 index 0000000000..c777cbbe72 --- /dev/null +++ b/packages/frontend/.storybook/fake-utils.ts @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import seedrandom from 'seedrandom'; + +/** + * AIã§ç”Ÿæˆã—ãŸç„¡ä½œç‚ºãªãƒ•ã‚¡ãƒ¼ã‚¹ãƒˆãƒãƒ¼ãƒ + */ +export const firstNameDict = [ + 'Ethan', 'Olivia', 'Jackson', 'Emma', 'Liam', 'Ava', 'Aiden', 'Sophia', 'Mason', 'Isabella', + 'Noah', 'Mia', 'Lucas', 'Harper', 'Caleb', 'Abigail', 'Samuel', 'Emily', 'Logan', + 'Madison', 'Benjamin', 'Chloe', 'Elijah', 'Grace', 'Alexander', 'Scarlett', 'William', 'Zoey', 'James', 'Lily', +] + +/** + * AIã§ç”Ÿæˆã—ãŸç„¡ä½œç‚ºãªãƒ©ã‚¹ãƒˆãƒãƒ¼ãƒ + */ +export const lastNameDict = [ + 'Anderson', 'Johnson', 'Thompson', 'Davis', 'Rodriguez', 'Smith', 'Patel', 'Williams', 'Lee', 'Brown', + 'Garcia', 'Jackson', 'Martinez', 'Taylor', 'Harris', 'Nguyen', 'Miller', 'Jones', 'Wilson', + 'White', 'Thomas', 'Garcia', 'Martinez', 'Robinson', 'Turner', 'Lewis', 'Hall', 'King', 'Baker', 'Cooper', +] + +/** + * AIã§ç”Ÿæˆã—ãŸç„¡ä½œç‚ºãªå›½å + */ +export const countryDict = [ + 'Japan', 'Canada', 'Brazil', 'Australia', 'Italy', 'SouthAfrica', 'Mexico', 'Sweden', 'Russia', 'India', + 'Germany', 'Argentina', 'South Korea', 'France', 'Nigeria', 'Turkey', 'Spain', 'Egypt', 'Thailand', + 'Vietnam', 'Kenya', 'Saudi Arabia', 'Netherlands', 'Colombia', 'Poland', 'Chile', 'Malaysia', 'Ukraine', 'New Zealand', 'Peru', +] + +export function text(length: number = 10, seed?: string): string { + let result = ""; + + // シード値を使ã†å ´åˆã€åŒã˜æ•°å€¤ãŒç¾…列ã•ã‚Œã‚‹ãŒã€ãƒ©ãƒ³ãƒ€ãƒ æ–‡å—列ã¨ã„ã†æ„味ã§ã¯æº€ãŸã›ã¦ã„ã‚‹ã¨æ€ã†ã®ã§ã“ã®ã¾ã¾ä½¿ã£ã¦ãŠã + const rand = seed ? seedrandom(seed)() : Math.random(); + while (result.length < length) { + result += rand.toString(36).substring(2); + } + + return result.substring(0, length); +} + +export function integer(min: number = 0, max: number = 9999, seed?: string): number { + const rand = seed ? seedrandom(seed)() : Math.random(); + return Math.floor(rand * (max - min)) + min; +} + +export function date(params?: { + yearMin?: number, + yearMax?: number, + monthMin?: number, + monthMax?: number, + dayMin?: number, + dayMax?: number, + hourMin?: number, + hourMax?: number, + minuteMin?: number, + minuteMax?: number, + secondMin?: number, + secondMax?: number, + millisecondMin?: number, + millisecondMax?: number, +}, seed?: string): Date { + const year = integer(params?.yearMin ?? 1970, params?.yearMax ?? (new Date()).getFullYear(), seed); + const month = integer(params?.monthMin ?? 1, params?.monthMax ?? 12, seed); + let day = integer(params?.dayMin ?? 1, params?.dayMax ?? 31, seed); + if (month === 2) { + day = Math.min(day, 28); + } else if ([4, 6, 9, 11].includes(month)) { + day = Math.min(day, 30); + } else { + day = Math.min(day, 31); + } + + const hour = integer(params?.hourMin ?? 0, params?.hourMax ?? 23, seed); + const minute = integer(params?.minuteMin ?? 0, params?.minuteMax ?? 59, seed); + const second = integer(params?.secondMin ?? 0, params?.secondMax ?? 59, seed); + const millisecond = integer(params?.millisecondMin ?? 0, params?.millisecondMax ?? 999, seed); + + return new Date(year, month - 1, day, hour, minute, second, millisecond); +} + +export function boolean(seed?: string): boolean { + const rand = seed ? seedrandom(seed)() : Math.random(); + return rand < 0.5; +} + +export function choose<T>(array: T[], seed?: string): T { + const rand = seed ? seedrandom(seed)() : Math.random(); + return array[Math.floor(rand * array.length)]; +} + +export function firstName(seed?: string): string { + return choose(firstNameDict, seed); +} + +export function lastName(seed?: string): string { + return choose(lastNameDict, seed); +} + +export function country(seed?: string): string { + return choose(countryDict, seed); +} + +const TIME2000 = 946684800000; +export function fakeId(seed?: string): string { + let time = new Date().getTime(); + + time = time - TIME2000; + if (time < 0) time = 0; + + const timeStr = time.toString(36).padStart(8, '0'); + const noiseStr = text(2, seed); + + return timeStr + noiseStr; +} + +export function imageDataUrl(options?: { + size?: { + width?: number, + height?: number, + }, + color?: { + red?: number, + green?: number, + blue?: number, + alpha?: number, + } +}, seed?: string): string { + const canvas = document.createElement('canvas'); + canvas.width = options?.size?.width ?? 100; + canvas.height = options?.size?.height ?? 100; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get 2d context'); + } + + ctx.beginPath() + + const red = options?.color?.red ?? integer(0, 255, seed); + const green = options?.color?.green ?? integer(0, 255, seed); + const blue = options?.color?.blue ?? integer(0, 255, seed); + const alpha = options?.color?.alpha ?? 1; + ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2, true); + ctx.fillStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})`; + ctx.fill(); + + return canvas.toDataURL('image/png', 1.0); +} diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index fc3b0334e4..0a5ac15aa5 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -5,6 +5,7 @@ import { AISCRIPT_VERSION } from '@syuilo/aiscript'; import type { entities } from 'misskey-js' +import { date, imageDataUrl, text } from "./fake-utils.js"; export function abuseUserReport() { return { @@ -301,3 +302,93 @@ export function inviteCode(isUsed = false, hasExpiration = false, isExpired = fa used: isUsed, } } + +export function role(params: { + id?: string, + name?: string, + color?: string | null, + iconUrl?: string | null, + description?: string, + isModerator?: boolean, + isAdministrator?: boolean, + displayOrder?: number, + createdAt?: string, + updatedAt?: string, + target?: 'manual' | 'conditional', + isPublic?: boolean, + isExplorable?: boolean, + asBadge?: boolean, + canEditMembersByModerator?: boolean, + usersCount?: number, +}, seed?: string): entities.Role { + const prefix = params.displayOrder ? params.displayOrder.toString().padStart(3, '0') + '-' : ''; + const genId = text(36, seed); + const createdAt = params.createdAt ?? date({}, seed).toISOString(); + const updatedAt = params.updatedAt ?? date({}, seed).toISOString(); + + return { + id: params.id ?? genId, + name: params.name ?? `${prefix}TestRole-${genId}`, + color: params.color ?? '#445566', + iconUrl: params.iconUrl ?? null, + description: params.description ?? '', + isModerator: params.isModerator ?? false, + isAdministrator: params.isAdministrator ?? false, + displayOrder: params.displayOrder ?? 0, + createdAt: createdAt, + updatedAt: updatedAt, + target: params.target ?? 'manual', + isPublic: params.isPublic ?? true, + isExplorable: params.isExplorable ?? true, + asBadge: params.asBadge ?? true, + canEditMembersByModerator: params.canEditMembersByModerator ?? false, + usersCount: params.usersCount ?? 10, + condFormula: { + id: '', + type: 'or', + values: [] + }, + policies: {}, + } +} + +export function emoji(params?: { + id?: string, + name?: string, + host?: string, + uri?: string, + publicUrl?: string, + originalUrl?: string, + type?: string, + aliases?: string[], + category?: string, + license?: string, + isSensitive?: boolean, + localOnly?: boolean, + roleIdsThatCanBeUsedThisEmojiAsReaction?: {id:string, name:string}[], + updatedAt?: string, +}, seed?: string): entities.EmojiDetailedAdmin { + const _seed = seed ?? (params?.id ?? "DEFAULT_SEED"); + const id = params?.id ?? text(32, _seed); + const name = params?.name ?? text(8, _seed); + const updatedAt = params?.updatedAt ?? date({}, _seed).toISOString(); + + const image = imageDataUrl({}, _seed) + + return { + id: id, + name: name, + host: params?.host ?? null, + uri: params?.uri ?? null, + publicUrl: params?.publicUrl ?? image, + originalUrl: params?.originalUrl ?? image, + type: params?.type ?? 'image/png', + aliases: params?.aliases ?? [`alias1-${name}`, `alias2-${name}`], + category: params?.category ?? null, + license: params?.license ?? null, + isSensitive: params?.isSensitive ?? false, + localOnly: params?.localOnly ?? false, + roleIdsThatCanBeUsedThisEmojiAsReaction: params?.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [], + updatedAt: updatedAt, + } +} diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index f2bdc631d2..8830523810 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -416,6 +416,10 @@ function toStories(component: string): Promise<string> { glob('src/components/MkUserSetupDialog.*.vue'), glob('src/components/MkInstanceCardMini.vue'), glob('src/components/MkInviteCode.vue'), + glob('src/components/MkTagItem.vue'), + glob('src/components/MkRoleSelectDialog.vue'), + glob('src/components/grid/MkGrid.vue'), + glob('src/pages/admin/custom-emojis-manager2.vue'), glob('src/pages/admin/overview.ap-requests.vue'), glob('src/pages/user/home.vue'), glob('src/pages/search.vue'), diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 7bdc06a8b4..384c0c0b34 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <KeepAlive> <div v-show="opened"> - <MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22"> + <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax"> <slot></slot> </MkSpacer> <div v-else> @@ -64,10 +64,14 @@ const props = withDefaults(defineProps<{ defaultOpen?: boolean; maxHeight?: number | null; withSpacer?: boolean; + spacerMin?: number; + spacerMax?: number; }>(), { defaultOpen: false, maxHeight: null, withSpacer: true, + spacerMin: 14, + spacerMax: 22, }); const rootEl = shallowRef<HTMLElement>(); diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index c766a33823..a446dad0ab 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -288,20 +288,23 @@ const align = () => { const onOpened = () => { emit('opened'); - // NOTE: Chromatic テストã®éš›ã« undefined ã«ãªã‚‹å ´åˆãŒã‚ã‚‹ - if (content.value == null) return; - - // モーダルコンテンツã«ãƒžã‚¦ã‚¹ãƒœã‚¿ãƒ³ãŒæŠ¼ã•ã‚Œã€ã‚³ãƒ³ãƒ†ãƒ³ãƒ„外ã§ãƒžã‚¦ã‚¹ãƒœã‚¿ãƒ³ãŒé›¢ã•ã‚ŒãŸã¨ãã«ãƒ¢ãƒ¼ãƒ€ãƒ«ãƒãƒƒã‚¯ã‚°ãƒ©ã‚¦ãƒ³ãƒ‰ã‚¯ãƒªãƒƒã‚¯ã¨åˆ¤å®šã•ã›ãªã„ãŸã‚ã«ãƒžã‚¦ã‚¹ã‚¤ãƒ™ãƒ³ãƒˆã‚’監視ã—フラグ管ç†ã™ã‚‹ - 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 @@ +<!-- +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"><</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">></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 @@ -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<DroppedItem[]> { + const dropItems = ev.dataTransfer?.items; + if (!dropItems || dropItems.length === 0) { + return []; + } + + const apiTestItem = dropItems[0]; + if ('webkitGetAsEntry' in apiTestItem) { + return readDataTransferItems(dropItems); + } else { + // webkitGetAsEntryã«å¯¾å¿œã—ã¦ã„ãªã„å ´åˆã¯filesã‹ã‚‰å–å¾—ã™ã‚‹ï¼ˆãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªã®ã‚µãƒãƒ¼ãƒˆã¯å‡ºæ¥ãªã„) + const dropFiles = ev.dataTransfer.files; + if (dropFiles.length === 0) { + return []; + } + + const droppedFiles = Array.of<DroppedFile>(); + for (let i = 0; i < dropFiles.length; i++) { + const file = dropFiles.item(i); + if (file) { + droppedFiles.push({ + isFile: true, + path: file.name, + file, + }); + } + } + + return droppedFiles; + } +} + +/** + * ドラッグ&ドãƒãƒƒãƒ—ã•ã‚ŒãŸãƒ•ã‚¡ã‚¤ãƒ«ã®ãƒªã‚¹ãƒˆã‹ã‚‰ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªæ§‹é€ ã¨ãƒ•ã‚¡ã‚¤ãƒ«ã¸ã®å‚照({@link File})をå–å¾—ã™ã‚‹ã€‚ + */ +export async function readDataTransferItems(itemList: DataTransferItemList): Promise<DroppedItem[]> { + async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> { + if (entry.isFile) { + return { + isFile: true, + path: entry.fullPath, + file: await readFile(entry as FileSystemFileEntry), + }; + } else { + return { + isFile: false, + path: entry.fullPath, + children: await readDirectory(entry as FileSystemDirectoryEntry), + }; + } + } + + function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> { + return new Promise((resolve, reject) => { + fileSystemFileEntry.file(resolve, reject); + }); + } + + function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> { + return new Promise(async (resolve) => { + const allEntries = Array.of<FileSystemEntry>(); + const reader = fileSystemDirectoryEntry.createReader(); + while (true) { + const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej)); + if (entries.length === 0) { + break; + } + allEntries.push(...entries); + } + + resolve(await Promise.all(allEntries.map(readEntry))); + }); + } + + // 扱ã„ã«ãã„ã®ã§é…列ã«å¤‰æ› + const items = Array.of<DataTransferItem>(); + for (let i = 0; i < itemList.length; i++) { + items.push(itemList[i]); + } + + return Promise.all( + items + .map(it => it.webkitGetAsEntry()) + .filter(it => it) + .map(it => readEntry(it!)), + ); +} + +/** + * {@link DroppedItem}ã®ãƒªã‚¹ãƒˆã‹ã‚‰ãƒ‡ã‚£ãƒ¬ã‚¯ãƒˆãƒªã‚’å†å¸°çš„ã«æ¤œç´¢ã—ã€ãƒ•ã‚¡ã‚¤ãƒ«ã®ãƒªã‚¹ãƒˆã‚’å–å¾—ã™ã‚‹ã€‚ + */ +export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] { + const result = Array.of<DroppedFile>(); + for (const item of items) { + if (item.isFile) { + result.push(item); + } else { + result.push(...flattenDroppedFiles(item.children)); + } + } + return result; +} diff --git a/packages/frontend/src/scripts/key-event.ts b/packages/frontend/src/scripts/key-event.ts new file mode 100644 index 0000000000..a72776d48c --- /dev/null +++ b/packages/frontend/src/scripts/key-event.ts @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * {@link KeyboardEvent.code} ã®å€¤ã‚’表ã™æ–‡å—列。ä¸è¶³åˆ†ã¯é©å®œè¿½åŠ ã™ã‚‹ + * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values + */ +export type KeyCode = + | 'Backspace' + | 'Tab' + | 'Enter' + | 'Shift' + | 'Control' + | 'Alt' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Space' + | 'PageUp' + | 'PageDown' + | 'End' + | 'Home' + | 'ArrowLeft' + | 'ArrowUp' + | 'ArrowRight' + | 'ArrowDown' + | 'Insert' + | 'Delete' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'NumLock' + | 'ScrollLock' + | 'Semicolon' + | 'Equal' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'Meta' + | 'AltGraph' + ; + +/** + * 修飾ã‚ーを表ã™æ–‡å—列。ä¸è¶³åˆ†ã¯é©å®œè¿½åŠ ã™ã‚‹ã€‚ + */ +export type KeyModifier = + | 'Shift' + | 'Control' + | 'Alt' + | 'Meta' + ; + +/** + * 押下ã•ã‚ŒãŸã‚ー以外ã®çŠ¶æ…‹ã‚’表ã™æ–‡å—列。ä¸è¶³åˆ†ã¯é©å®œè¿½åŠ ã™ã‚‹ã€‚ + */ +export type KeyState = + | 'composing' + | 'repeat' + ; + +export type KeyEventHandler = { + modifiers?: KeyModifier[]; + states?: KeyState[]; + code: KeyCode | 'any'; + handler: (event: KeyboardEvent) => void; +} + +export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) { + function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) { + if (modifiers) { + return modifiers.every(modifier => ev.getModifierState(modifier)); + } + return true; + } + + function checkState(ev: KeyboardEvent, states?: KeyState[]) { + if (states) { + return states.every(state => ev.getModifierState(state)); + } + return true; + } + + let hit = false; + for (const handler of handlers.filter(it => it.code === event.code)) { + if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) { + handler.handler(event); + hit = true; + break; + } + } + + if (!hit) { + for (const handler of handlers.filter(it => it.code === 'any')) { + handler.handler(event); + } + } +} diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts index b037aa8acc..c25b4d73bd 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -12,14 +12,28 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { uploadFile } from '@/scripts/upload.js'; -export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise<Misskey.entities.DriveFile[]> { +export function chooseFileFromPc( + multiple: boolean, + options?: { + uploadFolder?: string | null; + keepOriginal?: boolean; + nameConverter?: (file: File) => string | undefined; + }, +): Promise<Misskey.entities.DriveFile[]> { + const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder; + const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading; + const nameConverter = options?.nameConverter ?? (() => undefined); + return new Promise((res, rej) => { const input = document.createElement('input'); input.type = 'file'; input.multiple = multiple; input.onchange = () => { if (!input.files) return res([]); - const promises = Array.from(input.files, file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal)); + const promises = Array.from( + input.files, + file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal), + ); Promise.all(promises).then(driveFiles => { res(driveFiles); @@ -94,7 +108,7 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)), + action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)), }, { text: i18n.ts.fromDrive, icon: 'ti ti-cloud', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 211ddb8287..7098b52205 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1117,6 +1117,9 @@ type EmojiDeleted = { // @public (undocumented) type EmojiDetailed = components['schemas']['EmojiDetailed']; +// @public (undocumented) +type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin']; + // @public (undocumented) type EmojiRequest = operations['emoji']['requestBody']['content']['application/json']; @@ -1294,6 +1297,8 @@ declare namespace entities { AdminEmojiSetCategoryBulkRequest, AdminEmojiSetLicenseBulkRequest, AdminEmojiUpdateRequest, + V2AdminEmojiListRequest, + V2AdminEmojiListResponse, AdminFederationDeleteAllFilesRequest, AdminFederationRefreshRemoteInstanceMetadataRequest, AdminFederationRemoveAllFollowingRequest, @@ -1847,6 +1852,7 @@ declare namespace entities { GalleryPost, EmojiSimple, EmojiDetailed, + EmojiDetailedAdmin, Flash, Signin, RoleCondFormulaLogics, @@ -3420,6 +3426,12 @@ type UsersShowResponse = operations['users___show']['responses']['200']['content // @public (undocumented) type UsersUpdateMemoRequest = operations['users___update-memo']['requestBody']['content']['application/json']; +// @public (undocumented) +type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json']; + +// @public (undocumented) +type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json']; + // Warnings were encountered during analysis: // // src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 3bcdae6a4a..edaa0498e9 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -493,6 +493,17 @@ declare module '../api.js' { credential?: string | null, ): Promise<SwitchCaseResponseType<E, P>>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + request<E extends 'v2/admin/emoji/list', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index b016d5bbcf..982717597b 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -62,6 +62,8 @@ import type { AdminEmojiSetCategoryBulkRequest, AdminEmojiSetLicenseBulkRequest, AdminEmojiUpdateRequest, + V2AdminEmojiListRequest, + V2AdminEmojiListResponse, AdminFederationDeleteAllFilesRequest, AdminFederationRefreshRemoteInstanceMetadataRequest, AdminFederationRemoveAllFollowingRequest, @@ -628,6 +630,7 @@ export type Endpoints = { 'admin/emoji/set-category-bulk': { req: AdminEmojiSetCategoryBulkRequest; res: EmptyResponse }; 'admin/emoji/set-license-bulk': { req: AdminEmojiSetLicenseBulkRequest; res: EmptyResponse }; 'admin/emoji/update': { req: AdminEmojiUpdateRequest; res: EmptyResponse }; + 'v2/admin/emoji/list': { req: V2AdminEmojiListRequest; res: V2AdminEmojiListResponse }; 'admin/federation/delete-all-files': { req: AdminFederationDeleteAllFilesRequest; res: EmptyResponse }; 'admin/federation/refresh-remote-instance-metadata': { req: AdminFederationRefreshRemoteInstanceMetadataRequest; res: EmptyResponse }; 'admin/federation/remove-all-following': { req: AdminFederationRemoveAllFollowingRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 02be4848c7..e4299d62c7 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -65,6 +65,8 @@ export type AdminEmojiSetAliasesBulkRequest = operations['admin___emoji___set-al export type AdminEmojiSetCategoryBulkRequest = operations['admin___emoji___set-category-bulk']['requestBody']['content']['application/json']; export type AdminEmojiSetLicenseBulkRequest = operations['admin___emoji___set-license-bulk']['requestBody']['content']['application/json']; export type AdminEmojiUpdateRequest = operations['admin___emoji___update']['requestBody']['content']['application/json']; +export type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json']; +export type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json']; export type AdminFederationDeleteAllFilesRequest = operations['admin___federation___delete-all-files']['requestBody']['content']['application/json']; export type AdminFederationRefreshRemoteInstanceMetadataRequest = operations['admin___federation___refresh-remote-instance-metadata']['requestBody']['content']['application/json']; export type AdminFederationRemoveAllFollowingRequest = operations['admin___federation___remove-all-following']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 04574849d4..1a30da4437 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -33,6 +33,7 @@ export type FederationInstance = components['schemas']['FederationInstance']; export type GalleryPost = components['schemas']['GalleryPost']; export type EmojiSimple = components['schemas']['EmojiSimple']; export type EmojiDetailed = components['schemas']['EmojiDetailed']; +export type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin']; export type Flash = components['schemas']['Flash']; export type Signin = components['schemas']['Signin']; export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index ada685604d..75a99263d0 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -414,6 +414,15 @@ export type paths = { */ post: operations['admin___emoji___update']; }; + '/v2/admin/emoji/list': { + /** + * v2/admin/emoji/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + post: operations['v2___admin___emoji___list']; + }; '/admin/federation/delete-all-files': { /** * admin/federation/delete-all-files @@ -4749,6 +4758,29 @@ export type components = { localOnly: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: string[]; }; + EmojiDetailedAdmin: { + /** Format: id */ + id: string; + /** Format: date-time */ + updatedAt: string | null; + name: string; + /** @description The local host is represented with `null`. */ + host: string | null; + publicUrl: string; + originalUrl: string; + uri: string | null; + type: string | null; + aliases: string[]; + category: string | null; + license: string | null; + localOnly: boolean; + isSensitive: boolean; + roleIdsThatCanBeUsedThisEmojiAsReaction: { + /** Format: misskey:id */ + id: string; + name: string; + }[]; + }; Flash: { /** * Format: id @@ -7872,6 +7904,97 @@ export type operations = { }; }; }; + /** + * v2/admin/emoji/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:emoji* + */ + v2___admin___emoji___list: { + requestBody: { + content: { + 'application/json': { + query?: ({ + updatedAtFrom?: string; + updatedAtTo?: string; + name?: string; + host?: string; + uri?: string; + publicUrl?: string; + originalUrl?: string; + type?: string; + aliases?: string; + category?: string; + license?: string; + isSensitive?: boolean; + localOnly?: boolean; + /** + * @default all + * @enum {string} + */ + hostType?: 'local' | 'remote' | 'all'; + roleIds?: string[]; + }) | null; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + /** @default 10 */ + limit?: number; + page?: number; + /** + * @default [ + * "-id" + * ] + */ + sortKeys?: ('+id' | '-id' | '+updatedAt' | '-updatedAt' | '+name' | '-name' | '+host' | '-host' | '+uri' | '-uri' | '+publicUrl' | '-publicUrl' | '+type' | '-type' | '+aliases' | '-aliases' | '+category' | '-category' | '+license' | '-license' | '+isSensitive' | '-isSensitive' | '+localOnly' | '-localOnly' | '+roleIdsThatCanBeUsedThisEmojiAsReaction' | '-roleIdsThatCanBeUsedThisEmojiAsReaction')[]; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + emojis: components['schemas']['EmojiDetailedAdmin'][]; + count: number; + allCount: number; + allPages: number; + }; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * admin/federation/delete-all-files * @description No description provided. -- GitLab