Files
open-webui/src/lib/components/common/MobileSwipePanel.svelte
T
2026-08-11 17:44:34 -06:00

302 lines
6.9 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount } from 'svelte';
const UNSAFE_EDGE = 16;
const SWIPE_SLOP = 8;
const AXIS_RATIO = 1.2;
const FLICK_VELOCITY = 0.55;
const SETTLE_MS = 210;
export let open = false;
export let enabled = false;
export let width = 245;
export let onOpenChange: (open: boolean) => void = () => {};
let mounted = false;
let active = false;
let locked = false;
let direction: 'open' | 'close' | null = null;
let startX = 0;
let startY = 0;
let lastX = 0;
let lastTime = 0;
let velocity = 0;
let startProgress = 0;
let swipeProgress = 0;
let settling = false;
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let settleFrame: ReturnType<typeof requestAnimationFrame> | null = null;
let scrollLocked = false;
let lastOpen: boolean | null = null;
let lastEnabled: boolean | null = null;
$: panelWidth = width || 245;
$: progress = enabled ? Math.max(0, Math.min(1, swipeProgress)) : 1;
$: visible = open || (enabled && mounted);
$: panelStyle = enabled
? `transform: translateX(${(progress - 1) * 100}%); transition: ${
settling ? `transform ${SETTLE_MS}ms cubic-bezier(0.22, 1, 0.36, 1)` : 'none'
};`
: '';
$: backdropStyle = enabled
? `opacity: ${progress * 0.6}; transition: ${
settling ? `opacity ${SETTLE_MS}ms cubic-bezier(0.22, 1, 0.36, 1)` : 'none'
};`
: '';
$: if (open !== lastOpen || enabled !== lastEnabled) {
lastOpen = open;
lastEnabled = enabled;
if (enabled) {
animate(open);
} else {
mounted = false;
swipeProgress = open ? 1 : 0;
settling = false;
setScrollLock(false);
}
}
const shouldSkipSwipe = (target: EventTarget | null) => {
return (
target instanceof Element &&
!!target.closest(
'input, textarea, select, [role="menu"], [contenteditable="true"], [data-sidebar-no-gesture]'
)
);
};
const canScrollHorizontally = (target: EventTarget | null, dx: number) => {
if (!(target instanceof Element) || dx === 0 || typeof window === 'undefined') {
return false;
}
let element: Element | null = target;
while (element && element !== document.documentElement && element !== document.body) {
const node = element as HTMLElement;
const overflowX = window.getComputedStyle(node).overflowX;
const isScrollable =
/(auto|scroll|overlay)/.test(overflowX) && node.scrollWidth > node.clientWidth + 1;
if (isScrollable) {
const maxScrollLeft = node.scrollWidth - node.clientWidth;
return dx > 0 ? node.scrollLeft > 0 : node.scrollLeft < maxScrollLeft - 1;
}
element = element.parentElement;
}
return false;
};
const setScrollLock = (locked: boolean) => {
if (scrollLocked === locked || typeof document === 'undefined') {
return;
}
scrollLocked = locked;
document.documentElement.classList.toggle('mobile-swipe-panel-active', locked);
document.body.classList.toggle('mobile-swipe-panel-active', locked);
};
const clearTimer = () => {
if (settleTimer) {
clearTimeout(settleTimer);
settleTimer = null;
}
};
const clearFrame = () => {
if (settleFrame) {
cancelAnimationFrame(settleFrame);
settleFrame = null;
}
};
const prefersReducedMotion = () =>
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const animate = (nextOpen: boolean) => {
clearTimer();
clearFrame();
mounted = nextOpen || mounted;
const finish = () => {
settling = false;
settleTimer = null;
};
const settle = () => {
settling = !prefersReducedMotion();
swipeProgress = nextOpen ? 1 : 0;
settleTimer = setTimeout(finish, settling ? SETTLE_MS : 0);
};
if (nextOpen && swipeProgress <= 0.01) {
swipeProgress = 0;
settling = false;
settleFrame = requestAnimationFrame(() => {
settleFrame = null;
settle();
});
return;
}
settle();
};
const resetSwipe = () => {
active = false;
locked = false;
direction = null;
velocity = 0;
setScrollLock(false);
};
const cancelSwipe = () => {
resetSwipe();
animate(open);
};
const onTouchStart = (e: TouchEvent) => {
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target)) {
return;
}
const touch = e.touches[0];
const x = touch.clientX;
if (!open) {
if (x < UNSAFE_EDGE) {
return;
}
direction = 'open';
startProgress = 0;
mounted = true;
swipeProgress = 0;
} else {
direction = 'close';
startProgress = swipeProgress || 1;
}
clearTimer();
settling = false;
active = true;
locked = false;
startX = x;
startY = touch.clientY;
lastX = x;
lastTime = e.timeStamp;
velocity = 0;
};
const onTouchMove = (e: TouchEvent) => {
if (!active || !direction || e.touches.length !== 1) {
return;
}
const touch = e.touches[0];
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
const absX = Math.abs(dx);
const absY = Math.abs(dy);
if (!locked) {
if ((direction === 'open' && dx > 0) || (direction === 'close' && dx < 0)) {
swipeProgress = Math.max(0, Math.min(1, startProgress + dx / panelWidth));
}
if (absX < SWIPE_SLOP && absY < SWIPE_SLOP) {
return;
}
if (absX > absY && canScrollHorizontally(e.target, dx)) {
cancelSwipe();
return;
}
if (absY > absX || absX < absY * AXIS_RATIO) {
cancelSwipe();
return;
}
locked = true;
setScrollLock(true);
}
e.preventDefault();
const now = e.timeStamp;
const elapsed = Math.max(1, now - lastTime);
velocity = (touch.clientX - lastX) / elapsed;
lastX = touch.clientX;
lastTime = now;
swipeProgress = Math.max(0, Math.min(1, startProgress + dx / panelWidth));
};
const onTouchEnd = () => {
if (!active) {
return;
}
if (!locked) {
cancelSwipe();
return;
}
let shouldOpen = direction === 'open' ? swipeProgress > 0.35 : swipeProgress > 0.65;
if (velocity > FLICK_VELOCITY) {
shouldOpen = true;
} else if (velocity < -FLICK_VELOCITY) {
shouldOpen = false;
}
resetSwipe();
animate(shouldOpen);
onOpenChange(shouldOpen);
};
const onTouchCancel = () => {
if (active) {
cancelSwipe();
}
};
onMount(() => {
window.addEventListener('touchstart', onTouchStart, { passive: true });
window.addEventListener('touchmove', onTouchMove, { passive: false });
window.addEventListener('touchend', onTouchEnd);
window.addEventListener('touchcancel', onTouchCancel);
});
onDestroy(() => {
window.removeEventListener('touchstart', onTouchStart);
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('touchend', onTouchEnd);
window.removeEventListener('touchcancel', onTouchCancel);
clearTimer();
clearFrame();
setScrollLock(false);
});
</script>
<slot {visible} {progress} {panelStyle} {backdropStyle} />
<style>
:global(html),
:global(body) {
overflow-x: hidden;
overscroll-behavior-x: contain;
}
:global(html.mobile-swipe-panel-active),
:global(body.mobile-swipe-panel-active) {
touch-action: pan-y;
}
</style>