summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkSwiper.vue
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components/MkSwiper.vue')
-rw-r--r--packages/frontend/src/components/MkSwiper.vue238
1 files changed, 238 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkSwiper.vue b/packages/frontend/src/components/MkSwiper.vue
new file mode 100644
index 0000000000..1d0ffaea11
--- /dev/null
+++ b/packages/frontend/src/components/MkSwiper.vue
@@ -0,0 +1,238 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ ref="rootEl"
+ :class="[$style.transitionRoot, { [$style.enableAnimation]: shouldAnimate }]"
+ @touchstart.passive="touchStart"
+ @touchmove.passive="touchMove"
+ @touchend.passive="touchEnd"
+>
+ <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;`"
+ >
+ <div :key="tabModel">
+ <slot></slot>
+ </div>
+ </Transition>
+</div>
+</template>
+<script lang="ts" setup>
+import { ref, useTemplateRef, computed, nextTick, watch } from 'vue';
+import type { Tab } from '@/components/global/MkPageHeader.tabs.vue';
+import { isHorizontalSwipeSwiping as isSwiping } from '@/utility/touch.js';
+import { prefer } from '@/preferences.js';
+
+const rootEl = useTemplateRef('rootEl');
+
+const tabModel = defineModel<string>('tab');
+
+const props = defineProps<{
+ tabs: Tab[];
+}>();
+
+const emit = defineEmits<{
+ (ev: 'swiped', newKey: string, direction: 'left' | 'right'): void;
+}>();
+
+const shouldAnimate = computed(() => prefer.r.enableHorizontalSwipe.value || prefer.r.animation.value);
+
+// ▼ しきい値 ▼ //
+
+// スワイプと判定される最小の距離
+const MIN_SWIPE_DISTANCE = 20;
+
+// スワイプ時の動作を発火する最小の距離
+const SWIPE_DISTANCE_THRESHOLD = 70;
+
+// スワイプを中断するY方向の移動距離
+const SWIPE_ABORT_Y_THRESHOLD = 75;
+
+// スワイプできる最大の距離
+const MAX_SWIPE_DISTANCE = 120;
+
+// ▲ しきい値 ▲ //
+
+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 (!prefer.r.enableHorizontalSwipe.value) return;
+
+ if (event.touches.length !== 1) return;
+
+ if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
+
+ startScreenX = event.touches[0].screenX;
+ startScreenY = event.touches[0].screenY;
+}
+
+function touchMove(event: TouchEvent) {
+ if (!prefer.r.enableHorizontalSwipe.value) return;
+
+ if (event.touches.length !== 1) return;
+
+ if (startScreenX == null || startScreenY == null) return;
+
+ if (swipeAborted) return;
+
+ if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
+
+ 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;
+ window.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 (!prefer.r.enableHorizontalSwipe.value) return;
+
+ if (event.touches.length !== 0) return;
+
+ if (startScreenX == null) return;
+
+ if (!isSwiping.value) return;
+
+ if (hasSomethingToDoWithXSwipe(event.target as HTMLElement)) return;
+
+ 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;
+ window.setTimeout(() => {
+ isSwipingForClass.value = false;
+ }, 400);
+}
+
+/** 横スワイプに関与する可能性のある要素を調べる */
+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;
+ }
+}
+
+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>
+.transitionRoot {
+ touch-action: pan-y pinch-zoom;
+ display: grid;
+ grid-template-columns: 100%;
+ overflow: clip;
+}
+
+.transitionChildren {
+ grid-area: 1 / 1 / 2 / 2;
+ transform: translateX(var(--swipe));
+}
+
+.enableAnimation .transitionChildren {
+ &.swipeAnimation_enterActive,
+ &.swipeAnimation_leaveActive {
+ transition: transform .3s cubic-bezier(0.65, 0.05, 0.36, 1);
+ }
+
+ &.swipeAnimationRight_leaveTo,
+ &.swipeAnimationLeft_enterFrom {
+ transform: translateX(calc(100% + 24px));
+ }
+
+ &.swipeAnimationRight_enterFrom,
+ &.swipeAnimationLeft_leaveTo {
+ transform: translateX(calc(-100% - 24px));
+ }
+}
+
+.swiping {
+ transition: transform .2s ease-out;
+}
+</style>