diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/launch-pad.vue index 4693df2916..7891f61bf1 100644 --- a/packages/client/src/components/launch-pad.vue +++ b/packages/client/src/components/launch-pad.vue @@ -15,20 +15,6 @@ </MkA> </template> </div> - <div class="sub"> - <button v-click-anime class="_button" @click="help"> - <i class="fas fa-question-circle icon"></i> - <div class="text">{{ $ts.help }}</div> - </button> - <MkA v-click-anime to="/about" @click.passive="close()"> - <i class="fas fa-info-circle icon"></i> - <div class="text">{{ $ts.instanceInfo }}</div> - </MkA> - <MkA v-click-anime to="/about-misskey" @click.passive="close()"> - <img src="/static-assets/favicon.png" class="icon"/> - <div class="text">{{ $ts.aboutMisskey }}</div> - </MkA> - </div> </div> </MkModal> </template> @@ -74,28 +60,6 @@ const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => function close() { modal.close(); } - -function help(ev: MouseEvent) { - os.popupMenu([{ - type: 'link', - to: '/mfm-cheat-sheet', - text: i18n.ts._mfm.cheatSheet, - icon: 'fas fa-code', - }, { - type: 'link', - to: '/scratchpad', - text: i18n.ts.scratchpad, - icon: 'fas fa-terminal', - }, null, { - text: i18n.ts.document, - icon: 'fas fa-question-circle', - action: () => { - window.open('https://misskey-hub.net/help.html', '_blank'); - }, - }], ev.currentTarget ?? ev.target); - - close(); -} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/ui/child-menu.vue b/packages/client/src/components/ui/child-menu.vue new file mode 100644 index 0000000000..a0c26b50cd --- /dev/null +++ b/packages/client/src/components/ui/child-menu.vue @@ -0,0 +1,63 @@ +<template> +<div ref="el" class="sfhdhdhr"> + <MkMenu ref="menu" :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/> +</div> +</template> + +<script lang="ts" setup> +import { on } from 'events'; +import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'; +import MkMenu from './menu.vue'; +import { MenuItem } from '@/types/menu'; +import * as os from '@/os'; + +const props = defineProps<{ + items: MenuItem[]; + targetElement: HTMLElement; + width?: number; + viaKeyboard?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'closed'): void; + (ev: 'actioned'): void; +}>(); + +const el = ref<HTMLElement>(); +const align = 'left'; + +function setPosition() { + const rect = props.targetElement.getBoundingClientRect(); + const left = rect.left + props.targetElement.offsetWidth; + const top = rect.top - 8; + el.value.style.left = left + 'px'; + el.value.style.top = top + 'px'; +} + +function onChildClosed(actioned?: boolean) { + if (actioned) { + emit('actioned'); + } else { + emit('closed'); + } +} + +onMounted(() => { + setPosition(); + nextTick(() => { + setPosition(); + }); +}); + +defineExpose({ + checkHit: (ev: MouseEvent) => { + return (ev.target === el.value || el.value.contains(ev.target)); + }, +}); +</script> + +<style lang="scss" scoped> +.sfhdhdhr { + position: fixed; +} +</style> diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue index 6ad63c2ad7..26283ffe55 100644 --- a/packages/client/src/components/ui/menu.vue +++ b/packages/client/src/components/ui/menu.vue @@ -1,55 +1,67 @@ <template> -<div - ref="itemsEl" v-hotkey="keymap" - class="rrevdjwt" - :class="{ center: align === 'center', asDrawer }" - :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" - @contextmenu.self="e => e.preventDefault()" -> - <template v-for="(item, i) in items2"> - <div v-if="item === null" class="divider"></div> - <span v-else-if="item.type === 'label'" class="label item"> - <span>{{ item.text }}</span> +<div> + <div + ref="itemsEl" v-hotkey="keymap" + class="rrevdjwt _popup _shadow" + :class="{ center: align === 'center', asDrawer }" + :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" + @contextmenu.self="e => e.preventDefault()" + > + <template v-for="(item, i) in items2"> + <div v-if="item === null" class="divider"></div> + <span v-else-if="item.type === 'label'" class="label item"> + <span>{{ item.text }}</span> + </span> + <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item"> + <span><MkEllipsis/></span> + </span> + <MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </MkA> + <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </a> + <button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </button> + <span v-else-if="item.type === 'switch'" :tabindex="i" class="item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch> + </span> + <button v-else-if="item.type === 'parent'" :tabindex="i" class="_button item parent" :class="{ childShowing: childShowingItem === item }" @mouseenter="showChildren(item, $event)"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <span>{{ item.text }}</span> + <span class="caret"><i class="fas fa-caret-right fa-fw"></i></span> + </button> + <button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </button> + </template> + <span v-if="items2.length === 0" class="none item"> + <span>{{ $ts.none }}</span> </span> - <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item"> - <span><MkEllipsis/></span> - </span> - <MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button item" @click.passive="close()"> - <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> - <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> - <span>{{ item.text }}</span> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </MkA> - <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button item" @click="close()"> - <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> - <span>{{ item.text }}</span> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </a> - <button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)"> - <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </button> - <span v-else-if="item.type === 'switch'" :tabindex="i" class="item"> - <FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch> - </span> - <button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)"> - <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> - <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> - <span>{{ item.text }}</span> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </button> - </template> - <span v-if="items2.length === 0" class="none item"> - <span>{{ $ts.none }}</span> - </span> + </div> + <div v-if="childMenu" class="child"> + <XChild ref="child" :items="childMenu" :target-element="childTarget" showing @actioned="childActioned"/> + </div> </div> </template> <script lang="ts" setup> -import { nextTick, onMounted, watch } from 'vue'; +import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, onUnmounted, Ref, ref, watch } from 'vue'; import { focusPrev, focusNext } from '@/scripts/focus'; import FormSwitch from '@/components/form/switch.vue'; import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu'; +import * as os from '@/os'; +const XChild = defineAsyncComponent(() => import('./child-menu.vue')); const props = defineProps<{ items: MenuItem[]; @@ -61,19 +73,23 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'close'): void; + (ev: 'close', actioned?: boolean): void; }>(); let itemsEl = $ref<HTMLDivElement>(); let items2: InnerMenuItem[] = $ref([]); +let child = $ref<InstanceType<typeof XChild>>(); + let keymap = $computed(() => ({ 'up|k|shift+tab': focusUp, 'down|j|tab': focusDown, 'esc': close, })); +let childShowingItem = $ref<MenuItem | null>(); + watch(() => props.items, () => { const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined); @@ -93,21 +109,53 @@ watch(() => props.items, () => { immediate: true, }); -onMounted(() => { - if (props.viaKeyboard) { - nextTick(() => { - focusNext(itemsEl.children[0], true, false); - }); +let childMenu = $ref<MenuItem[] | null>(); +let childTarget = $ref<HTMLElement | null>(); + +function closeChild() { + childMenu = null; + childShowingItem = null; +} + +function childActioned() { + closeChild(); + close(true); +} + +function onGlobalMousedown(event: MouseEvent) { + if (childTarget && (event.target === childTarget || childTarget.contains(event.target))) return; + if (child && child.checkHit(event)) return; + closeChild(); +} + +let childCloseTimer: null | number = null; +function onItemMouseEnter(item) { + childCloseTimer = window.setTimeout(() => { + closeChild(); + }, 300); +} +function onItemMouseLeave(item) { + if (childCloseTimer) window.clearTimeout(childCloseTimer); +} + +async function showChildren(item: MenuItem, ev: MouseEvent) { + if (props.asDrawer) { + os.popupMenu(item.children, ev.currentTarget ?? ev.target); + close(); + } else { + childTarget = ev.currentTarget ?? ev.target; + childMenu = item.children; + childShowingItem = item; } -}); +} function clicked(fn: MenuAction, ev: MouseEvent) { fn(ev); - close(); + close(true); } -function close() { - emit('close'); +function close(actioned = false) { + emit('close', actioned); } function focusUp() { @@ -117,6 +165,20 @@ function focusUp() { function focusDown() { focusNext(document.activeElement); } + +onMounted(() => { + if (props.viaKeyboard) { + nextTick(() => { + focusNext(itemsEl.children[0], true, false); + }); + } + + document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); +}); + +onBeforeUnmount(() => { + document.removeEventListener('mousedown', onGlobalMousedown); +}); </script> <style lang="scss" scoped> @@ -225,6 +287,25 @@ function focusDown() { opacity: 0.7; } + &.parent { + display: flex; + align-items: center; + cursor: default; + + > .caret { + margin-left: auto; + } + + &.childShowing { + color: var(--accent); + text-decoration: none; + + &:before { + background: var(--accentedBg); + } + } + } + > i { margin-right: 5px; width: 20px; diff --git a/packages/client/src/components/ui/popup-menu.vue b/packages/client/src/components/ui/popup-menu.vue index 2bc7030d77..c29aff45e7 100644 --- a/packages/client/src/components/ui/popup-menu.vue +++ b/packages/client/src/components/ui/popup-menu.vue @@ -1,6 +1,6 @@ <template> <MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @closed="emit('closed')"> - <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq _popup _shadow" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/> + <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/> </MkModal> </template> diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue index f81bf2fc5b..4c6258d245 100644 --- a/packages/client/src/components/ui/tooltip.vue +++ b/packages/client/src/components/ui/tooltip.vue @@ -12,6 +12,7 @@ <script lang="ts" setup> import { nextTick, onMounted, onUnmounted, ref } from 'vue'; import * as os from '@/os'; +import { calcPopupPosition } from '@/scripts/popup-position'; const props = withDefaults(defineProps<{ showing: boolean; @@ -36,151 +37,20 @@ const emit = defineEmits<{ const el = ref<HTMLElement>(); const zIndex = os.claimZIndex('high'); -const setPosition = () => { - if (el.value == null) return; +function setPosition() { + const data = calcPopupPosition(el.value, { + anchorElement: props.targetElement, + direction: props.direction, + align: 'center', + innerMargin: props.innerMargin, + x: props.x, + y: props.y, + }); - const contentWidth = el.value.offsetWidth; - const contentHeight = el.value.offsetHeight; - - let rect: DOMRect; - - if (props.targetElement) { - rect = props.targetElement.getBoundingClientRect(); - } - - const calcPosWhenTop = () => { - let left: number; - let top: number; - - if (props.targetElement) { - left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2); - top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin; - } else { - left = props.x; - top = (props.y - contentHeight) - props.innerMargin; - } - - left -= (el.value.offsetWidth / 2); - - if (left + contentWidth - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - contentWidth + window.pageXOffset - 1; - } - - return [left, top]; - }; - - const calcPosWhenBottom = () => { - let left: number; - let top: number; - - if (props.targetElement) { - left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2); - top = (rect.top + window.pageYOffset + props.targetElement.offsetHeight) + props.innerMargin; - } else { - left = props.x; - top = (props.y) + props.innerMargin; - } - - left -= (el.value.offsetWidth / 2); - - if (left + contentWidth - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - contentWidth + window.pageXOffset - 1; - } - - return [left, top]; - }; - - const calcPosWhenLeft = () => { - let left: number; - let top: number; - - if (props.targetElement) { - left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin; - top = rect.top + window.pageYOffset + (props.targetElement.offsetHeight / 2); - } else { - left = (props.x - contentWidth) - props.innerMargin; - top = props.y; - } - - top -= (el.value.offsetHeight / 2); - - if (top + contentHeight - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - contentHeight + window.pageYOffset - 1; - } - - return [left, top]; - }; - - const calcPosWhenRight = () => { - let left: number; - let top: number; - - if (props.targetElement) { - left = (rect.left + props.targetElement.offsetWidth + window.pageXOffset) + props.innerMargin; - top = rect.top + window.pageYOffset + (props.targetElement.offsetHeight / 2); - } else { - left = props.x + props.innerMargin; - top = props.y; - } - - top -= (el.value.offsetHeight / 2); - - if (top + contentHeight - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - contentHeight + window.pageYOffset - 1; - } - - return [left, top]; - }; - - const calc = (): { - left: number; - top: number; - transformOrigin: string; - } => { - switch (props.direction) { - case 'top': { - const [left, top] = calcPosWhenTop(); - - // ツールチップを上に向かって表示するスペースがなければ下に向かって出す - if (top - window.pageYOffset < 0) { - const [left, top] = calcPosWhenBottom(); - return { left, top, transformOrigin: 'center top' }; - } - - return { left, top, transformOrigin: 'center bottom' }; - } - - case 'bottom': { - const [left, top] = calcPosWhenBottom(); - // TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す - return { left, top, transformOrigin: 'center top' }; - } - - case 'left': { - const [left, top] = calcPosWhenLeft(); - - // ツールチップを左に向かって表示するスペースがなければ右に向かって出す - if (left - window.pageXOffset < 0) { - const [left, top] = calcPosWhenRight(); - return { left, top, transformOrigin: 'left center' }; - } - - return { left, top, transformOrigin: 'right center' }; - } - - case 'right': { - const [left, top] = calcPosWhenRight(); - // TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す - return { left, top, transformOrigin: 'left center' }; - } - } - }; - - const { left, top, transformOrigin } = calc(); - el.value.style.transformOrigin = transformOrigin; - el.value.style.left = left + 'px'; - el.value.style.top = top + 'px'; -}; + el.value.style.transformOrigin = data.transformOrigin; + el.value.style.left = data.left + 'px'; + el.value.style.top = data.top + 'px'; +} let loopHandler; diff --git a/packages/client/src/scripts/popup-position.ts b/packages/client/src/scripts/popup-position.ts new file mode 100644 index 0000000000..e84eebf103 --- /dev/null +++ b/packages/client/src/scripts/popup-position.ts @@ -0,0 +1,158 @@ +import { Ref } from 'vue'; + +export function calcPopupPosition(el: HTMLElement, props: { + anchorElement: HTMLElement | null; + innerMargin: number; + direction: 'top' | 'bottom' | 'left' | 'right'; + align: 'top' | 'bottom' | 'left' | 'right' | 'center'; + alignOffset?: number; + x?: number; + y?: number; +}): { top: number; left: number; transformOrigin: string; } { + const contentWidth = el.offsetWidth; + const contentHeight = el.offsetHeight; + + let rect: DOMRect; + + if (props.anchorElement) { + rect = props.anchorElement.getBoundingClientRect(); + } + + const calcPosWhenTop = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin; + } else { + left = props.x; + top = (props.y - contentHeight) - props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenBottom = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin; + } else { + left = props.x; + top = (props.y) + props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenLeft = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin; + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + } else { + left = (props.x - contentWidth) - props.innerMargin; + top = props.y; + } + + top -= (el.offsetHeight / 2); + + if (top + contentHeight - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - contentHeight + window.pageYOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenRight = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin; + + if (props.align === 'top') { + top = rect.top + window.pageYOffset; + if (props.alignOffset != null) top += props.alignOffset; + } else if (props.align === 'bottom') { + // TODO + } else { // center + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + top -= (el.offsetHeight / 2); + } + } else { + left = props.x + props.innerMargin; + top = props.y; + top -= (el.offsetHeight / 2); + } + + if (top + contentHeight - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - contentHeight + window.pageYOffset - 1; + } + + return [left, top]; + }; + + const calc = (): { + left: number; + top: number; + transformOrigin: string; + } => { + switch (props.direction) { + case 'top': { + const [left, top] = calcPosWhenTop(); + + // ツールチップを上に向かって表示するスペースがなければ下に向かって出す + if (top - window.pageYOffset < 0) { + const [left, top] = calcPosWhenBottom(); + return { left, top, transformOrigin: 'center top' }; + } + + return { left, top, transformOrigin: 'center bottom' }; + } + + case 'bottom': { + const [left, top] = calcPosWhenBottom(); + // TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す + return { left, top, transformOrigin: 'center top' }; + } + + case 'left': { + const [left, top] = calcPosWhenLeft(); + + // ツールチップを左に向かって表示するスペースがなければ右に向かって出す + if (left - window.pageXOffset < 0) { + const [left, top] = calcPosWhenRight(); + return { left, top, transformOrigin: 'left center' }; + } + + return { left, top, transformOrigin: 'right center' }; + } + + case 'right': { + const [left, top] = calcPosWhenRight(); + // TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す + return { left, top, transformOrigin: 'left center' }; + } + } + }; + + return calc(); +} diff --git a/packages/client/src/types/menu.ts b/packages/client/src/types/menu.ts index ed67e6ab88..972f6db214 100644 --- a/packages/client/src/types/menu.ts +++ b/packages/client/src/types/menu.ts @@ -11,10 +11,11 @@ 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 MenuPending = { type: 'pending' }; -type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton; -type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton>; +type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; +type OuterPromiseMenuItem = Promise<MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent>; export type MenuItem = OuterMenuItem | OuterPromiseMenuItem; -export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton; +export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; diff --git a/packages/client/src/ui/_common_/common.vue b/packages/client/src/ui/_common_/common.vue index 9f7388db53..f32cd3fe0d 100644 --- a/packages/client/src/ui/_common_/common.vue +++ b/packages/client/src/ui/_common_/common.vue @@ -1,5 +1,6 @@ <template> -<component :is="popup.component" +<component + :is="popup.component" v-for="popup in popups" :key="popup.id" v-bind="popup.props" @@ -15,56 +16,45 @@ <div v-if="dev" id="devTicker"><span>DEV BUILD</span></div> </template> -<script lang="ts"> -import { defineAsyncComponent, defineComponent } from 'vue'; +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { swInject } from './sw-inject'; import { popup, popups, pendingApiRequestsCount } from '@/os'; import { uploads } from '@/scripts/upload'; import * as sound from '@/scripts/sound'; import { $i } from '@/account'; -import { swInject } from './sw-inject'; import { stream } from '@/stream'; -export default defineComponent({ - components: { - XStreamIndicator: defineAsyncComponent(() => import('./stream-indicator.vue')), - XUpload: defineAsyncComponent(() => import('./upload.vue')), - }, +const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); +const XUpload = defineAsyncComponent(() => import('./upload.vue')); - setup() { - const onNotification = notification => { - if ($i.mutingNotificationTypes.includes(notification.type)) return; +const dev = _DEV_; - if (document.visibilityState === 'visible') { - stream.send('readNotification', { - id: notification.id - }); +const onNotification = notification => { + if ($i.mutingNotificationTypes.includes(notification.type)) return; - popup(defineAsyncComponent(() => import('@/components/notification-toast.vue')), { - notification - }, {}, 'closed'); - } + if (document.visibilityState === 'visible') { + stream.send('readNotification', { + id: notification.id, + }); - sound.play('notification'); - }; + popup(defineAsyncComponent(() => import('@/components/notification-toast.vue')), { + notification, + }, {}, 'closed'); + } - if ($i) { - const connection = stream.useChannel('main', null, 'UI'); - connection.on('notification', onNotification); + sound.play('notification'); +}; - //#region Listen message from SW - if ('serviceWorker' in navigator) { - swInject(); - } - } +if ($i) { + const connection = stream.useChannel('main', null, 'UI'); + connection.on('notification', onNotification); - return { - uploads, - popups, - pendingApiRequestsCount, - dev: _DEV_, - }; - }, -}); + //#region Listen message from SW + if ('serviceWorker' in navigator) { + swInject(); + } +} </script> <style lang="scss"> diff --git a/packages/client/src/ui/_common_/navbar-for-mobile.vue b/packages/client/src/ui/_common_/navbar-for-mobile.vue index d1b4c30b31..f2521cfc72 100644 --- a/packages/client/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/client/src/ui/_common_/navbar-for-mobile.vue @@ -87,6 +87,36 @@ function openInstanceMenu(ev: MouseEvent) { text: i18n.ts.federation, icon: 'fas fa-globe', to: '/about#federation', + }, null, { + type: 'parent', + text: i18n.ts.help, + icon: 'fas fa-question-circle', + children: [{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'fas fa-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'fas fa-terminal', + }, { + type: 'link', + to: '/api-console', + text: 'API Console', + icon: 'fas fa-terminal', + }, null, { + text: i18n.ts.document, + icon: 'fas fa-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], + }, { + type: 'link', + text: i18n.ts.aboutMisskey, + to: '/about-misskey', }], ev.currentTarget ?? ev.target, { align: 'left', }); diff --git a/packages/client/src/ui/_common_/navbar.vue b/packages/client/src/ui/_common_/navbar.vue index e18f89113f..7e6065c305 100644 --- a/packages/client/src/ui/_common_/navbar.vue +++ b/packages/client/src/ui/_common_/navbar.vue @@ -110,6 +110,36 @@ function openInstanceMenu(ev: MouseEvent) { text: i18n.ts.federation, icon: 'fas fa-globe', to: '/about#federation', + }, null, { + type: 'parent', + text: i18n.ts.help, + icon: 'fas fa-question-circle', + children: [{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'fas fa-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'fas fa-terminal', + }, { + type: 'link', + to: '/api-console', + text: 'API Console', + icon: 'fas fa-terminal', + }, null, { + text: i18n.ts.document, + icon: 'fas fa-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], + }, { + type: 'link', + text: i18n.ts.aboutMisskey, + to: '/about-misskey', }], ev.currentTarget ?? ev.target, { align: 'left', });