mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
refactor(calendar): simplify UnifiedBar and extract ViewCarousel gestures
UnifiedBar (633→559 LOC): - Remove 3 duplicate DateStrip blocks (4→1) and 1 duplicate TagStrip block (2→1) - Remove deprecated createEventDispatcher (Svelte 4→5) - Remove unused component imports (DateStrip, TagStrip, CalendarToolbar, DateStripFab) ViewCarousel (402→162 LOC, -60%): - Extract all gesture handling (touch, wheel, velocity, snap, animation, chain navigation) into useSwipeNavigation composable (260 LOC) - ViewCarousel now only handles layout, date calculation, and view rendering - Composable is reusable for any carousel/swipe navigation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e57b64590d
commit
9df908d838
4 changed files with 292 additions and 338 deletions
|
|
@ -1,16 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { setContext, onMount } from 'svelte';
|
||||
import { unifiedBarStore } from '$lib/stores/unified-bar.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
|
||||
// Components
|
||||
// import QuickInputBar from '@manacore/shared-ui/components/QuickInputBar.svelte';
|
||||
import DateStrip from './DateStrip.svelte';
|
||||
import TagStrip from './TagStrip.svelte';
|
||||
import CalendarToolbar from './CalendarToolbar.svelte';
|
||||
import DateStripFab from './DateStripFab.svelte';
|
||||
// Components (not yet integrated — using placeholders)
|
||||
// import DateStrip from './DateStrip.svelte';
|
||||
// import TagStrip from './TagStrip.svelte';
|
||||
// import CalendarToolbar from './CalendarToolbar.svelte';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
|
|
@ -68,8 +65,6 @@
|
|||
onToolbarCollapsedChange = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Local state for transitions
|
||||
let isTransitioning = $state(false);
|
||||
let previousMode = $state(unifiedBarStore.mode);
|
||||
|
|
@ -115,13 +110,9 @@
|
|||
// Overlay menu handlers
|
||||
function handleOverlayToggle() {
|
||||
unifiedBarStore.toggleOverlay();
|
||||
dispatch('overlayToggle', { isOpen: unifiedBarStore.isOverlayOpen });
|
||||
}
|
||||
|
||||
function handleOverlayAction(action: string) {
|
||||
dispatch('overlayAction', { action });
|
||||
|
||||
// Handle common actions
|
||||
switch (action) {
|
||||
case 'toggle-date-strip':
|
||||
unifiedBarStore.toggleDateStrip();
|
||||
|
|
@ -218,71 +209,6 @@
|
|||
aria-label="Date strip"
|
||||
onclick={() => handleLayerClick('date')}
|
||||
>
|
||||
<!-- DateStrip placeholder -->
|
||||
<div class="date-strip-placeholder">DateStrip Component</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Layer 1: DateStrip -->
|
||||
{#if unifiedBarStore.showDateStrip}
|
||||
<div
|
||||
class="unified-bar-layer date-strip-layer"
|
||||
style="z-index: {layerZIndices.date}; bottom: 70px;"
|
||||
class:active={unifiedBarStore.activeLayer === 'date'}
|
||||
transition:fly={{ ...flyConfig, y: 30 }}
|
||||
role="toolbar"
|
||||
aria-label="Date strip"
|
||||
onclick={() => handleLayerClick('date')}
|
||||
>
|
||||
<!-- DateStrip placeholder -->
|
||||
<div class="date-strip-placeholder">DateStrip Component</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Layer 2: TagStrip -->
|
||||
{#if unifiedBarStore.showTagStrip}
|
||||
<div
|
||||
class="unified-bar-layer tag-strip-layer"
|
||||
style="z-index: {layerZIndices.tag}; bottom: {layerBottomOffsets.tag};"
|
||||
class:active={unifiedBarStore.activeLayer === 'tag'}
|
||||
transition:fly={{ ...flyConfig, y: 50 }}
|
||||
role="toolbar"
|
||||
aria-label="Tag filter bar"
|
||||
onclick={() => handleLayerClick('tag')}
|
||||
>
|
||||
<!-- TagStrip placeholder -->
|
||||
<div class="tag-strip-placeholder">TagStrip Component</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Layer 1: DateStrip -->
|
||||
{#if unifiedBarStore.showDateStrip}
|
||||
<div
|
||||
class="unified-bar-layer date-strip-layer"
|
||||
style="z-index: {layerZIndices.date}; bottom: {layerBottomOffsets.date};"
|
||||
class:active={unifiedBarStore.activeLayer === 'date'}
|
||||
transition:fly={{ ...flyConfig, y: 30 }}
|
||||
role="toolbar"
|
||||
aria-label="Date strip"
|
||||
onclick={() => handleLayerClick('date')}
|
||||
>
|
||||
<!-- DateStrip placeholder -->
|
||||
<div class="date-strip-placeholder">DateStrip Component</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Layer 1: DateStrip -->
|
||||
{#if unifiedBarStore.showDateStrip}
|
||||
<div
|
||||
class="unified-bar-layer date-strip-layer"
|
||||
style="z-index: {layerZIndices.date}; bottom: {layerBottomOffsets.date};"
|
||||
class:active={unifiedBarStore.activeLayer === 'date'}
|
||||
transition:fly={{ ...flyConfig, y: 30 }}
|
||||
role="toolbar"
|
||||
aria-label="Date strip"
|
||||
onclick={() => handleLayerClick('date')}
|
||||
>
|
||||
<!-- DateStrip placeholder -->
|
||||
<div class="date-strip-placeholder">DateStrip Component</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { getOffsetDate } from '$lib/utils/dateNavigation';
|
||||
import { HOUR_HEIGHT_PX } from '$lib/utils/calendarConstants';
|
||||
import { useSwipeNavigation } from '$lib/composables/useSwipeNavigation.svelte';
|
||||
import WeekView from './WeekView.svelte';
|
||||
import MonthView from './MonthView.svelte';
|
||||
import AgendaView from './AgendaView.svelte';
|
||||
|
|
@ -18,41 +18,23 @@
|
|||
|
||||
let { onQuickCreate, onEventClick, disableSwipe = false }: Props = $props();
|
||||
|
||||
// Swipe tracking state
|
||||
let offsetX = $state(0);
|
||||
let startX = $state(0);
|
||||
let isSwiping = $state(false);
|
||||
let isAnimating = $state(false);
|
||||
let animatingDirection: 'prev' | 'next' | null = null;
|
||||
|
||||
// Velocity tracking for momentum
|
||||
let lastX = 0;
|
||||
let lastTime = 0;
|
||||
let velocity = 0;
|
||||
|
||||
// Animation frame tracking
|
||||
let animationFrameId: number | null = null;
|
||||
let pendingCallback: (() => void) | null = null;
|
||||
|
||||
// Container refs
|
||||
let viewportEl: HTMLDivElement;
|
||||
let currentPageEl: HTMLDivElement;
|
||||
let viewportWidth = $state(0);
|
||||
|
||||
// Threshold: 15% of viewport width or high velocity triggers navigation
|
||||
const SNAP_THRESHOLD = 0.15;
|
||||
const VELOCITY_THRESHOLD = 0.5; // px/ms - increased for faster swipes
|
||||
// Animation speed (px/ms) - constant speed for linear feel
|
||||
const ANIMATION_SPEED = 3.0; // increased for snappier feel
|
||||
// Debounce for wheel events
|
||||
const WHEEL_DEBOUNCE_MS = 50; // reduced for faster response
|
||||
let wheelDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Calculate dates for previous/current/next views
|
||||
let prevDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, -1));
|
||||
let currentDate = $derived(viewStore.currentDate);
|
||||
let nextDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, 1));
|
||||
|
||||
// Swipe navigation composable
|
||||
const swipe = useSwipeNavigation(() => ({
|
||||
getViewportWidth: () => viewportWidth,
|
||||
onNavigatePrev: () => viewStore.goToPrevious(),
|
||||
onNavigateNext: () => viewStore.goToNext(),
|
||||
disabled: disableSwipe,
|
||||
}));
|
||||
|
||||
// Update viewport width on mount and resize
|
||||
$effect(() => {
|
||||
if (!browser || !viewportEl) return;
|
||||
|
|
@ -62,246 +44,24 @@
|
|||
};
|
||||
|
||||
updateWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(viewportEl);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
});
|
||||
|
||||
// Wheel handler (trackpad horizontal scroll)
|
||||
function handleWheel(e: WheelEvent) {
|
||||
if (disableSwipe) return;
|
||||
|
||||
// Only handle horizontal scrolling (deltaX dominant)
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
||||
|
||||
// Don't interfere with event dragging
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// If animating, check if we should chain navigation
|
||||
if (isAnimating) {
|
||||
const scrollDirection = e.deltaX < 0 ? 'next' : 'prev';
|
||||
if (scrollDirection === animatingDirection && Math.abs(e.deltaX) > 10) {
|
||||
// Chain navigation - immediately go to next page in same direction
|
||||
chainNavigation(scrollDirection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple direct offset update
|
||||
offsetX += e.deltaX * -1;
|
||||
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
|
||||
|
||||
// Debounced snap
|
||||
if (wheelDebounceTimer) clearTimeout(wheelDebounceTimer);
|
||||
wheelDebounceTimer = setTimeout(snapToPage, WHEEL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// Touch handlers
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
if (disableSwipe || isAnimating) return;
|
||||
|
||||
// Don't interfere with event dragging
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
|
||||
|
||||
startX = e.touches[0].clientX;
|
||||
lastX = startX;
|
||||
lastTime = performance.now();
|
||||
velocity = 0;
|
||||
isSwiping = true;
|
||||
|
||||
if (wheelDebounceTimer) {
|
||||
clearTimeout(wheelDebounceTimer);
|
||||
wheelDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!isSwiping || disableSwipe) return;
|
||||
|
||||
const currentX = e.touches[0].clientX;
|
||||
const currentTime = performance.now();
|
||||
|
||||
// Calculate velocity (px/ms)
|
||||
const dt = currentTime - lastTime;
|
||||
if (dt > 0) {
|
||||
velocity = (currentX - lastX) / dt;
|
||||
}
|
||||
|
||||
lastX = currentX;
|
||||
lastTime = currentTime;
|
||||
|
||||
offsetX = currentX - startX;
|
||||
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
snapToPage();
|
||||
}
|
||||
|
||||
function handleTouchCancel() {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
isAnimating = true;
|
||||
animateToOffset(0, () => {
|
||||
isAnimating = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Chain navigation - immediately complete current and start next
|
||||
function chainNavigation(direction: 'prev' | 'next') {
|
||||
// Cancel current animation
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
|
||||
// Complete current navigation immediately (without resetting state flags)
|
||||
if (animatingDirection === 'prev') {
|
||||
viewStore.goToPrevious();
|
||||
} else if (animatingDirection === 'next') {
|
||||
viewStore.goToNext();
|
||||
}
|
||||
|
||||
// Reset and start new animation for another page in same direction
|
||||
offsetX = direction === 'prev' ? viewportWidth * 0.4 : -viewportWidth * 0.4;
|
||||
animatingDirection = direction;
|
||||
|
||||
const targetOffset = direction === 'prev' ? viewportWidth : -viewportWidth;
|
||||
pendingCallback = () => {
|
||||
if (direction === 'prev') {
|
||||
viewStore.goToPrevious();
|
||||
} else {
|
||||
viewStore.goToNext();
|
||||
}
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
|
||||
animateToOffset(targetOffset, pendingCallback);
|
||||
}
|
||||
|
||||
// Snap to page based on current offset and velocity
|
||||
function snapToPage() {
|
||||
if (isAnimating || viewportWidth === 0) return;
|
||||
|
||||
const threshold = viewportWidth * SNAP_THRESHOLD;
|
||||
const hasHighVelocity = Math.abs(velocity) > VELOCITY_THRESHOLD;
|
||||
|
||||
// Determine direction based on position and velocity
|
||||
let targetPage: 'prev' | 'next' | 'current' = 'current';
|
||||
|
||||
if (offsetX > threshold || (hasHighVelocity && velocity > 0 && offsetX > 0)) {
|
||||
targetPage = 'prev';
|
||||
} else if (offsetX < -threshold || (hasHighVelocity && velocity < 0 && offsetX < 0)) {
|
||||
targetPage = 'next';
|
||||
}
|
||||
|
||||
isAnimating = true;
|
||||
animatingDirection = targetPage === 'current' ? null : targetPage;
|
||||
|
||||
if (targetPage === 'prev') {
|
||||
pendingCallback = () => {
|
||||
viewStore.goToPrevious();
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
animateToOffset(viewportWidth, pendingCallback);
|
||||
} else if (targetPage === 'next') {
|
||||
pendingCallback = () => {
|
||||
viewStore.goToNext();
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
animateToOffset(-viewportWidth, pendingCallback);
|
||||
} else {
|
||||
pendingCallback = () => {
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
animateToOffset(0, pendingCallback);
|
||||
}
|
||||
}
|
||||
|
||||
function animateToOffset(targetX: number, onComplete: () => void) {
|
||||
// Cancel any existing animation
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
const startX = offsetX;
|
||||
const distance = targetX - startX;
|
||||
const direction = Math.sign(distance);
|
||||
const absDistance = Math.abs(distance);
|
||||
|
||||
// If already at target, complete immediately
|
||||
if (absDistance < 1) {
|
||||
offsetX = targetX;
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let lastFrameTime = performance.now();
|
||||
|
||||
function tick() {
|
||||
const now = performance.now();
|
||||
const dt = now - lastFrameTime;
|
||||
lastFrameTime = now;
|
||||
|
||||
// Move at constant speed
|
||||
const step = ANIMATION_SPEED * dt * direction;
|
||||
offsetX += step;
|
||||
|
||||
// Check if we've reached or passed the target
|
||||
const reachedTarget =
|
||||
(direction > 0 && offsetX >= targetX) || (direction < 0 && offsetX <= targetX);
|
||||
|
||||
if (reachedTarget) {
|
||||
offsetX = targetX;
|
||||
animationFrameId = null;
|
||||
onComplete();
|
||||
} else {
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// Computed styles
|
||||
let trackStyle = $derived(`transform: translateX(calc(-33.333% + ${offsetX}px))`);
|
||||
let trackStyle = $derived(`transform: translateX(calc(-33.333% + ${swipe.offsetX}px))`);
|
||||
|
||||
// Scroll to center of day (around 12:00) on initial mount
|
||||
// Only for time-grid views (day, week, multi-day)
|
||||
// Scroll to center of day on initial mount
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
|
||||
// Small delay to ensure views are rendered
|
||||
setTimeout(() => {
|
||||
if (!currentPageEl) return;
|
||||
if (!['week'].includes(viewStore.viewType)) return;
|
||||
|
||||
// Only scroll for time-grid views (not month, agenda)
|
||||
const timeGridViews = ['week'];
|
||||
if (!timeGridViews.includes(viewStore.viewType)) return;
|
||||
|
||||
// Calculate scroll position to center around 12:00 (noon)
|
||||
const targetHour = 12;
|
||||
const targetScrollTop = targetHour * HOUR_HEIGHT_PX - currentPageEl.clientHeight / 2;
|
||||
|
||||
const targetScrollTop = 12 * HOUR_HEIGHT_PX - currentPageEl.clientHeight / 2;
|
||||
currentPageEl.scrollTop = Math.max(0, targetScrollTop);
|
||||
}, 150);
|
||||
});
|
||||
|
|
@ -311,15 +71,15 @@
|
|||
<div
|
||||
class="carousel-viewport"
|
||||
bind:this={viewportEl}
|
||||
onwheel={handleWheel}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
ontouchcancel={handleTouchCancel}
|
||||
onwheel={swipe.handleWheel}
|
||||
ontouchstart={swipe.handleTouchStart}
|
||||
ontouchmove={swipe.handleTouchMove}
|
||||
ontouchend={swipe.handleTouchEnd}
|
||||
ontouchcancel={swipe.handleTouchCancel}
|
||||
>
|
||||
<div class="carousel-track" style={trackStyle}>
|
||||
<!-- Previous View -->
|
||||
<div class="carousel-page" class:inactive={!isSwiping && offsetX <= 0}>
|
||||
<div class="carousel-page" class:inactive={!swipe.isSwiping && swipe.offsetX <= 0}>
|
||||
{#if viewStore.viewType === 'week'}
|
||||
<WeekView date={prevDate} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
|
|
@ -343,7 +103,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Next View -->
|
||||
<div class="carousel-page" class:inactive={!isSwiping && offsetX >= 0}>
|
||||
<div class="carousel-page" class:inactive={!swipe.isSwiping && swipe.offsetX >= 0}>
|
||||
{#if viewStore.viewType === 'week'}
|
||||
<WeekView date={nextDate} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
|
|
|
|||
|
|
@ -26,5 +26,8 @@ export { useDragToCreate, type DragToCreateConfig } from './useDragToCreate.svel
|
|||
// Keyboard handling
|
||||
export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKeyboard.svelte';
|
||||
|
||||
// Swipe navigation (carousel gesture handling)
|
||||
export { useSwipeNavigation, type SwipeNavigationConfig } from './useSwipeNavigation.svelte';
|
||||
|
||||
// Birthday popover management
|
||||
export { useBirthdayPopover } from './useBirthdayPopover.svelte';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* Swipe Navigation Composable
|
||||
* Extracts touch/wheel/velocity/snap/animation logic from ViewCarousel
|
||||
*/
|
||||
|
||||
export interface SwipeNavigationConfig {
|
||||
/** Get current viewport width */
|
||||
getViewportWidth: () => number;
|
||||
/** Navigate to previous page */
|
||||
onNavigatePrev: () => void;
|
||||
/** Navigate to next page */
|
||||
onNavigateNext: () => void;
|
||||
/** Whether swipe is disabled */
|
||||
disabled?: boolean;
|
||||
/** Snap threshold as fraction of viewport width (default: 0.15) */
|
||||
snapThreshold?: number;
|
||||
/** Velocity threshold in px/ms (default: 0.5) */
|
||||
velocityThreshold?: number;
|
||||
/** Animation speed in px/ms (default: 3.0) */
|
||||
animationSpeed?: number;
|
||||
/** Wheel debounce in ms (default: 50) */
|
||||
wheelDebounceMs?: number;
|
||||
}
|
||||
|
||||
export function useSwipeNavigation(getConfig: () => SwipeNavigationConfig) {
|
||||
// Swipe tracking state
|
||||
let offsetX = $state(0);
|
||||
let startX = $state(0);
|
||||
let isSwiping = $state(false);
|
||||
let isAnimating = $state(false);
|
||||
let animatingDirection: 'prev' | 'next' | null = null;
|
||||
|
||||
// Velocity tracking
|
||||
let lastX = 0;
|
||||
let lastTime = 0;
|
||||
let velocity = 0;
|
||||
|
||||
// Animation frame tracking
|
||||
let animationFrameId: number | null = null;
|
||||
let pendingCallback: (() => void) | null = null;
|
||||
|
||||
// Wheel debounce
|
||||
let wheelDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function getDefaults() {
|
||||
const config = getConfig();
|
||||
return {
|
||||
snapThreshold: config.snapThreshold ?? 0.15,
|
||||
velocityThreshold: config.velocityThreshold ?? 0.5,
|
||||
animationSpeed: config.animationSpeed ?? 3.0,
|
||||
wheelDebounceMs: config.wheelDebounceMs ?? 50,
|
||||
};
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const config = getConfig();
|
||||
if (config.disabled) return;
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
|
||||
|
||||
e.preventDefault();
|
||||
const viewportWidth = config.getViewportWidth();
|
||||
|
||||
if (isAnimating) {
|
||||
const scrollDirection = e.deltaX < 0 ? 'next' : 'prev';
|
||||
if (scrollDirection === animatingDirection && Math.abs(e.deltaX) > 10) {
|
||||
chainNavigation(scrollDirection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
offsetX += e.deltaX * -1;
|
||||
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
|
||||
|
||||
const { wheelDebounceMs } = getDefaults();
|
||||
if (wheelDebounceTimer) clearTimeout(wheelDebounceTimer);
|
||||
wheelDebounceTimer = setTimeout(snapToPage, wheelDebounceMs);
|
||||
}
|
||||
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
const config = getConfig();
|
||||
if (config.disabled || isAnimating) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
|
||||
|
||||
startX = e.touches[0].clientX;
|
||||
lastX = startX;
|
||||
lastTime = performance.now();
|
||||
velocity = 0;
|
||||
isSwiping = true;
|
||||
|
||||
if (wheelDebounceTimer) {
|
||||
clearTimeout(wheelDebounceTimer);
|
||||
wheelDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
const config = getConfig();
|
||||
if (!isSwiping || config.disabled) return;
|
||||
|
||||
const currentX = e.touches[0].clientX;
|
||||
const currentTime = performance.now();
|
||||
const dt = currentTime - lastTime;
|
||||
if (dt > 0) velocity = (currentX - lastX) / dt;
|
||||
|
||||
lastX = currentX;
|
||||
lastTime = currentTime;
|
||||
|
||||
const viewportWidth = config.getViewportWidth();
|
||||
offsetX = currentX - startX;
|
||||
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
snapToPage();
|
||||
}
|
||||
|
||||
function handleTouchCancel() {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
isAnimating = true;
|
||||
animateToOffset(0, () => {
|
||||
isAnimating = false;
|
||||
});
|
||||
}
|
||||
|
||||
function chainNavigation(direction: 'prev' | 'next') {
|
||||
const config = getConfig();
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
|
||||
if (animatingDirection === 'prev') config.onNavigatePrev();
|
||||
else if (animatingDirection === 'next') config.onNavigateNext();
|
||||
|
||||
const viewportWidth = config.getViewportWidth();
|
||||
offsetX = direction === 'prev' ? viewportWidth * 0.4 : -viewportWidth * 0.4;
|
||||
animatingDirection = direction;
|
||||
|
||||
const targetOffset = direction === 'prev' ? viewportWidth : -viewportWidth;
|
||||
pendingCallback = () => {
|
||||
if (direction === 'prev') config.onNavigatePrev();
|
||||
else config.onNavigateNext();
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
|
||||
animateToOffset(targetOffset, pendingCallback);
|
||||
}
|
||||
|
||||
function snapToPage() {
|
||||
const config = getConfig();
|
||||
const viewportWidth = config.getViewportWidth();
|
||||
if (isAnimating || viewportWidth === 0) return;
|
||||
|
||||
const { snapThreshold, velocityThreshold } = getDefaults();
|
||||
const threshold = viewportWidth * snapThreshold;
|
||||
const hasHighVelocity = Math.abs(velocity) > velocityThreshold;
|
||||
|
||||
let targetPage: 'prev' | 'next' | 'current' = 'current';
|
||||
|
||||
if (offsetX > threshold || (hasHighVelocity && velocity > 0 && offsetX > 0)) {
|
||||
targetPage = 'prev';
|
||||
} else if (offsetX < -threshold || (hasHighVelocity && velocity < 0 && offsetX < 0)) {
|
||||
targetPage = 'next';
|
||||
}
|
||||
|
||||
isAnimating = true;
|
||||
animatingDirection = targetPage === 'current' ? null : targetPage;
|
||||
|
||||
if (targetPage === 'prev') {
|
||||
pendingCallback = () => {
|
||||
config.onNavigatePrev();
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
animateToOffset(viewportWidth, pendingCallback);
|
||||
} else if (targetPage === 'next') {
|
||||
pendingCallback = () => {
|
||||
config.onNavigateNext();
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
animateToOffset(-viewportWidth, pendingCallback);
|
||||
} else {
|
||||
pendingCallback = () => {
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
animateToOffset(0, pendingCallback);
|
||||
}
|
||||
}
|
||||
|
||||
function animateToOffset(targetX: number, onComplete: () => void) {
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
const { animationSpeed } = getDefaults();
|
||||
const sX = offsetX;
|
||||
const distance = targetX - sX;
|
||||
const direction = Math.sign(distance);
|
||||
|
||||
if (Math.abs(distance) < 1) {
|
||||
offsetX = targetX;
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let lastFrameTime = performance.now();
|
||||
|
||||
function tick() {
|
||||
const now = performance.now();
|
||||
const dt = now - lastFrameTime;
|
||||
lastFrameTime = now;
|
||||
|
||||
offsetX += animationSpeed * dt * direction;
|
||||
|
||||
const reachedTarget =
|
||||
(direction > 0 && offsetX >= targetX) || (direction < 0 && offsetX <= targetX);
|
||||
|
||||
if (reachedTarget) {
|
||||
offsetX = targetX;
|
||||
animationFrameId = null;
|
||||
onComplete();
|
||||
} else {
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
return {
|
||||
get offsetX() {
|
||||
return offsetX;
|
||||
},
|
||||
get isSwiping() {
|
||||
return isSwiping;
|
||||
},
|
||||
get isAnimating() {
|
||||
return isAnimating;
|
||||
},
|
||||
|
||||
handleWheel,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
handleTouchCancel,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue