diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-05-03 10:26:40 +0900 |
|---|---|---|
| committer | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-05-03 10:26:40 +0900 |
| commit | df1a3742dd27c80220692f78edb201f17a6bfc58 (patch) | |
| tree | 4899aba196518d38a0dd28ae61187265c3cca4a8 | |
| parent | perf(frontend): improve timeline page performance (diff) | |
| download | misskey-df1a3742dd27c80220692f78edb201f17a6bfc58.tar.gz misskey-df1a3742dd27c80220692f78edb201f17a6bfc58.tar.bz2 misskey-df1a3742dd27c80220692f78edb201f17a6bfc58.zip | |
feat(frontend): マウスでもタイムラインを引っ張って更新できるように & MkPullToRefreshのパフォーマンス向上
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | locales/index.d.ts | 4 | ||||
| -rw-r--r-- | locales/ja-JP.yml | 1 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkNotifications.vue | 4 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPullToRefresh.vue | 167 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkTimeline.vue | 7 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/preferences.vue | 11 | ||||
| -rw-r--r-- | packages/frontend/src/preferences/def.ts | 3 |
8 files changed, 109 insertions, 92 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index aba43b9e3a..92c3fada72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ - ### Client -- +- Feat: マウスでもタイムラインを引っ張って更新できるように + - アクセシビリティ設定からオフにすることもできます +- Enhance: タイムラインのパフォーマンスを向上 ### Server - Enhance: 凍結されたユーザのノートが各種タイムラインで表示されないように `#15775` diff --git a/locales/index.d.ts b/locales/index.d.ts index 08b1624e25..f04ec26828 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5709,6 +5709,10 @@ export interface Locale extends ILocale { * デバイス間でインストールしたテーマを同期 */ "enableSyncThemesBetweenDevices": string; + /** + * ひっぱって更新 + */ + "enablePullToRefresh": string; "_chat": { /** * 送信者の名前を表示 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d962369799..11f586e98b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1427,6 +1427,7 @@ _settings: ifOn: "オンのとき" ifOff: "オフのとき" enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期" + enablePullToRefresh: "ひっぱって更新" _chat: showSenderName: "送信者の名前を表示" diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index b8fada1020..177ae0219c 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPullToRefresh :refresher="() => reload()"> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()"> <MkPagination ref="pagingComponent" :pagination="pagination"> <template #empty> <div class="_fullinfo"> @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </component> </template> </MkPagination> -</MkPullToRefresh> +</component> </template> <script lang="ts" setup> diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 22ae563d13..ad828c50b6 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -5,12 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div ref="rootEl"> - <div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`"> + <div v-if="isPulling" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`"> <div :class="$style.frameContent"> <MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/> - <i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i> + <i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPulledEnough }]"></i> <div :class="$style.text"> - <template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template> + <template v-if="isPulledEnough">{{ i18n.ts.releaseToRefresh }}</template> <template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template> <template v-else>{{ i18n.ts.pullDownToRefresh }}</template> </div> @@ -34,19 +34,16 @@ const RELEASE_TRANSITION_DURATION = 200; const PULL_BRAKE_BASE = 1.5; const PULL_BRAKE_FACTOR = 170; -const isPullStart = ref(false); -const isPullEnd = ref(false); +const isPulling = ref(false); +const isPulledEnough = ref(false); const isRefreshing = ref(false); const pullDistance = ref(0); -let supportPointerDesktop = false; let startScreenY: number | null = null; const rootEl = useTemplateRef('rootEl'); let scrollEl: HTMLElement | null = null; -let disabled = false; - const props = withDefaults(defineProps<{ refresher: () => Promise<void>; }>(), { @@ -57,18 +54,51 @@ const emit = defineEmits<{ (ev: 'refresh'): void; }>(); -function getScreenY(event) { - if (supportPointerDesktop) { +function getScreenY(event: TouchEvent | MouseEvent | PointerEvent): number { + if (event.touches && event.touches[0] && event.touches[0].screenY != null) { + return event.touches[0].screenY; + } else { return event.screenY; } - return event.touches[0].screenY; } -function moveStart(event) { - if (!isPullStart.value && !isRefreshing.value && !disabled) { - isPullStart.value = true; - startScreenY = getScreenY(event); - pullDistance.value = 0; +// When at the top of the page, disable vertical overscroll so passive touch listeners can take over. +function lockDownScroll() { + scrollEl!.style.touchAction = 'pan-x pan-down pinch-zoom'; + scrollEl!.style.overscrollBehavior = 'none'; +} + +function unlockDownScroll() { + scrollEl!.style.touchAction = 'auto'; + scrollEl!.style.overscrollBehavior = 'contain'; +} + +function moveStart(event: PointerEvent) { + const scrollPos = scrollEl!.scrollTop; + if (scrollPos === 0) { + lockDownScroll(); + if (!isPulling.value && !isRefreshing.value) { + isPulling.value = true; + startScreenY = getScreenY(event); + pullDistance.value = 0; + + // タッチデバイスでPointerEventを使うとなんか挙動がおかしいので、TouchEventとMouseEventを使い分ける + if (event.pointerType === 'mouse') { + window.addEventListener('mousemove', moving, { passive: true }); + window.addEventListener('mouseup', () => { + window.removeEventListener('mousemove', moving); + onPullRelease(); + }, { passive: true, once: true }); + } else { + window.addEventListener('touchmove', moving, { passive: true }); + window.addEventListener('touchend', () => { + window.removeEventListener('touchmove', moving); + onPullRelease(); + }, { passive: true, once: true }); + } + } + } else { + unlockDownScroll(); } } @@ -108,31 +138,39 @@ async function closeContent() { } } -function moveEnd() { - if (isPullStart.value && !isRefreshing.value) { - startScreenY = null; - if (isPullEnd.value) { - isPullEnd.value = false; - isRefreshing.value = true; - fixOverContent().then(() => { - emit('refresh'); - props.refresher().then(() => { - refreshFinished(); - }); +function onPullRelease() { + window.document.body.removeAttribute('inert'); + startScreenY = null; + if (isPulledEnough.value) { + isPulledEnough.value = false; + isRefreshing.value = true; + fixOverContent().then(() => { + emit('refresh'); + props.refresher().then(() => { + refreshFinished(); }); - } else { - closeContent().then(() => isPullStart.value = false); - } + }); + } else { + closeContent().then(() => isPulling.value = false); } } -function moving(event: TouchEvent | PointerEvent) { - if (!isPullStart.value || isRefreshing.value || disabled) return; +function toggleScrollLockOnTouchEnd() { + const scrollPos = scrollEl!.scrollTop; + if (scrollPos === 0) { + lockDownScroll(); + } else { + unlockDownScroll(); + } +} - if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) { +function moving(event: MouseEvent | TouchEvent) { + if (!isPulling.value || isRefreshing.value) return; + + if ((scrollEl?.scrollTop ?? 0) > SCROLL_STOP + pullDistance.value || isHorizontalSwipeSwiping.value) { pullDistance.value = 0; - isPullEnd.value = false; - moveEnd(); + isPulledEnough.value = false; + onPullRelease(); return; } @@ -144,15 +182,12 @@ function moving(event: TouchEvent | PointerEvent) { const moveHeight = moveScreenY - startScreenY!; pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); - if (pullDistance.value > 0) { - if (event.cancelable) event.preventDefault(); + // マウスでのpull時、画面上のテキスト選択が発生して画面がスクロールされたりするのを防ぐ + if (pullDistance.value > 3) { // ある程度遊びを持たせないと通常のクリックでも発火しクリックできなくなる + window.document.body.setAttribute('inert', 'true'); } - if (pullDistance.value > SCROLL_STOP) { - event.stopPropagation(); - } - - isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD; + isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD; } /** @@ -162,61 +197,23 @@ function moving(event: TouchEvent | PointerEvent) { */ function refreshFinished() { closeContent().then(() => { - isPullStart.value = false; + isPulling.value = false; isRefreshing.value = false; }); } -function setDisabled(value) { - disabled = value; -} - -function onScrollContainerScroll() { - const scrollPos = scrollEl!.scrollTop; - - // When at the top of the page, disable vertical overscroll so passive touch listeners can take over. - if (scrollPos === 0) { - scrollEl!.style.touchAction = 'pan-x pan-down pinch-zoom'; - registerEventListenersForReadyToPull(); - } else { - scrollEl!.style.touchAction = 'auto'; - unregisterEventListenersForReadyToPull(); - } -} - -function registerEventListenersForReadyToPull() { - if (rootEl.value == null) return; - rootEl.value.addEventListener('touchstart', moveStart, { passive: true }); - rootEl.value.addEventListener('touchmove', moving, { passive: false }); // passive: falseにしないとpreventDefaultが使えない -} - -function unregisterEventListenersForReadyToPull() { - if (rootEl.value == null) return; - rootEl.value.removeEventListener('touchstart', moveStart); - rootEl.value.removeEventListener('touchmove', moving); -} - onMounted(() => { if (rootEl.value == null) return; scrollEl = getScrollContainer(rootEl.value); - if (scrollEl == null) return; - scrollEl.addEventListener('scroll', onScrollContainerScroll, { passive: true }); - - rootEl.value.addEventListener('touchend', moveEnd, { passive: true }); - - registerEventListenersForReadyToPull(); + rootEl.value.addEventListener('pointerdown', moveStart, { passive: true }); + rootEl.value.addEventListener('touchend', toggleScrollLockOnTouchEnd, { passive: true }); }); onUnmounted(() => { - if (scrollEl) scrollEl.removeEventListener('scroll', onScrollContainerScroll); - - unregisterEventListenersForReadyToPull(); -}); - -defineExpose({ - setDisabled, + rootEl.value.removeEventListener('pointerdown', moveStart); + rootEl.value.removeEventListener('touchend', toggleScrollLockOnTouchEnd); }); </script> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 8ca690f2ce..bbf1a03c32 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()"> - <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)"> +<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()"> + <MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)"> <template #empty> <div class="_fullinfo"> <img :src="infoImageUrl" draggable="false"/> @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only </component> </template> </MkPagination> -</MkPullToRefresh> +</component> </template> <script lang="ts" setup> @@ -93,7 +93,6 @@ type TimelineQueryType = { roleId?: string }; -const prComponent = useTemplateRef('prComponent'); const pagingComponent = useTemplateRef('pagingComponent'); let tlNotesCount = 0; diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 57b140f97b..758bbc13b3 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -471,6 +471,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPreferenceContainer> </SearchMarker> + <SearchMarker :keywords="['swipe', 'pull', 'refresh']"> + <MkPreferenceContainer k="enablePullToRefresh"> + <MkSwitch v-model="enablePullToRefresh"> + <template #label><SearchLabel>{{ i18n.ts._settings.enablePullToRefresh }}</SearchLabel></template> + </MkSwitch> + </MkPreferenceContainer> + </SearchMarker> + <SearchMarker :keywords="['keep', 'screen', 'display', 'on']"> <MkPreferenceContainer k="keepScreenOn"> <MkSwitch v-model="keepScreenOn"> @@ -800,6 +808,7 @@ const animatedMfm = prefer.model('animatedMfm'); const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages'); const keepScreenOn = prefer.model('keepScreenOn'); const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe'); +const enablePullToRefresh = prefer.model('enablePullToRefresh'); const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer'); const contextMenu = prefer.model('contextMenu'); const menuStyle = prefer.model('menuStyle'); @@ -857,6 +866,8 @@ watch([ fontSize, useSystemFont, makeEveryTextElementsSelectable, + enableHorizontalSwipe, + enablePullToRefresh, ], async () => { await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 73c6ff96c9..37c7098511 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -300,6 +300,9 @@ export const PREF_DEF = { enableHorizontalSwipe: { default: true, }, + enablePullToRefresh: { + default: true, + }, useNativeUiForVideoAudioPlayer: { default: false, }, |