mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
refactor(calendar): simplify swipe to instant navigation
Remove all animation complexity - just instant page switches: - Swipe/scroll threshold triggers immediate navigation - No animation, no bounce, no transitions - Accumulated delta for trackpad scrolling - ~100 lines instead of ~300 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3929911051
commit
a86f8e4ef4
1 changed files with 49 additions and 303 deletions
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { getOffsetDate } from '$lib/utils/dateNavigation';
|
||||
import WeekView from './WeekView.svelte';
|
||||
import DayView from './DayView.svelte';
|
||||
import MonthView from './MonthView.svelte';
|
||||
|
|
@ -18,39 +17,16 @@
|
|||
|
||||
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;
|
||||
// Swipe tracking
|
||||
let startX = 0;
|
||||
let isSwiping = false;
|
||||
|
||||
// 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
|
||||
// Container ref
|
||||
let viewportEl: HTMLDivElement;
|
||||
let viewportWidth = $state(0);
|
||||
|
||||
// Threshold: 15% of viewport width or high velocity triggers navigation
|
||||
// Threshold: 15% of viewport width 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));
|
||||
|
||||
// Update viewport width on mount and resize
|
||||
$effect(() => {
|
||||
|
|
@ -69,10 +45,12 @@
|
|||
});
|
||||
|
||||
// Wheel handler (trackpad horizontal scroll)
|
||||
let accumulatedDelta = 0;
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
if (disableSwipe) return;
|
||||
|
||||
// Only handle horizontal scrolling (deltaX dominant)
|
||||
// Only handle horizontal scrolling
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
||||
|
||||
// Don't interfere with event dragging
|
||||
|
|
@ -81,208 +59,48 @@
|
|||
|
||||
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;
|
||||
accumulatedDelta += e.deltaX;
|
||||
|
||||
const threshold = viewportWidth * SNAP_THRESHOLD;
|
||||
|
||||
if (accumulatedDelta > threshold) {
|
||||
viewStore.goToNext();
|
||||
accumulatedDelta = 0;
|
||||
} else if (accumulatedDelta < -threshold) {
|
||||
viewStore.goToPrevious();
|
||||
accumulatedDelta = 0;
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (disableSwipe) 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() {
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
snapToPage();
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const deltaX = endX - startX;
|
||||
const threshold = viewportWidth * SNAP_THRESHOLD;
|
||||
|
||||
if (deltaX > threshold) {
|
||||
viewStore.goToPrevious();
|
||||
} else if (deltaX < -threshold) {
|
||||
viewStore.goToNext();
|
||||
}
|
||||
}
|
||||
|
||||
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))`);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
|
@ -291,76 +109,28 @@
|
|||
bind:this={viewportEl}
|
||||
onwheel={handleWheel}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
ontouchcancel={handleTouchCancel}
|
||||
>
|
||||
<div class="carousel-track" style={trackStyle}>
|
||||
<!-- Previous View -->
|
||||
<div class="carousel-page" class:inactive={!isSwiping && offsetX <= 0}>
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView date={prevDate} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} date={prevDate} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView date={prevDate} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} date={prevDate} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} date={prevDate} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView date={prevDate} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView date={prevDate} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView date={prevDate} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Current View (main interactive view) -->
|
||||
<div class="carousel-page current">
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView {onEventClick} />
|
||||
{:else}
|
||||
<WeekView {onQuickCreate} {onEventClick} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next View -->
|
||||
<div class="carousel-page" class:inactive={!isSwiping && offsetX >= 0}>
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView date={nextDate} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} date={nextDate} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView date={nextDate} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} date={nextDate} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} date={nextDate} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView date={nextDate} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView date={nextDate} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView date={nextDate} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView {onEventClick} />
|
||||
{:else}
|
||||
<WeekView {onQuickCreate} {onEventClick} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -371,28 +141,4 @@
|
|||
position: relative;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
width: 300%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.carousel-page {
|
||||
width: 33.333%;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Inactive pages have reduced interactivity for performance */
|
||||
.carousel-page.inactive {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.carousel-page.current {
|
||||
/* Always interactive */
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue