diff --git a/frontend/spa/src/App.vue b/frontend/spa/src/App.vue index 03603b7..271c724 100644 --- a/frontend/spa/src/App.vue +++ b/frontend/spa/src/App.vue @@ -47,6 +47,7 @@ import CartButton from "@/components/CartButton.vue"; import Dock from "@/components/Dock.vue"; import AppDebugMessage from "@/components/AppDebugMessage.vue"; import PrivacyPolicy from "@/components/PrivacyPolicy.vue"; +import {useSwipeBack} from "@/composables/useSwipeBack.js"; const tg = useMiniApp(); const platform = ref(); @@ -64,6 +65,9 @@ const backButton = window.Telegram.WebApp.BackButton; const haptic = window.Telegram.WebApp.HapticFeedback; const drawerOpen = ref(false); +// Инициализация жеста Swipe Back (без визуального индикатора) +useSwipeBack(); + const routesToHideAppDock = [ 'product.show', 'checkout', diff --git a/frontend/spa/src/composables/useSwipeBack.js b/frontend/spa/src/composables/useSwipeBack.js new file mode 100644 index 0000000..45e6861 --- /dev/null +++ b/frontend/spa/src/composables/useSwipeBack.js @@ -0,0 +1,208 @@ +import { ref, onMounted, onUnmounted, computed } from 'vue'; +import { useRouter, useRoute } from 'vue-router'; +import { useHapticFeedback } from './useHapticFeedback.js'; + +/** + * Композабл для обработки жеста Swipe Back + * Реализует поведение, аналогичное нативному в Telegram MiniApp + * Без визуального индикатора + */ +export function useSwipeBack() { + const router = useRouter(); + const route = useRoute(); + const haptic = useHapticFeedback(); + + // Состояние жеста + const isActive = ref(false); + const deltaX = ref(0); + const progress = ref(0); + const hasTriggeredHaptic = ref(false); + + // Конфигурация + const EDGE_THRESHOLD = 20; // Расстояние от левого края для активации (px) + const ACTIVATION_THRESHOLD = 40; // Пороговое расстояние для активации (px) + const MAX_DELTA = 80; // Максимальное расстояние для расчета прогресса (px) + + let touchStartX = 0; + let touchStartY = 0; + let touchStartTime = 0; + let isTracking = false; + + /** + * Вычисляет прогресс жеста (0-1) + */ + const calculateProgress = (currentDeltaX) => { + return Math.min(currentDeltaX / MAX_DELTA, 1); + }; + + /** + * Проверяет, можно ли использовать жест swipe back + */ + const canUseSwipeBack = computed(() => { + return route.name !== 'home'; + }); + + /** + * Проверяет, начинается ли жест от левого края экрана + */ + const isStartingFromLeftEdge = (x) => { + return x <= EDGE_THRESHOLD; + }; + + /** + * Обработчик начала касания + */ + const handleTouchStart = (e) => { + // Проверяем, можно ли использовать жест (не на главной странице) + if (!canUseSwipeBack.value) return; + + const touch = e.touches[0]; + if (!touch) return; + + // Проверяем, не является ли цель касания интерактивным элементом + const target = e.target; + if (target.closest('input, textarea, select, button, a, [role="button"], .swiper, .swiper-wrapper')) { + return; + } + + touchStartX = touch.clientX; + touchStartY = touch.clientY; + touchStartTime = Date.now(); + + // Проверяем, начинается ли жест от левого края + if (isStartingFromLeftEdge(touchStartX)) { + isTracking = true; + isActive.value = true; + deltaX.value = 0; + progress.value = 0; + hasTriggeredHaptic.value = false; + } + }; + + /** + * Обработчик движения пальца + */ + const handleTouchMove = (e) => { + if (!isTracking) return; + + const touch = e.touches[0]; + if (!touch) return; + + const currentX = touch.clientX; + const currentY = touch.clientY; + const currentDeltaX = currentX - touchStartX; + const currentDeltaY = Math.abs(currentY - touchStartY); + + // Проверяем, что движение преимущественно горизонтальное + if (Math.abs(currentDeltaX) < Math.abs(currentDeltaY) * 0.5 && Math.abs(currentDeltaX) > 5) { + // Вертикальное движение - отменяем жест + resetGesture(); + return; + } + + // Если движение влево (отрицательное), уменьшаем прогресс + if (currentDeltaX < 0) { + deltaX.value = Math.max(currentDeltaX, -EDGE_THRESHOLD); + // Если вернулись слишком далеко влево, отменяем жест + if (currentDeltaX < -10) { + resetGesture(); + return; + } + } else { + // Прямое обновление deltaX для положительного движения + deltaX.value = currentDeltaX; + } + + // Обновляем прогресс только для положительного движения + progress.value = calculateProgress(Math.max(0, deltaX.value)); + + // Проверяем достижение порога активации + if (deltaX.value >= ACTIVATION_THRESHOLD && !hasTriggeredHaptic.value) { + // Срабатывает тактильная обратная связь один раз + if (haptic) { + haptic.impactOccurred('light'); + } + hasTriggeredHaptic.value = true; + } + + // Предотвращаем прокрутку страницы во время жеста + if (deltaX.value > 0) { + e.preventDefault(); + } + }; + + /** + * Обработчик окончания касания + */ + const handleTouchEnd = () => { + if (!isTracking) return; + + // Если жест достиг порога активации - выполняем навигацию назад + if (deltaX.value >= ACTIVATION_THRESHOLD) { + // Небольшая задержка для плавности + setTimeout(() => { + router.back(); + resetGesture(); + }, 50); + } else { + // Откат жеста без действия + resetGesture(); + } + }; + + /** + * Обработчик отмены касания + */ + const handleTouchCancel = () => { + resetGesture(); + }; + + /** + * Сброс состояния жеста + */ + const resetGesture = () => { + isTracking = false; + isActive.value = false; + deltaX.value = 0; + progress.value = 0; + hasTriggeredHaptic.value = false; + }; + + /** + * Инициализация обработчиков событий + */ + const init = () => { + document.addEventListener('touchstart', handleTouchStart, { passive: true }); + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd, { passive: true }); + document.addEventListener('touchcancel', handleTouchCancel, { passive: true }); + }; + + /** + * Очистка обработчиков событий + */ + const cleanup = () => { + document.removeEventListener('touchstart', handleTouchStart); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchcancel', handleTouchCancel); + resetGesture(); + }; + + onMounted(() => { + init(); + }); + + onUnmounted(() => { + cleanup(); + }); + + return { + isActive, + deltaX, + progress, + ACTIVATION_THRESHOLD, + MAX_DELTA, + }; +} +