From 9349f72227a96eb0e70404fa5bfea7044d3231a2 Mon Sep 17 00:00:00 2001 From: tamaina <tamaina@hotmail.co.jp> Date: Sat, 11 Feb 2023 16:04:45 +0900 Subject: [PATCH] refactor(client): Refactor MkPageHeader #9869 (#9878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * disable animation * refactor(client): MkPageHeaderのタブをMkPageHeader.tabsに分離 animationをフォローするように * update CHANGELOG.md * remove unnecessary props --- CHANGELOG.md | 7 + .../components/global/MkPageHeader.tabs.vue | 218 ++++++++++++++++++ .../src/components/global/MkPageHeader.vue | 200 +--------------- packages/frontend/src/pages/timeline.vue | 7 +- 4 files changed, 239 insertions(+), 193 deletions(-) create mode 100644 packages/frontend/src/components/global/MkPageHeader.tabs.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b650e7de9..097195d531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ You should also include the user name that made the change. --> +## 13.x.x (unreleased) + +### Improvements +- アニメーションを少なくする設定の時、MkPageHeaderのタブアニメーションを無効化 + +### Bugfixes +- ## 13.6.0 (2023/02/11) diff --git a/packages/frontend/src/components/global/MkPageHeader.tabs.vue b/packages/frontend/src/components/global/MkPageHeader.tabs.vue new file mode 100644 index 0000000000..9b19c5dc87 --- /dev/null +++ b/packages/frontend/src/components/global/MkPageHeader.tabs.vue @@ -0,0 +1,218 @@ +<template> + <div ref="el" :class="$style.tabs" @wheel="onTabWheel"> + <div :class="$style.tabsInner"> + <button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" + class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]" + @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"> + <div :class="$style.tabInner"> + <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> + <div v-if="!t.iconOnly || (!defaultStore.reactiveState.animation.value && t.key === tab)" + :class="$style.tabTitle">{{ t.title }}</div> + <Transition v-else @enter="enter" @after-enter="afterEnter" @leave="leave" @after-leave="afterLeave" + mode="in-out"> + <div v-if="t.key === tab" :class="$style.tabTitle">{{ t.title }}</div> + </Transition> + </div> + </button> + </div> + <div ref="tabHighlightEl" + :class="[$style.tabHighlight, { [$style.animate]: defaultStore.reactiveState.animation.value }]"></div> + </div> +</template> + +<script lang="ts"> +export type Tab = { + key: string; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +} & { + iconOnly: true; + iccn: string; +}; +</script> + +<script lang="ts" setup> +import { onMounted, onUnmounted, watch, nextTick } from 'vue'; +import { defaultStore } from '@/store'; + +const props = withDefaults(defineProps<{ + tabs?: Tab[]; + tab?: string; + rootEl?: HTMLElement; +}>(), { + tabs: () => ([] as Tab[]), +}); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); + (ev: 'tabClick', key: string); +}>(); + +let el = $shallowRef<HTMLElement | null>(null); +const tabRefs: Record<string, HTMLElement | null> = {}; +let tabHighlightEl = $shallowRef<HTMLElement | null>(null); + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(t: Tab, ev: MouseEvent): void { + emit('tabClick', t.key); + + if (t.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + t.onClick(ev); + } + + if (t.key) { + emit('update:tab', t.key); + } +} + +function renderTab() { + const tabEl = props.tab ? tabRefs[props.tab] : undefined; + if (tabEl && tabHighlightEl && tabHighlightEl.parentElement) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabHighlightEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left + tabHighlightEl.parentElement.scrollLeft) + 'px'; + } +} + +function onTabWheel(ev: WheelEvent) { + if (ev.deltaY !== 0 && ev.deltaX === 0) { + ev.preventDefault(); + ev.stopPropagation(); + (ev.currentTarget as HTMLElement).scrollBy({ + left: ev.deltaY, + behavior: 'smooth', + }); + } + return false; +} + +function enter(el: HTMLElement) { + const elementWidth = el.getBoundingClientRect().width; + el.style.width = '0'; + el.offsetWidth; // reflow + el.style.width = elementWidth + 'px'; + setTimeout(renderTab, 70); +} +function afterEnter(el: HTMLElement) { + el.style.width = ''; + nextTick(renderTab); +} +function leave(el: HTMLElement) { + const elementWidth = el.getBoundingClientRect().width; + el.style.width = elementWidth + 'px'; + el.offsetWidth; // reflow + el.style.width = '0'; +} +function afterLeave(el: HTMLElement) { + el.style.width = ''; +} + +let ro2: ResizeObserver | null; + +onMounted(() => { + watch([() => props.tab, () => props.tabs], () => { + nextTick(() => renderTab()); + }, { + immediate: true, + }); + + if (props.rootEl) { + ro2 = new ResizeObserver((entries, observer) => { + if (document.body.contains(el as HTMLElement)) { + nextTick(() => renderTab()); + } + }); + ro2.observe(props.rootEl); + } +}); + +onUnmounted(() => { + if (ro2) ro2.disconnect(); +}); +</script> + +<style lang="scss" module> +.tabs { + display: block; + position: relative; + margin: 0; + height: var(--height); + font-size: 0.8em; + text-align: center; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.tabsInner { + display: inline-block; + height: var(--height); + white-space: nowrap; +} + +.tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + &.animate { + transition: opacity 0.2s ease; + } +} + +.tabInner { + display: flex; + align-items: center; +} + +.tabIcon+.tabTitle { + margin-left: 8px; +} + +.tabTitle { + overflow: hidden; + transition: width 0.15s ease-in-out; +} + +.tabHighlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--accent); + border-radius: 999px; + transition: none; + pointer-events: none; + + &.animate { + transition: width 0.15s ease, left 0.15s ease; + } +} +</style> diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 23a39b9ac9..d39fcde1b5 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -19,27 +19,7 @@ </div> </div> </div> - <div v-if="!narrow || hideTitle" :class="$style.tabs" @wheel="onTabWheel"> - <div :class="$style.tabsInner"> - <button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab }]" @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"> - <div :class="$style.tabInner"> - <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> - <div v-if="!t.iconOnly" :class="$style.tabTitle">{{ t.title }}</div> - <Transition - v-else - @enter="enter" - @after-enter="afterEnter" - @leave="leave" - @after-leave="afterLeave" - mode="in-out" - > - <div v-if="t.key === tab" :class="$style.tabTitle">{{ t.title }}</div> - </Transition> - </div> - </button> - </div> - <div ref="tabHighlightEl" :class="$style.tabHighlight"></div> - </div> + <XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" @update:tab="key => emit('update:tab', key)" :tabs="tabs" :root-el="el" @tab-click="onTabClick"/> </template> <div v-if="(narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight"> <template v-for="action in actions"> @@ -48,34 +28,19 @@ </div> </div> <div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> - <div :class="$style.tabs" @wheel="onTabWheel"> - <div :class="$style.tabsInner"> - <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="_button" :class="[$style.tab, { [$style.active]: tab.key != null && tab.key === props.tab }]" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> - <i v-if="tab.icon" :class="[$style.tabIcon, tab.icon]"></i> - <span v-if="!tab.iconOnly" :class="$style.tabTitle">{{ tab.title }}</span> - </button> - </div> - <div ref="tabHighlightEl" :class="$style.tabHighlight"></div> - </div> + <XTabs :class="$style.tabs" :tab="tab" @update:tab="key => emit('update:tab', key)" :tabs="tabs" :root-el="el" @tab-click="onTabClick"/> </div> </div> </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; +import { onMounted, onUnmounted, ref, inject } from 'vue'; import tinycolor from 'tinycolor2'; import { scrollToTop } from '@/scripts/scroll'; import { globalEvents } from '@/events'; import { injectPageMetadata } from '@/scripts/page-metadata'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; - -type Tab = { - key: string; - title: string; - icon?: string; - iconOnly?: boolean; - onClick?: (ev: MouseEvent) => void; -}; +import XTabs, { Tab } from './MkPageHeader.tabs.vue' const props = withDefaults(defineProps<{ tabs?: Tab[]; @@ -102,8 +67,6 @@ const hideTitle = inject('shouldOmitHeaderTitle', false); const thin_ = props.thin || inject('shouldHeaderThin', false); let el = $shallowRef<HTMLElement | undefined>(undefined); -const tabRefs: Record<string, HTMLElement | null> = {}; -let tabHighlightEl = $shallowRef<HTMLElement | null>(null); const bg = ref<string | undefined>(undefined); let narrow = $ref(false); const hasTabs = $computed(() => props.tabs.length > 0); @@ -128,25 +91,8 @@ function openAccountMenu(ev: MouseEvent) { }, ev); } -function onTabMousedown(tab: Tab, ev: MouseEvent): void { - // ユーザビリティの観点からmousedown時にはonClickは呼ばない - if (tab.key) { - emit('update:tab', tab.key); - } -} - -function onTabClick(t: Tab, ev: MouseEvent): void { - if (t.key === props.tab) { - top(); - } else if (t.onClick) { - ev.preventDefault(); - ev.stopPropagation(); - t.onClick(ev); - } - - if (t.key) { - emit('update:tab', t.key); - } +function onTabClick(): void { + top(); } const calcBg = () => { @@ -156,88 +102,26 @@ const calcBg = () => { bg.value = tinyBg.toRgbString(); }; -let ro1: ResizeObserver | null; -let ro2: ResizeObserver | null; - -function renderTab() { - const tabEl = props.tab ? tabRefs[props.tab] : undefined; - if (tabEl && tabHighlightEl && tabHighlightEl.parentElement) { - // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある - // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 - const parentRect = tabHighlightEl.parentElement.getBoundingClientRect(); - const rect = tabEl.getBoundingClientRect(); - tabHighlightEl.style.width = rect.width + 'px'; - tabHighlightEl.style.left = (rect.left - parentRect.left + tabHighlightEl.parentElement.scrollLeft) + 'px'; - } -} - -function onTabWheel(ev: WheelEvent) { - if (ev.deltaY !== 0 && ev.deltaX === 0) { - ev.preventDefault(); - ev.stopPropagation(); - (ev.currentTarget as HTMLElement).scrollBy({ - left: ev.deltaY, - behavior: 'smooth', - }); - } - return false; -} - -function enter(el: HTMLElement) { - const elementWidth = el.getBoundingClientRect().width; - el.style.width = '0'; - el.offsetWidth; // reflow - el.style.width = elementWidth + 'px'; - setTimeout(renderTab, 70); -} -function afterEnter(el: HTMLElement) { - el.style.width = ''; - nextTick(renderTab); -} -function leave(el: HTMLElement) { - const elementWidth = el.getBoundingClientRect().width; - el.style.width = elementWidth + 'px'; - el.offsetWidth; // reflow - el.style.width = '0'; -} -function afterLeave(el: HTMLElement) { - el.style.width = ''; -} +let ro: ResizeObserver | null; onMounted(() => { calcBg(); globalEvents.on('themeChanged', calcBg); - watch([() => props.tab, () => props.tabs], () => { - nextTick(() => renderTab()); - }, { - immediate: true, - }); - if (el && el.parentElement) { narrow = el.parentElement.offsetWidth < 500; - ro1 = new ResizeObserver((entries, observer) => { + ro = new ResizeObserver((entries, observer) => { if (el && el.parentElement && document.body.contains(el as HTMLElement)) { narrow = el.parentElement.offsetWidth < 500; } }); - ro1.observe(el.parentElement as HTMLElement); - } - - if (el) { - ro2 = new ResizeObserver((entries, observer) => { - if (document.body.contains(el as HTMLElement)) { - nextTick(() => renderTab()); - } - }); - ro2.observe(el); + ro.observe(el.parentElement as HTMLElement); } }); onUnmounted(() => { globalEvents.off('themeChanged', calcBg); - if (ro1) ro1.disconnect(); - if (ro2) ro2.disconnect(); + if (ro) ro.disconnect(); }); </script> @@ -418,68 +302,4 @@ onUnmounted(() => { } } } - -.tabs { - display: block; - position: relative; - margin: 0; - height: var(--height); - font-size: 0.8em; - text-align: center; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } -} - -.tabsInner { - display: inline-block; - height: var(--height); - white-space: nowrap; -} - -.tab { - display: inline-block; - position: relative; - padding: 0 10px; - height: 100%; - font-weight: normal; - opacity: 0.7; - transition: opacity 0.2s ease; - - &:hover { - opacity: 1; - } - - &.active { - opacity: 1; - } -} - -.tabInner { - display: flex; - align-items: center; -} - -.tabIcon + .tabTitle { - margin-left: 8px; -} - -.tabTitle { - overflow: hidden; - transition: width 0.15s ease-in-out; -} - -.tabHighlight { - position: absolute; - bottom: 0; - height: 3px; - background: var(--accent); - border-radius: 999px; - transition: width 0.15s ease, left 0.15s ease; - pointer-events: none; -} </style> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 31f4793dc4..a071361150 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -32,6 +32,7 @@ import { i18n } from '@/i18n'; import { instance } from '@/instance'; import { $i } from '@/account'; import { definePageMetadata } from '@/scripts/page-metadata'; +import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; provide('shouldOmitHeaderTitle', true); @@ -57,7 +58,7 @@ function queueUpdated(q: number): void { } function top(): void { - scroll(rootEl, { top: 0 }); + if (rootEl) scroll(rootEl, { top: 0 }); } async function chooseList(ev: MouseEvent): Promise<void> { @@ -150,7 +151,7 @@ const headerTabs = $computed(() => [{ title: i18n.ts.channel, iconOnly: true, onClick: chooseChannel, -}]); +}] as Tab[]); const headerTabsWhenNotLogin = $computed(() => [ ...(isLocalTimelineAvailable ? [{ @@ -165,7 +166,7 @@ const headerTabsWhenNotLogin = $computed(() => [ icon: 'ti ti-whirl', iconOnly: true, }] : []), -]); +] as Tab[]); definePageMetadata(computed(() => ({ title: i18n.ts.timeline,