2024-01-18 10:21:33 +01:00
|
|
|
<!--
|
|
|
|
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
-->
|
|
|
|
|
|
|
|
<template>
|
|
|
|
<div
|
|
|
|
ref="rootEl"
|
2024-01-24 01:22:51 +01:00
|
|
|
:class="[$style.transitionRoot, { [$style.enableAnimation]: shouldAnimate }]"
|
2024-01-21 10:08:49 +01:00
|
|
|
@touchstart.passive="touchStart"
|
|
|
|
@touchmove.passive="touchMove"
|
|
|
|
@touchend.passive="touchEnd"
|
2024-01-18 10:21:33 +01:00
|
|
|
>
|
|
|
|
<Transition
|
|
|
|
:class="[$style.transitionChildren, { [$style.swiping]: isSwipingForClass }]"
|
|
|
|
:enterActiveClass="$style.swipeAnimation_enterActive"
|
|
|
|
:leaveActiveClass="$style.swipeAnimation_leaveActive"
|
|
|
|
:enterFromClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_enterFrom : $style.swipeAnimationRight_enterFrom"
|
|
|
|
:leaveToClass="transitionName === 'swipeAnimationLeft' ? $style.swipeAnimationLeft_leaveTo : $style.swipeAnimationRight_leaveTo"
|
|
|
|
:style="`--swipe: ${pullDistance}px;`"
|
|
|
|
>
|
|
|
|
<!-- 【注意】slot内の最上位要素に動的にkeyを設定すること -->
|
|
|
|
<!-- 各最上位要素にユニークなkeyの指定がないとTransitionがうまく動きません -->
|
|
|
|
<slot></slot>
|
|
|
|
</Transition>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
|
|
import { ref, shallowRef, computed, nextTick, watch } from 'vue';
|
|
|
|
import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
|
|
|
|
import { defaultStore } from '@/store.js';
|
2024-02-07 12:02:29 +01:00
|
|
|
import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js';
|
2024-01-18 10:21:33 +01:00
|
|
|
|
|
|
|
const rootEl = shallowRef<HTMLDivElement>();
|
|
|
|
|
|
|
|
// eslint-disable-next-line no-undef
|
|
|
|
const tabModel = defineModel<string>('tab');
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
tabs: Tab[];
|
|
|
|
}>();
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
(ev: 'swiped', newKey: string, direction: 'left' | 'right'): void;
|
|
|
|
}>();
|
|
|
|
|
2024-01-24 01:22:51 +01:00
|
|
|
const shouldAnimate = computed(() => defaultStore.reactiveState.enableHorizontalSwipe.value || defaultStore.reactiveState.animation.value);
|
|
|
|
|
2024-01-18 10:21:33 +01:00
|
|
|
// ▼ しきい値 ▼ //
|
|
|
|
|
|
|
|
// スワイプと判定される最小の距離
|
2024-02-07 12:02:29 +01:00
|
|
|
const MIN_SWIPE_DISTANCE = 20;
|
2024-01-18 10:21:33 +01:00
|
|
|
|
|
|
|
// スワイプ時の動作を発火する最小の距離
|
2024-02-07 12:02:29 +01:00
|
|
|
const SWIPE_DISTANCE_THRESHOLD = 70;
|
2024-01-18 10:21:33 +01:00
|
|
|
|
|
|
|
// スワイプを中断するY方向の移動距離
|
|
|
|
const SWIPE_ABORT_Y_THRESHOLD = 75;
|
|
|
|
|
|
|
|
// スワイプできる最大の距離
|
2024-02-07 12:02:29 +01:00
|
|
|
const MAX_SWIPE_DISTANCE = 120;
|
2024-01-18 10:21:33 +01:00
|
|
|
|
|
|
|
// ▲ しきい値 ▲ //
|
|
|
|
|
|
|
|
let startScreenX: number | null = null;
|
|
|
|
let startScreenY: number | null = null;
|
|
|
|
|
|
|
|
const currentTabIndex = computed(() => props.tabs.findIndex(tab => tab.key === tabModel.value));
|
|
|
|
|
|
|
|
const pullDistance = ref(0);
|
|
|
|
const isSwipingForClass = ref(false);
|
|
|
|
let swipeAborted = false;
|
|
|
|
|
|
|
|
function touchStart(event: TouchEvent) {
|
|
|
|
if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
|
|
|
|
|
|
|
|
if (event.touches.length !== 1) return;
|
|
|
|
|
2024-02-07 12:02:29 +01:00
|
|
|
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
|
|
|
|
|
2024-01-18 10:21:33 +01:00
|
|
|
startScreenX = event.touches[0].screenX;
|
|
|
|
startScreenY = event.touches[0].screenY;
|
|
|
|
}
|
|
|
|
|
|
|
|
function touchMove(event: TouchEvent) {
|
|
|
|
if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
|
|
|
|
|
|
|
|
if (event.touches.length !== 1) return;
|
|
|
|
|
|
|
|
if (startScreenX == null || startScreenY == null) return;
|
|
|
|
|
|
|
|
if (swipeAborted) return;
|
|
|
|
|
2024-02-07 12:02:29 +01:00
|
|
|
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
|
|
|
|
|
2024-01-18 10:21:33 +01:00
|
|
|
let distanceX = event.touches[0].screenX - startScreenX;
|
|
|
|
let distanceY = event.touches[0].screenY - startScreenY;
|
|
|
|
|
|
|
|
if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) {
|
|
|
|
swipeAborted = true;
|
|
|
|
|
|
|
|
pullDistance.value = 0;
|
|
|
|
isSwiping.value = false;
|
|
|
|
setTimeout(() => {
|
|
|
|
isSwipingForClass.value = false;
|
|
|
|
}, 400);
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (Math.abs(distanceX) < MIN_SWIPE_DISTANCE) return;
|
|
|
|
if (Math.abs(distanceX) > MAX_SWIPE_DISTANCE) return;
|
|
|
|
|
|
|
|
if (currentTabIndex.value === 0 || props.tabs[currentTabIndex.value - 1].onClick) {
|
|
|
|
distanceX = Math.min(distanceX, 0);
|
|
|
|
}
|
|
|
|
if (currentTabIndex.value === props.tabs.length - 1 || props.tabs[currentTabIndex.value + 1].onClick) {
|
|
|
|
distanceX = Math.max(distanceX, 0);
|
|
|
|
}
|
|
|
|
if (distanceX === 0) return;
|
|
|
|
|
|
|
|
isSwiping.value = true;
|
|
|
|
isSwipingForClass.value = true;
|
|
|
|
nextTick(() => {
|
|
|
|
// グリッチを控えるため、1.5px以上の差がないと更新しない
|
|
|
|
if (Math.abs(distanceX - pullDistance.value) < 1.5) return;
|
|
|
|
pullDistance.value = distanceX;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function touchEnd(event: TouchEvent) {
|
|
|
|
if (swipeAborted) {
|
|
|
|
swipeAborted = false;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!defaultStore.reactiveState.enableHorizontalSwipe.value) return;
|
|
|
|
|
|
|
|
if (event.touches.length !== 0) return;
|
|
|
|
|
|
|
|
if (startScreenX == null) return;
|
|
|
|
|
|
|
|
if (!isSwiping.value) return;
|
|
|
|
|
2024-02-07 12:02:29 +01:00
|
|
|
if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
|
|
|
|
|
2024-01-18 10:21:33 +01:00
|
|
|
const distance = event.changedTouches[0].screenX - startScreenX;
|
|
|
|
|
|
|
|
if (Math.abs(distance) > SWIPE_DISTANCE_THRESHOLD) {
|
|
|
|
if (distance > 0) {
|
|
|
|
if (props.tabs[currentTabIndex.value - 1] && !props.tabs[currentTabIndex.value - 1].onClick) {
|
|
|
|
tabModel.value = props.tabs[currentTabIndex.value - 1].key;
|
|
|
|
emit('swiped', props.tabs[currentTabIndex.value - 1].key, 'right');
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (props.tabs[currentTabIndex.value + 1] && !props.tabs[currentTabIndex.value + 1].onClick) {
|
|
|
|
tabModel.value = props.tabs[currentTabIndex.value + 1].key;
|
|
|
|
emit('swiped', props.tabs[currentTabIndex.value + 1].key, 'left');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
pullDistance.value = 0;
|
|
|
|
isSwiping.value = false;
|
2024-01-21 10:08:49 +01:00
|
|
|
window.setTimeout(() => {
|
2024-01-18 10:21:33 +01:00
|
|
|
isSwipingForClass.value = false;
|
|
|
|
}, 400);
|
|
|
|
}
|
|
|
|
|
2024-02-07 12:02:29 +01:00
|
|
|
/** 横スワイプに関与する可能性のある要素を調べる */
|
|
|
|
function hasSomethingToDoWithXSwipe(el: HTMLElement) {
|
|
|
|
if (['INPUT', 'TEXTAREA'].includes(el.tagName)) return true;
|
|
|
|
if (el.isContentEditable) return true;
|
|
|
|
if (el.scrollWidth > el.clientWidth) return true;
|
|
|
|
|
|
|
|
const style = window.getComputedStyle(el);
|
|
|
|
if (['absolute', 'fixed', 'sticky'].includes(style.position)) return true;
|
|
|
|
if (['scroll', 'auto'].includes(style.overflowX)) return true;
|
|
|
|
if (style.touchAction === 'pan-x') return true;
|
|
|
|
|
|
|
|
if (el.parentElement && el.parentElement !== rootEl.value) {
|
|
|
|
return hasSomethingToDoWithXSwipe(el.parentElement);
|
|
|
|
} else {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-18 10:21:33 +01:00
|
|
|
const transitionName = ref<'swipeAnimationLeft' | 'swipeAnimationRight' | undefined>(undefined);
|
|
|
|
|
|
|
|
watch(tabModel, (newTab, oldTab) => {
|
|
|
|
const newIndex = props.tabs.findIndex(tab => tab.key === newTab);
|
|
|
|
const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab);
|
|
|
|
|
|
|
|
if (oldIndex >= 0 && newIndex && oldIndex < newIndex) {
|
|
|
|
transitionName.value = 'swipeAnimationLeft';
|
|
|
|
} else {
|
|
|
|
transitionName.value = 'swipeAnimationRight';
|
|
|
|
}
|
|
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
transitionName.value = undefined;
|
|
|
|
}, 400);
|
|
|
|
});
|
|
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" module>
|
2024-01-21 10:08:49 +01:00
|
|
|
.transitionRoot {
|
2024-02-07 12:02:29 +01:00
|
|
|
touch-action: pan-y pinch-zoom;
|
2024-01-18 10:21:33 +01:00
|
|
|
display: grid;
|
2024-01-19 09:19:06 +01:00
|
|
|
grid-template-columns: 100%;
|
2024-01-18 10:21:33 +01:00
|
|
|
overflow: clip;
|
2024-01-21 10:08:49 +01:00
|
|
|
}
|
2024-01-18 10:21:33 +01:00
|
|
|
|
2024-01-21 10:08:49 +01:00
|
|
|
.transitionChildren {
|
|
|
|
grid-area: 1 / 1 / 2 / 2;
|
|
|
|
transform: translateX(var(--swipe));
|
2024-01-24 01:22:51 +01:00
|
|
|
}
|
2024-01-18 10:21:33 +01:00
|
|
|
|
2024-01-24 01:22:51 +01:00
|
|
|
.enableAnimation .transitionChildren {
|
2024-01-21 10:08:49 +01:00
|
|
|
&.swipeAnimation_enterActive,
|
|
|
|
&.swipeAnimation_leaveActive {
|
|
|
|
transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
|
|
|
|
}
|
2024-01-18 10:21:33 +01:00
|
|
|
|
2024-01-21 10:08:49 +01:00
|
|
|
&.swipeAnimationRight_leaveTo,
|
|
|
|
&.swipeAnimationLeft_enterFrom {
|
|
|
|
transform: translateX(calc(100% + 24px));
|
|
|
|
}
|
2024-01-18 10:21:33 +01:00
|
|
|
|
2024-01-21 10:08:49 +01:00
|
|
|
&.swipeAnimationRight_enterFrom,
|
|
|
|
&.swipeAnimationLeft_leaveTo {
|
|
|
|
transform: translateX(calc(-100% - 24px));
|
2024-01-18 10:21:33 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.swiping {
|
|
|
|
transition: transform .2s ease-out;
|
|
|
|
}
|
|
|
|
</style>
|