From 8a72a05958f415aa035ae3523e28c0b75f2a8d26 Mon Sep 17 00:00:00 2001 From: tamaina <tamaina@hotmail.co.jp> Date: Tue, 1 Aug 2023 15:32:03 +0900 Subject: [PATCH] =?UTF-8?q?enhance(frontend):=20=E3=83=A6=E3=83=BC?= =?UTF-8?q?=E3=82=B6=E3=83=BC=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC=E3=81=A7?= =?UTF-8?q?=E3=82=B9=E3=82=A4=E3=83=83=E3=83=81=E3=81=A7=E3=83=A6=E3=83=BC?= =?UTF-8?q?=E3=82=B6=E3=83=BC=E3=83=AA=E3=82=B9=E3=83=88=E3=81=AB=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=83=BB=E5=89=8A=E9=99=A4=E3=81=A7=E3=81=8D=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=20(#11439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * メニューのトグルをいい感じにする * user list toggle! * add changelog * fix * stop --- CHANGELOG.md | 2 + packages/frontend/src/components/MkMenu.vue | 57 +++++++++--- packages/frontend/src/components/MkNote.vue | 8 +- .../src/components/MkNoteDetailed.vue | 8 +- .../src/components/MkSwitch.button.vue | 88 +++++++++++++++++++ packages/frontend/src/components/MkSwitch.vue | 50 +---------- .../frontend/src/components/MkUserPopup.vue | 3 +- packages/frontend/src/pages/user/home.vue | 3 +- .../frontend/src/scripts/get-note-menu.ts | 29 ++++-- .../frontend/src/scripts/get-user-menu.ts | 49 ++++++++--- packages/frontend/src/types/menu.ts | 2 +- 11 files changed, 214 insertions(+), 85 deletions(-) create mode 100644 packages/frontend/src/components/MkSwitch.button.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c8bcd42..10ddc33d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - OAuth 2.0のサポート ### Client +- メニューのスイッチの動作を改善 +- Enhance: ユーザーメニューでスイッチでユーザーリストに追加・削除できるように - Enhance: 自分が押したリアクションのデザインを改善 - Fix: サーバー情報画面(`/instance-info/{domain}`)でブロックができないのを修正 - Fix: 未読のお知らせの「わかった」をクリック・タップしてもその場で「わかった」が消えない問題を修正 diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index f3c7c235a..3d4e45b1f 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -35,9 +35,10 @@ SPDX-License-Identifier: AGPL-3.0-only <MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/> <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> </button> - <span v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> - <MkSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</MkSwitch> - </span> + <button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <MkSwitchButton :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)" /> + <span :class="$style.switchText">{{ item.text }}</span> + </button> <button v-else-if="item.type === 'parent'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <span>{{ item.text }}</span> @@ -63,8 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { focusPrev, focusNext } from '@/scripts/focus'; -import MkSwitch from '@/components/MkSwitch.vue'; -import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu'; +import MkSwitchButton from '@/components/MkSwitch.button.vue'; +import { MenuItem, InnerMenuItem, OuterMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu'; import * as os from '@/os'; import { i18n } from '@/i18n'; @@ -145,17 +146,17 @@ function onItemMouseLeave(item) { if (childCloseTimer) window.clearTimeout(childCloseTimer); } -let childrenCache = new WeakMap(); -async function showChildren(item: MenuItem, ev: MouseEvent) { - const children = ref([]); +let childrenCache = new WeakMap<MenuParent, OuterMenuItem[]>(); +async function showChildren(item: MenuParent, ev: MouseEvent) { + const children = ref<OuterMenuItem[]>([]); if (childrenCache.has(item)) { - children.value = childrenCache.get(item); + children.value = childrenCache.get(item)!; } else { if (typeof item.children === 'function') { children.value = [{ type: 'pending', }]; - item.children().then(x => { + Promise.resolve(item.children()).then(x => { children.value = x; childrenCache.set(item, x); }); @@ -191,6 +192,11 @@ function focusDown() { focusNext(document.activeElement); } +function switchItem(item: MenuSwitch & { ref: any }) { + if (item.disabled) return; + item.ref = !item.ref; +} + onMounted(() => { if (props.viaKeyboard) { nextTick(() => { @@ -357,6 +363,37 @@ onBeforeUnmount(() => { } } +.switch { + position: relative; + display: flex; + transition: all 0.2s ease; + user-select: none; + cursor: pointer; +} + +.switchDisabled { + cursor: not-allowed; +} + +.switchButton { + margin-left: -2px; +} + +.switchText { + margin-left: 8px; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; +} + +.switchInput { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; +} + .icon { margin-right: 8px; } diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 962a5eef5..6cd1a4c4b 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -408,14 +408,16 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), ev).then(focus); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + os.contextMenu(menu, ev).then(focus).finally(cleanup); } } function menu(viaKeyboard = false): void { - os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), menuButton.value, { + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + os.popupMenu(menu, menuButton.value, { viaKeyboard, - }).then(focus); + }).then(focus).finally(cleanup); } async function clip() { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index c34ba4f83..8acd4e470 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -385,14 +385,16 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), ev).then(focus); + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }); + os.contextMenu(menu, ev).then(focus).finally(cleanup); } } function menu(viaKeyboard = false): void { - os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, { + const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }); + os.popupMenu(menu, menuButton.value, { viaKeyboard, - }).then(focus); + }).then(focus).finally(cleanup); } async function clip() { diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue new file mode 100644 index 000000000..1d420f86c --- /dev/null +++ b/packages/frontend/src/components/MkSwitch.button.vue @@ -0,0 +1,88 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<span + v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" + :class="{ + [$style.button]: true, + [$style.buttonChecked]: checked, + [$style.buttonDisabled]: props.disabled + }" + data-cy-switch-toggle + @click.prevent.stop="toggle" +> + <div :class="{ [$style.knob]: true, [$style.knobChecked]: checked }"></div> +</span> +</template> + +<script lang="ts" setup> +import { toRefs, Ref } from 'vue'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + checked: boolean | Ref<boolean>; + disabled?: boolean; +}>(), { + disabled: false, +}); + +const emit = defineEmits<{ + (ev: 'toggle'): void; +}>(); + +const checked = toRefs(props).checked; +const toggle = () => { + emit('toggle'); +}; +</script> + +<style lang="scss" module> +.button { + position: relative; + display: inline-flex; + flex-shrink: 0; + margin: 0; + box-sizing: border-box; + width: 32px; + height: 23px; + outline: none; + background: var(--switchOffBg); + background-clip: content-box; + border: solid 1px var(--switchOffBg); + border-radius: 999px; + cursor: pointer; + transition: inherit; + user-select: none; +} + +.buttonChecked { + background-color: var(--switchOnBg) !important; + border-color: var(--switchOnBg) !important; +} + +.buttonDisabled { + cursor: not-allowed; +} + +.knob { + position: absolute; + top: 3px; + width: 15px; + height: 15px; + border-radius: 999px; + transition: all 0.2s ease; + + &:not(.knobChecked) { + left: 3px; + background: var(--switchOffFg); + } +} + +.knobChecked { + left: 12px; + background: var(--switchOnFg); +} +</style> diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 9304f1717..96e2bad49 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -12,9 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.input" @keydown.enter="toggle" > - <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" :class="$style.button" data-cy-switch-toggle @click.prevent="toggle"> - <div :class="$style.knob"></div> - </span> + <XButton :checked="checked" :disabled="disabled" @toggle="toggle" /> <span :class="$style.body"> <!-- TODO: 無名slotの方は廃止 --> <span :class="$style.label" @click="toggle"><slot name="label"></slot><slot></slot></span> @@ -25,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { toRefs, Ref } from 'vue'; -import { i18n } from '@/i18n'; +import XButton from '@/components/MkSwitch.button.vue'; const props = defineProps<{ modelValue: boolean | Ref<boolean>; @@ -36,7 +34,6 @@ const emit = defineEmits<{ (ev: 'update:modelValue', v: boolean): void; }>(); -let button = $shallowRef<HTMLElement>(); const checked = toRefs(props).modelValue; const toggle = () => { if (props.disabled) return; @@ -66,17 +63,8 @@ const toggle = () => { cursor: not-allowed; } - &.checked { - > .button { - background-color: var(--switchOnBg) !important; - border-color: var(--switchOnBg) !important; - - > .knob { - left: 12px; - background: var(--switchOnFg); - } - } - } + //&.checked { + //} } .input { @@ -86,36 +74,6 @@ const toggle = () => { opacity: 0; margin: 0; } - -.button { - position: relative; - display: inline-flex; - flex-shrink: 0; - margin: 0; - box-sizing: border-box; - width: 32px; - height: 23px; - outline: none; - background: var(--switchOffBg); - background-clip: content-box; - border: solid 1px var(--switchOffBg); - border-radius: 999px; - cursor: pointer; - transition: inherit; - user-select: none; -} - -.knob { - position: absolute; - top: 3px; - left: 3px; - width: 15px; - height: 15px; - background: var(--switchOffFg); - border-radius: 999px; - transition: all 0.2s ease; -} - .body { margin-left: 12px; margin-top: 2px; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index 50f11f07b..be32af4cf 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -86,7 +86,8 @@ let top = $ref(0); let left = $ref(0); function showMenu(ev: MouseEvent) { - os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target); + const { menu, cleanup } = getUserMenu(user); + os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } onMounted(() => { diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index b32717c64..069954b93 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -214,7 +214,8 @@ const age = $computed(() => { }); function menu(ev) { - os.popupMenu(getUserMenu(props.user, router), ev.currentTarget ?? ev.target); + const { menu, cleanup } = getUserMenu(props.user, router); + os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } function parallaxLoop() { diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index b63c79750..20cea45ee 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -16,6 +16,7 @@ import { defaultStore, noteActions } from '@/store'; import { miLocalStorage } from '@/local-storage'; import { getUserMenu } from '@/scripts/get-user-menu'; import { clipsCache } from '@/cache'; +import { MenuItem } from '@/types/menu'; export async function getNoteClipMenu(props: { note: misskey.entities.Note; @@ -108,6 +109,8 @@ export function getNoteMenu(props: { const appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note; + const cleanups = [] as (() => void)[]; + function del(): void { os.confirm({ type: 'warning', @@ -233,7 +236,7 @@ export function getNoteMenu(props: { props.translation.value = res; } - let menu; + let menu: MenuItem[]; if ($i) { const statePromise = os.api('notes/state', { noteId: appearNote.id, @@ -295,7 +298,7 @@ export function getNoteMenu(props: { action: () => toggleFavorite(true), }), { - type: 'parent', + type: 'parent' as const, icon: 'ti ti-paperclip', text: i18n.ts.clip, children: () => getNoteClipMenu(props), @@ -318,15 +321,17 @@ export function getNoteMenu(props: { text: i18n.ts.pin, action: () => togglePin(true), } : undefined, - appearNote.userId !== $i.id ? { - type: 'parent', + { + type: 'parent' as const, icon: 'ti ti-user', text: i18n.ts.user, children: async () => { - const user = await os.api('users/show', { userId: appearNote.userId }); - return getUserMenu(user); + const user = appearNote.userId === $i?.id ? $i : await os.api('users/show', { userId: appearNote.userId }); + const { menu, cleanup } = getUserMenu(user); + cleanups.push(cleanup); + return menu; }, - } : undefined, + }, /* ...($i.isModerator || $i.isAdmin ? [ null, @@ -411,5 +416,13 @@ export function getNoteMenu(props: { }]); } - return menu; + const cleanup = () => { + if (_DEV_) console.log('note menu cleanup', cleanups); + cleanups.forEach(cleanup => cleanup()); + }; + + return { + menu, + cleanup, + }; } diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 7f2111be4..445560b0c 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -4,7 +4,7 @@ */ import { toUnicode } from 'punycode'; -import { defineAsyncComponent } from 'vue'; +import { defineAsyncComponent, ref, watch } from 'vue'; import * as misskey from 'misskey-js'; import { i18n } from '@/i18n'; import copyToClipboard from '@/scripts/copy-to-clipboard'; @@ -19,6 +19,8 @@ import { antennasCache, rolesCache, userListsCache } from '@/cache'; export function getUserMenu(user: misskey.entities.UserDetailed, router: Router = mainRouter) { const meId = $i ? $i.id : null; + const cleanups = [] as (() => void)[]; + async function toggleMute() { if (user.isMuted) { os.apiWithDialog('mute/delete', { @@ -168,17 +170,32 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router text: i18n.ts.addToList, children: async () => { const lists = await userListsCache.fetch(() => os.api('users/lists/list')); + return lists.map(list => { + const isListed = ref(list.userIds.includes(user.id)); + cleanups.push(watch(isListed, () => { + if (isListed.value) { + os.apiWithDialog('users/lists/push', { + listId: list.id, + userId: user.id, + }).then(() => { + list.userIds.push(user.id); + }); + } else { + os.apiWithDialog('users/lists/pull', { + listId: list.id, + userId: user.id, + }).then(() => { + list.userIds.splice(list.userIds.indexOf(user.id), 1); + }); + } + })); - return lists.map(list => ({ - text: list.name, - action: async () => { - await os.apiWithDialog('users/lists/push', { - listId: list.id, - userId: user.id, - }); - userListsCache.delete(); - }, - })); + return { + type: 'switch', + text: list.name, + ref: isListed, + }; + }); }, }, { type: 'parent', @@ -311,5 +328,13 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router }))]); } - return menu; + const cleanup = () => { + if (_DEV_) console.log('user menu cleanup', cleanups); + cleanups.forEach(cleanup => cleanup()); + }; + + return { + menu, + cleanup, + }; } diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts index edeabe428..b2ba6290c 100644 --- a/packages/frontend/src/types/menu.ts +++ b/packages/frontend/src/types/menu.ts @@ -16,7 +16,7 @@ export type MenuA = { type: 'a', href: string, target?: string, download?: strin export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, disabled?: boolean }; export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction }; -export type MenuParent = { type: 'parent', text: string, icon?: string, children: OuterMenuItem[] }; +export type MenuParent = { type: 'parent', text: string, icon?: string, children: OuterMenuItem[] | (() => Promise<OuterMenuItem[]> | OuterMenuItem[]) }; export type MenuPending = { type: 'pending' };