1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-14 22:45:40 +02:00

enhance(frontend): improve nested popup menu ux (#17187)

* wip

* Update MkMenu.vue

* wip

* wip

* Update MkMenu.vue

* wip

* Update MkMenu.vue

* Update MkMenu.vue

* Update MkMenu.vue

* Update MkMenu.vue

* Update MkMenu.vue

* Update MkMenu.vue

* Update MkMenu.vue

* Update MkMenu.vue

* 💢

* Update MkMenu.vue

* Update MkMenu.vue

* Update MkMenu.vue
This commit is contained in:
syuilo
2026-04-07 16:52:30 +09:00
committed by GitHub
parent ae34578c6f
commit 38be94b2a3
5 changed files with 246 additions and 29 deletions

View File

@@ -5,7 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="el" :class="$style.root">
<MkMenu :items="items" :align="align" :width="width" :asDrawer="false" @close="onChildClosed"/>
<MkMenu
:items="items"
:align="align"
:width="width"
:asDrawer="false"
:debugDisablePredictionCone="debugDisablePredictionCone"
:debugShowPredictionCone="debugShowPredictionCone"
@close="onChildClosed"
/>
</div>
</template>
@@ -19,6 +27,8 @@ const props = defineProps<{
anchorElement: HTMLElement;
rootElement: HTMLElement;
width?: number;
debugDisablePredictionCone?: boolean;
debugShowPredictionCone?: boolean;
}>();
const emit = defineEmits<{
@@ -80,6 +90,7 @@ onUnmounted(() => {
});
defineExpose({
rootElement: el,
checkHit: (ev: MouseEvent) => {
return (ev.target === el.value || el.value?.contains(ev.target as Node));
},

View File

@@ -27,6 +27,8 @@ SPDX-License-Identifier: AGPL-3.0-only
}"
@keydown.stop="() => {}"
@contextmenu.self.prevent="() => {}"
@mousemove.passive="onMouseMove"
@mouseleave.passive="onMouseLeave"
>
<template v-for="item in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
@@ -169,6 +171,7 @@ SPDX-License-Identifier: AGPL-3.0-only
tabindex="0"
:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]"
@mouseenter.prevent="preferClick ? null : showChildren(item, $event)"
@mousemove="parentMouseMove"
@keydown.enter.prevent="preferClick ? null : showChildren(item, $event)"
@click.prevent="!preferClick ? null : showChildren(item, $event)"
>
@@ -206,15 +209,30 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]">
<span>{{ i18n.ts.none }}</span>
</span>
<div
:class="[$style.guard, { [$style.showGuard]: debugShowPredictionCone }]"
:style="{ clipPath: guardPolygon, top: guard.top + 'px' }"
@mousemove="guardMouseMove"
></div>
</div>
<div v-if="childMenu">
<XChild ref="child" :items="childMenu" :anchorElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/>
</div>
<XChild
v-if="childMenu" :key="childMenuKey"
ref="child"
:items="childMenu"
:anchorElement="childTarget!"
:rootElement="itemsEl!"
:debugDisablePredictionCone="props.debugDisablePredictionCone"
:debugShowPredictionCone="props.debugShowPredictionCone"
@actioned="childActioned"
@closed="closeChild"
/>
</div>
</template>
<script lang="ts">
import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, unref, watch, shallowRef } from 'vue';
import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, unref, watch, shallowRef, reactive } from 'vue';
import type { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
import type { Keymap } from '@/utility/hotkey.js';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
@@ -236,6 +254,8 @@ const props = defineProps<{
align?: 'center' | string;
width?: number;
maxHeight?: number;
debugDisablePredictionCone?: boolean;
debugShowPredictionCone?: boolean;
}>();
const emit = defineEmits<{
@@ -292,6 +312,7 @@ watch(() => props.items, () => {
});
const childMenu = ref<MenuItem[] | null>();
const childMenuKey = ref(0);
const childTarget = shallowRef<HTMLElement>();
function closeChild() {
@@ -348,6 +369,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent | PointerEvent |
} else {
childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement;
childMenu.value = children;
childMenuKey.value++;
childShowingItem.value = item;
}
}
@@ -378,6 +400,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent | PointerEvent | Ke
childTarget.value = (ev.currentTarget ?? ev.target) as HTMLElement;
// これでもリアクティビティは保たれる
childMenu.value = children;
childMenuKey.value++;
childShowingItem.value = item;
}
}
@@ -472,6 +495,67 @@ onMounted(() => {
onBeforeUnmount(() => {
disposeHandlers();
});
const guard = reactive({
enabled: false,
top: 0,
cursorSideX: 0,
cursorSideY: 0,
childSideTopY: 0,
childSideBottomY: 0,
direction: 'toRight',
});
const guardPolygon = computed(() =>
guard.enabled
? guard.direction === 'toRight'
? `polygon(${guard.cursorSideX}px ${guard.cursorSideY}px, 101% ${guard.childSideTopY}px, 101% ${guard.childSideBottomY}px)` // ぴったり端に100%で覆ってもなぜか端でカーソルのイベントが後ろに貫通するので1%だけ伸ばす
: `polygon(0% ${guard.childSideTopY}px, 0% ${guard.childSideBottomY}px, ${guard.cursorSideX}px ${guard.cursorSideY}px)`
: 'polygon(0 0, 0 0, 0 0)',
);
function parentMouseMove(ev: MouseEvent) {
if (props.debugDisablePredictionCone) return;
if (isTouchUsing) return;
if (child.value == null || child.value.rootElement == null) return;
ev.stopPropagation();
const itemBounding = (ev.currentTarget as HTMLElement).getBoundingClientRect();
const rootBounding = itemsEl.value!.getBoundingClientRect();
const childBounding = child.value.rootElement.getBoundingClientRect();
const isChildRight = childBounding.left > rootBounding.left;
const CURSOR_SIDE_X_PADDING = 3; // (px)
const CHILD_SIDE_Y_PADDING_BASE = 70; // (px)
const CHILD_SIDE_Y_PADDING_EXTEND = 30; // (px)
const SCALE_FACTOR_COMPUTE_DISTANCE = 300; // コーンの広さが最大になる距離(px)
const localMouseX = ev.clientX - itemBounding.left;
const localMouseY = ev.clientY - rootBounding.top;
const scaleFactor = isChildRight ? Math.min((itemBounding.width - localMouseX), SCALE_FACTOR_COMPUTE_DISTANCE) / SCALE_FACTOR_COMPUTE_DISTANCE : Math.min(localMouseX, SCALE_FACTOR_COMPUTE_DISTANCE) / SCALE_FACTOR_COMPUTE_DISTANCE;
const cursorSideXPadding = isChildRight ? CURSOR_SIDE_X_PADDING : -CURSOR_SIDE_X_PADDING;
const childSideYPadding = CHILD_SIDE_Y_PADDING_BASE + (CHILD_SIDE_Y_PADDING_EXTEND * scaleFactor);
guard.enabled = true;
guard.top = itemsEl.value!.scrollTop;
guard.cursorSideX = localMouseX - cursorSideXPadding;
guard.cursorSideY = localMouseY;
guard.childSideTopY = (childBounding.top - rootBounding.top) - childSideYPadding;
guard.childSideBottomY = (childBounding.bottom - rootBounding.top) + childSideYPadding;
guard.direction = isChildRight ? 'toRight' : 'toLeft';
}
function onMouseLeave() {
guard.enabled = false;
}
function onMouseMove() {
guard.enabled = false;
}
function guardMouseMove(ev: MouseEvent) {
ev.stopPropagation();
}
</script>
<style lang="scss" module>
@@ -592,6 +676,8 @@ onBeforeUnmount(() => {
&:focus-visible:active,
&:focus-visible.active {
color: var(--menuHoverFg, var(--MI_THEME-accent));
position: relative;
z-index: 10; // guardより上にする
&::before {
background-color: var(--menuHoverBg, var(--MI_THEME-accentedBg));
@@ -744,4 +830,20 @@ onBeforeUnmount(() => {
}
}
}
.guard {
position: absolute;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
&.showGuard {
background: #0f04;
&:hover {
background: #f004;
}
}
}
</style>

View File

@@ -4,8 +4,31 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :anchorElement="anchorElement" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed">
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusTo="returnFocusTo" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
<MkModal
ref="modal"
v-slot="{ type, maxHeight }"
:manualShowing="manualShowing"
:zPriority="'high'"
:anchorElement="anchorElement"
:transparentBg="true"
:returnFocusTo="returnFocusTo"
@click="click"
@close="onModalClose"
@closed="onModalClosed"
>
<MkMenu
:items="items"
:align="align"
:width="width"
:max-height="maxHeight"
:asDrawer="type === 'drawer'"
:returnFocusTo="returnFocusTo"
:debugDisablePredictionCone="debugDisablePredictionCone"
:debugShowPredictionCone="debugShowPredictionCone"
:class="{ [$style.drawer]: type === 'drawer' }"
@close="onMenuClose"
@hide="hide"
/>
</MkModal>
</template>
@@ -21,6 +44,8 @@ defineProps<{
width?: number;
anchorElement?: HTMLElement | null;
returnFocusTo?: HTMLElement | null;
debugDisablePredictionCone?: boolean;
debugShowPredictionCone?: boolean;
}>();
const emit = defineEmits<{