feat(mobile): swipe to open menu for explorer (#8953)

close AF-1803

- bump theme
- extract `SwipeHelper` and add `speed`, `direction` detection support
- new mobile `SwipeMenu` component
- integrate `SwipeMenu` to open a menu in Explorer
- New `Haptics` module for mobile, implemented in `ios` and `mobile`(`navigator.vibrate()`)

![CleanShot 2024-11-28 at 12.25.14.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/cba36660-f38a-473b-b85c-33540a406835.gif)
This commit is contained in:
CatsJuice
2024-12-02 03:27:01 +00:00
parent b600f2b0a2
commit 11b453f4d8
23 changed files with 457 additions and 45 deletions
+1
View File
@@ -13,6 +13,7 @@ def capacitor_pods
pod 'CapacitorCordova', :path => '../../../../../node_modules/@capacitor/ios'
pod 'CapacitorApp', :path => '../../../../../node_modules/@capacitor/app'
pod 'CapacitorBrowser', :path => '../../../../../node_modules/@capacitor/browser'
pod 'CapacitorHaptics', :path => '../../../../../node_modules/@capacitor/haptics'
pod 'CapacitorKeyboard', :path => '../../../../../node_modules/@capacitor/keyboard'
end
+17 -11
View File
@@ -1,12 +1,14 @@
PODS:
- Capacitor (6.1.2):
- Capacitor (6.2.0):
- CapacitorCordova
- CapacitorApp (6.0.1):
- CapacitorApp (6.0.2):
- Capacitor
- CapacitorBrowser (6.0.3):
- CapacitorBrowser (6.0.4):
- Capacitor
- CapacitorCordova (6.1.2)
- CapacitorKeyboard (6.0.2):
- CapacitorCordova (6.2.0)
- CapacitorHaptics (6.0.2):
- Capacitor
- CapacitorKeyboard (6.0.3):
- Capacitor
- CryptoSwift (1.8.3)
@@ -15,6 +17,7 @@ DEPENDENCIES:
- "CapacitorApp (from `../../../../../node_modules/@capacitor/app`)"
- "CapacitorBrowser (from `../../../../../node_modules/@capacitor/browser`)"
- "CapacitorCordova (from `../../../../../node_modules/@capacitor/ios`)"
- "CapacitorHaptics (from `../../../../../node_modules/@capacitor/haptics`)"
- "CapacitorKeyboard (from `../../../../../node_modules/@capacitor/keyboard`)"
- CryptoSwift (~> 1.8.3)
@@ -31,17 +34,20 @@ EXTERNAL SOURCES:
:path: "../../../../../node_modules/@capacitor/browser"
CapacitorCordova:
:path: "../../../../../node_modules/@capacitor/ios"
CapacitorHaptics:
:path: "../../../../../node_modules/@capacitor/haptics"
CapacitorKeyboard:
:path: "../../../../../node_modules/@capacitor/keyboard"
SPEC CHECKSUMS:
Capacitor: 679f9673fdf30597493a6362a5d5bf233d46abc2
CapacitorApp: 0bc633b4eae40a1f32cd2834788fad3bc42da6a1
CapacitorBrowser: aab1ed943b01c0365c4810538a8b3477e2d9f72e
CapacitorCordova: f48c89f96c319101cd2f0ce8a2b7449b5fb8b3dd
CapacitorKeyboard: 2700f9b18687be021e28b5a09b59eb151a46d5e0
Capacitor: 1f3c7b9802d958cd8c4eb63895fff85dff2e1eea
CapacitorApp: 2a8c3a0b0814322e5e6e15fe595f02c3808f0f8b
CapacitorBrowser: ef0529d16cd8839281050c350e7bbee4f5c6d65f
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9
CapacitorHaptics: b53409aaca1203f79c6d0eb3ed5de40556339518
CapacitorKeyboard: 460c6f9ec5e52c84f2742d5ce2e67bbc7ab0ebb0
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
PODFILE CHECKSUM: 1b0d3fe81862c0e9ce712ddd0c5a0accd0097698
PODFILE CHECKSUM: e0c0ccf027ea6d51e476f0baf9d44d97b9a90a4b
COCOAPODS: 1.16.2
+1
View File
@@ -20,6 +20,7 @@
"@capacitor/app": "^6.0.1",
"@capacitor/browser": "^6.0.3",
"@capacitor/core": "^6.1.2",
"@capacitor/haptics": "^6.0.2",
"@capacitor/ios": "^6.1.2",
"@capacitor/keyboard": "^6.0.2",
"@sentry/react": "^8.0.0",
+10
View File
@@ -1,6 +1,7 @@
import { AffineContext } from '@affine/core/components/context';
import { AppFallback } from '@affine/core/mobile/components/app-fallback';
import { configureMobileModules } from '@affine/core/mobile/modules';
import { HapticProvider } from '@affine/core/mobile/modules/haptics';
import { NavigationGestureProvider } from '@affine/core/mobile/modules/navigation-gesture';
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { router } from '@affine/core/mobile/router';
@@ -22,6 +23,7 @@ import {
} from '@affine/core/modules/workspace-engine';
import { App as CapacitorApp } from '@capacitor/app';
import { Browser } from '@capacitor/browser';
import { Haptics } from '@capacitor/haptics';
import { Keyboard } from '@capacitor/keyboard';
import {
Framework,
@@ -94,6 +96,14 @@ framework.impl(NavigationGestureProvider, {
enable: () => NavigationGesture.enable(),
disable: () => NavigationGesture.disable(),
});
framework.impl(HapticProvider, {
impact: options => Haptics.impact(options as any),
vibrate: options => Haptics.vibrate(options as any),
notification: options => Haptics.notification(options as any),
selectionStart: () => Haptics.selectionStart(),
selectionChanged: () => Haptics.selectionChanged(),
selectionEnd: () => Haptics.selectionEnd(),
});
const frameworkProvider = framework.provider();
// setup application lifecycle events, and emit application start event
+23
View File
@@ -1,6 +1,7 @@
import { AffineContext } from '@affine/core/components/context';
import { AppFallback } from '@affine/core/mobile/components/app-fallback';
import { configureMobileModules } from '@affine/core/mobile/modules';
import { HapticProvider } from '@affine/core/mobile/modules/haptics';
import { router } from '@affine/core/mobile/router';
import { configureCommonModules } from '@affine/core/modules';
import { I18nProvider } from '@affine/core/modules/i18n';
@@ -52,6 +53,28 @@ framework.impl(PopupWindowProvider, {
window.open(url, '_blank', 'noreferrer noopener');
},
});
framework.impl(HapticProvider, {
impact: options => {
return new Promise(resolve => {
const style = options?.style ?? 'LIGHT';
const pattern = {
LIGHT: [10],
MEDIUM: [20],
HEAVY: [30],
}[style];
const result = navigator.vibrate?.(pattern);
if (!result) {
console.warn('vibrate not supported, or user not interacted');
}
resolve();
});
},
notification: () => Promise.reject('Not supported'),
vibrate: () => Promise.reject('Not supported'),
selectionStart: () => Promise.reject('Not supported'),
selectionChanged: () => Promise.reject('Not supported'),
selectionEnd: () => Promise.reject('Not supported'),
});
const frameworkProvider = framework.provider();
// setup application lifecycle events, and emit application start event
+1 -1
View File
@@ -41,7 +41,7 @@
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@radix-ui/react-visually-hidden": "^1.1.0",
"@toeverything/theme": "^1.0.18",
"@toeverything/theme": "^1.0.21",
"@vanilla-extract/dynamic": "^2.1.0",
"check-password-strength": "^2.0.10",
"clsx": "^2.1.0",
+1 -1
View File
@@ -37,7 +37,7 @@
"@radix-ui/react-toolbar": "^1.0.4",
"@sentry/react": "^8.0.0",
"@toeverything/pdf-viewer": "^0.1.1",
"@toeverything/theme": "^1.0.18",
"@toeverything/theme": "^1.0.21",
"@vanilla-extract/dynamic": "^2.1.0",
"animejs": "^3.2.2",
"bytes": "^3.1.2",
@@ -233,7 +233,7 @@ export const useExplorerDocNodeOperationsMenu = (
),
},
{
index: 99,
index: 97,
view: (
<MenuItem
prefixIcon={<LinkedPageIcon />}
@@ -244,7 +244,7 @@ export const useExplorerDocNodeOperationsMenu = (
),
},
{
index: 99,
index: 98,
view: (
<MenuItem prefixIcon={<DuplicateIcon />} onClick={handleDuplicate}>
{t['com.affine.header.option.duplicate']()}
@@ -3,7 +3,7 @@ import type { BaseExplorerTreeNodeProps } from '@affine/core/modules/explorer';
import { ExplorerTreeContext } from '@affine/core/modules/explorer';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { extractEmojiIcon } from '@affine/core/utils';
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
import { ArrowDownSmallIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import {
@@ -15,6 +15,7 @@ import {
useState,
} from 'react';
import { SwipeMenu } from '../../swipe-menu';
import * as styles from './node.css';
interface ExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {}
@@ -43,6 +44,7 @@ export const ExplorerTreeNode = ({
const clickForCollapse = !onClick && !to && !disabled;
const [childCount, setChildCount] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const { emoji, name } = useMemo(() => {
if (!extractEmojiAsIcon || !rawName) {
@@ -160,15 +162,32 @@ export const ExplorerTreeNode = ({
ref={rootRef}
{...otherProps}
>
<div className={styles.contentContainer} data-open={!collapsed}>
{to ? (
<LinkComponent to={to} className={styles.linkItemRoot}>
{content}
</LinkComponent>
) : (
<div>{content}</div>
)}
</div>
<SwipeMenu
onExecute={useCallback(() => setMenuOpen(true), [])}
menu={
<MobileMenu
rootOptions={useMemo(
() => ({ open: menuOpen, onOpenChange: setMenuOpen }),
[menuOpen]
)}
items={menuOperations.map(({ view, index }) => (
<Fragment key={index}>{view}</Fragment>
))}
>
<MoreHorizontalIcon fontSize={24} />
</MobileMenu>
}
>
<div className={styles.contentContainer} data-open={!collapsed}>
{to ? (
<LinkComponent to={to} className={styles.linkItemRoot}>
{content}
</LinkComponent>
) : (
<div>{content}</div>
)}
</div>
</SwipeMenu>
<Collapsible.Content>
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
<div className={styles.collapseContentPlaceholder}>
@@ -5,5 +5,6 @@ export * from './page-header';
export * from './rename';
export * from './search-input';
export * from './search-result';
export * from './swipe-menu';
export * from './user-plan-tag';
export * from './workspace-selector';
@@ -0,0 +1,175 @@
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import anime from 'animejs';
import clsx from 'clsx';
import {
type HTMLAttributes,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
} from 'react';
import { HapticsService } from '../../modules/haptics';
import { SwipeHelper } from '../../utils';
import * as styles from './styles.css';
export interface SwipeMenuProps extends HTMLAttributes<HTMLDivElement> {
menu: ReactNode;
/**
* if the swipe distance is greater than the threshold, will execute the callback
* @default 200
*/
executeThreshold?: number;
onExecute?: () => void;
normalWidth?: number;
}
type TickFunc<T extends Array<any> = any[]> = (
content: HTMLDivElement,
menu: HTMLDivElement,
options: {
deltaX: number;
normalWidth: number;
},
...args: T
) => void;
const tick: TickFunc = (content, menu, options) => {
const limitedDeltaX = Math.min(
0,
Math.max(options.deltaX, -options.normalWidth * 3)
);
content.style.transform = `translateX(${limitedDeltaX}px)`;
menu.style.transform = `translateX(${limitedDeltaX}px)`;
menu.style.width = `${Math.max(options.normalWidth, Math.abs(limitedDeltaX))}px`;
};
const animate: TickFunc<[number]> = (content, menu, options, to) => {
const styleX = Number(content.style.transform.match(/-?\d+/)?.[0]);
const deltaX = isNaN(styleX) ? options.deltaX : styleX;
const proxy = new Proxy(
{ deltaX },
{
set(target, key, value) {
if (key !== 'deltaX') return false;
target.deltaX = value;
tick(content, menu, { ...options, deltaX: value });
return true;
},
}
);
if (deltaX === to) return;
anime({
targets: proxy,
deltaX: to,
duration: 230,
easing: 'easeInOutSine',
});
};
const activeId$ = new LiveData<string | null>(null);
/**
* Only support swipe left yet
* Only support single menu item yet
*/
export const SwipeMenu = ({
children,
className,
menu,
normalWidth = 90,
executeThreshold = 200,
onExecute,
...props
}: SwipeMenuProps) => {
const id = useId();
const haptics = useService(HapticsService);
const activeId = useLiveData(activeId$);
const isOpenRef = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const handleClose = useCallback(() => {
isOpenRef.current = false;
const content = contentRef.current;
const menu = menuRef.current;
if (!content || !menu) return;
animate(content, menu, { deltaX: -normalWidth, normalWidth }, 0);
}, [normalWidth]);
useEffect(() => {
if (activeId && activeId !== id && isOpenRef.current) {
handleClose();
}
}, [activeId, handleClose, id]);
useEffect(() => {
const container = containerRef.current;
const menu = menuRef.current;
const content = contentRef.current;
if (!container || !menu || !content) return;
const swipeHelper = new SwipeHelper();
let shouldExecute = false;
return swipeHelper.init(container, {
preventScroll: true,
direction: 'horizontal',
onSwipeStart() {
activeId$.next(id);
},
onSwipe({ deltaX: dragX, initialDirection }) {
if (initialDirection !== 'horizontal') return;
const deltaX = isOpenRef.current ? dragX - normalWidth : dragX;
const prevShouldExecute = shouldExecute;
shouldExecute = Math.abs(deltaX) > executeThreshold;
if (shouldExecute && !prevShouldExecute) {
haptics.impact({ style: 'LIGHT' });
}
tick(content, menu, { deltaX, normalWidth });
},
onSwipeEnd({ deltaX: dragX, initialDirection }) {
activeId$.next(null);
if (initialDirection !== 'horizontal') return;
const deltaX = isOpenRef.current ? dragX - normalWidth : dragX;
if (shouldExecute) {
animate(content, menu, { deltaX, normalWidth }, 0);
onExecute?.();
isOpenRef.current = false;
return;
}
// open
if (Math.abs(deltaX) > normalWidth / 2) {
animate(content, menu, { deltaX, normalWidth }, -normalWidth);
isOpenRef.current = true;
}
// close
else {
animate(content, menu, { deltaX, normalWidth }, 0);
isOpenRef.current = false;
}
},
});
}, [executeThreshold, haptics, id, normalWidth, onExecute]);
return (
<div
className={clsx(styles.container, className)}
ref={containerRef}
{...props}
>
<div className={styles.content} ref={contentRef}>
{children}
</div>
<div className={styles.menu} ref={menuRef} onClick={handleClose}>
{menu}
</div>
</div>
);
};
@@ -0,0 +1,22 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
position: 'relative',
overflow: 'hidden',
});
export const content = style({
position: 'relative',
zIndex: 1,
});
export const menu = style({
position: 'absolute',
left: '100%',
top: 0,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVarV2('icon/primary'),
background: cssVarV2('layer/background/mobile/tertiary'),
});
@@ -0,0 +1,13 @@
import type { Framework } from '@toeverything/infra';
import { HapticProvider } from './providers/haptic';
import { HapticsService } from './services/haptics';
export function configureMobileHapticsModule(framework: Framework) {
framework.service(
HapticsService,
f => new HapticsService(f.getOptional(HapticProvider))
);
}
export { HapticProvider, HapticsService };
@@ -0,0 +1,15 @@
import { createIdentifier } from '@toeverything/infra';
export interface HapticProvider {
impact: (options?: { style?: 'HEAVY' | 'LIGHT' | 'MEDIUM' }) => Promise<void>;
notification: (options?: {
type?: 'SUCCESS' | 'ERROR' | 'WARNING';
}) => Promise<void>;
vibrate: (options?: { duration?: number }) => Promise<void>;
selectionStart: () => Promise<void>;
selectionChanged: () => Promise<void>;
selectionEnd: () => Promise<void>;
}
export const HapticProvider =
createIdentifier<HapticProvider>('HapticProvider');
@@ -0,0 +1,37 @@
import { Service } from '@toeverything/infra';
import type { HapticProvider } from '../providers/haptic';
type ExtractArg<T extends keyof HapticProvider> = Parameters<
HapticProvider[T]
>[0];
export class HapticsService extends Service {
constructor(private readonly provider?: HapticProvider) {
super();
}
impact(options?: ExtractArg<'impact'>) {
this.provider?.impact?.(options)?.catch(console.error);
}
notification(options?: ExtractArg<'notification'>) {
this.provider?.notification?.(options)?.catch(console.error);
}
vibrate(options?: ExtractArg<'vibrate'>) {
this.provider?.vibrate?.(options)?.catch(console.error);
}
selectionStart() {
this.provider?.selectionStart?.().catch(console.error);
}
selectionChanged() {
this.provider?.selectionChanged?.().catch(console.error);
}
selectionEnd() {
this.provider?.selectionEnd?.().catch(console.error);
}
}
@@ -1,5 +1,6 @@
import type { Framework } from '@toeverything/infra';
import { configureMobileHapticsModule } from './haptics';
import { configureMobileNavigationGestureModule } from './navigation-gesture';
import { configureMobileSearchModule } from './search';
import { configureMobileVirtualKeyboardModule } from './virtual-keyboard';
@@ -8,4 +9,5 @@ export function configureMobileModules(framework: Framework) {
configureMobileSearchModule(framework);
configureMobileVirtualKeyboardModule(framework);
configureMobileNavigationGestureModule(framework);
configureMobileHapticsModule(framework);
}
@@ -1,3 +1,4 @@
import { SwipeHelper } from '@affine/core/mobile/utils';
import anime from 'animejs';
import clsx from 'clsx';
import dayjs from 'dayjs';
@@ -20,7 +21,6 @@ import {
} from './constants';
import { JournalDatePickerContext } from './context';
import * as styles from './month.css';
import { SwipeHelper } from './swipe-helper';
import { getFirstDayOfMonth } from './utils';
import { WeekRow } from './week';
export interface MonthViewProps {
@@ -106,9 +106,9 @@ export const MonthView = ({ viewportHeight }: MonthViewProps) => {
setCursor(selected);
}
},
onSwipeEnd: ({ deltaX }) => {
onSwipeEnd: ({ deltaX, speedX }) => {
setSwiping(false);
if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) {
if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD || speedX > 100) {
animateTo(deltaX > 0 ? -1 : 1);
} else {
animateTo(0);
@@ -1,4 +1,5 @@
import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events';
import { SwipeHelper } from '@affine/core/mobile/utils';
import anime from 'animejs';
import clsx from 'clsx';
import dayjs from 'dayjs';
@@ -20,7 +21,6 @@ import {
} from './constants';
import { JournalDatePickerContext } from './context';
import { MonthView } from './month';
import { SwipeHelper } from './swipe-helper';
import * as styles from './viewport.css';
import { WeekHeader, WeekRowSwipe } from './week';
@@ -97,8 +97,8 @@ export const ResizeViewport = ({
setDragOffset(deltaY);
setIsDragging(true);
},
onSwipeEnd: ({ deltaY }) => {
if (Math.abs(deltaY) > 2 * CELL_HEIGHT) {
onSwipeEnd: ({ deltaY, speedY }) => {
if (Math.abs(deltaY) > 2 * CELL_HEIGHT || speedY > 100) {
handleToggleModeWithAnimation(
mode === 'week' ? 'month' : 'week',
deltaY
@@ -1,3 +1,4 @@
import { SwipeHelper } from '@affine/core/mobile/utils';
import { useI18n } from '@affine/i18n';
import anime from 'animejs';
import clsx from 'clsx';
@@ -16,7 +17,6 @@ import {
import { DATE_FORMAT, HORIZONTAL_SWIPE_THRESHOLD } from './constants';
import { JournalDatePickerContext } from './context';
import { DayCell } from './day-cell';
import { SwipeHelper } from './swipe-helper';
import { getFirstDayOfWeek } from './utils';
import * as styles from './week.css';
@@ -115,10 +115,10 @@ export const WeekRowSwipe = ({ start }: WeekRowProps) => {
setCursor(start);
}
},
onSwipeEnd({ deltaX }) {
onSwipeEnd({ deltaX, speedX }) {
setSwiping(false);
if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) {
if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD || speedX > 100) {
animateTo(deltaX > 0 ? -1 : 1);
} else {
animateTo(0);
@@ -0,0 +1 @@
export * from './swipe-helper';
@@ -1,3 +1,5 @@
type Direction = 'horizontal' | 'vertical';
export interface SwipeInfo {
e: TouchEvent;
startX: number;
@@ -7,25 +9,60 @@ export interface SwipeInfo {
deltaX: number;
deltaY: number;
isFirst: boolean;
/**
* Instant horizontal speed in `px/s`
*/
speedX: number;
/**
* Average horizontal speed in `px/s`
*/
averageSpeedX: number;
/**
* Instant vertical speed in `px/s`
*/
speedY: number;
/**
* Average vertical speed in `px/s`
*/
averageSpeedY: number;
/**
* The first detected direction
*/
initialDirection: Direction | null;
}
export interface SwipeHelperOptions {
scope?: 'global' | 'inside';
/**
* When swipe started, the direction will be detected and not change until swipe ended.
* If the direction is specified, and not match the detected one, the scroll won't be prevented.
*/
direction?: Direction;
/**
* @description The distance in px that determine which direction the swipe gesture is
* @default 10
*/
directionThreshold?: number;
preventScroll?: boolean;
onTap?: () => void;
onSwipeStart?: () => void;
onSwipe?: (info: SwipeInfo) => void;
onSwipeEnd?: (info: SwipeInfo) => void;
}
const defaultOptions = Object.freeze({
scope: 'global',
directionThreshold: 10,
} as const);
export class SwipeHelper {
private _trigger: HTMLElement | null = null;
private _options: SwipeHelperOptions = {
scope: 'global',
};
private _options: SwipeHelperOptions = defaultOptions;
private _direction: Direction | null = null;
private _startPos: { x: number; y: number } = { x: 0, y: 0 };
private _isFirst: boolean = true;
private _lastInfo: SwipeInfo | null = null;
private _startTime: number = 0;
private _lastTime: number = 0;
get scopeElement() {
return this._options.scope === 'inside'
@@ -69,6 +106,8 @@ export class SwipeHelper {
x: touch.clientX,
y: touch.clientY,
};
this._lastTime = Date.now();
this._startTime = this._lastTime;
this._options.onSwipeStart?.();
const moveHandler = this._handleTouchMove.bind(this);
this.scopeElement.addEventListener('touchmove', moveHandler, {
@@ -87,10 +126,18 @@ export class SwipeHelper {
}
private _handleTouchMove(e: TouchEvent) {
if (this._options.preventScroll) {
const info = this._getInfo(e);
if (
this._options.preventScroll &&
// direction is not detected
(!this._direction ||
// direction is not specified
!this._options.direction ||
// direction is same as specified
this._direction === this._options.direction)
) {
e.preventDefault();
}
const info = this._getInfo(e);
this._lastInfo = info;
this._isFirst = false;
this._options.onSwipe?.(info);
@@ -110,9 +157,30 @@ export class SwipeHelper {
}
private _getInfo(e: TouchEvent): SwipeInfo {
const lastTime = this._lastTime;
this._lastTime = Date.now();
const deltaTime = this._lastTime - lastTime;
const touch = e.touches[0];
const deltaX = touch.clientX - this._startPos.x;
const deltaY = touch.clientY - this._startPos.y;
const speedX =
(Math.abs(deltaX - (this._lastInfo?.deltaX ?? 0)) / deltaTime) * 1000;
const averageSpeedX =
(Math.abs(deltaX) / (this._lastTime - this._startTime)) * 1000;
const speedY =
(Math.abs(deltaY - (this._lastInfo?.deltaY ?? 0)) / deltaTime) * 1000;
const averageSpeedY =
(Math.abs(deltaY) / (this._lastTime - this._startTime)) * 1000;
// detect direction
const threshold =
this._options.directionThreshold ?? defaultOptions.directionThreshold;
const maxDelta = Math.max(Math.abs(deltaX), Math.abs(deltaY));
if (!this._direction && maxDelta > threshold) {
this._direction =
Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical';
}
return {
e,
startX: this._startPos.x,
@@ -122,11 +190,19 @@ export class SwipeHelper {
deltaX,
deltaY,
isFirst: this._isFirst,
speedX,
averageSpeedX,
speedY,
averageSpeedY,
initialDirection: this._direction,
};
}
private _clearDrag() {
this._lastInfo = null;
this._lastTime = 0;
this._startTime = 0;
this._direction = null;
this._dragMoveCleanup();
this._dragEndCleanup();
}
@@ -1,5 +1,5 @@
{
"ar": 75,
"ar": 74,
"ca": 5,
"da": 6,
"de": 28,
@@ -13,12 +13,12 @@
"it-IT": 1,
"it": 1,
"ja": 99,
"ko": 79,
"ko": 78,
"pl": 0,
"pt-BR": 85,
"ru": 73,
"sv-SE": 4,
"ur": 3,
"zh-Hans": 100,
"zh-Hans": 99,
"zh-Hant": 99
}
+13 -3
View File
@@ -343,7 +343,7 @@ __metadata:
"@storybook/react": "npm:^8.2.9"
"@storybook/react-vite": "npm:^8.2.9"
"@testing-library/react": "npm:^16.0.0"
"@toeverything/theme": "npm:^1.0.18"
"@toeverything/theme": "npm:^1.0.21"
"@types/react": "npm:^18.2.75"
"@types/react-dom": "npm:^18.2.24"
"@vanilla-extract/css": "npm:^1.14.2"
@@ -422,7 +422,7 @@ __metadata:
"@sentry/react": "npm:^8.0.0"
"@testing-library/react": "npm:^16.0.0"
"@toeverything/pdf-viewer": "npm:^0.1.1"
"@toeverything/theme": "npm:^1.0.18"
"@toeverything/theme": "npm:^1.0.21"
"@types/animejs": "npm:^3.1.12"
"@types/bytes": "npm:^3.1.4"
"@types/image-blob-reduce": "npm:^4.1.4"
@@ -618,6 +618,7 @@ __metadata:
"@capacitor/browser": "npm:^6.0.3"
"@capacitor/cli": "npm:^6.1.2"
"@capacitor/core": "npm:^6.1.2"
"@capacitor/haptics": "npm:^6.0.2"
"@capacitor/ios": "npm:^6.1.2"
"@capacitor/keyboard": "npm:^6.0.2"
"@sentry/react": "npm:^8.0.0"
@@ -3006,6 +3007,15 @@ __metadata:
languageName: node
linkType: hard
"@capacitor/haptics@npm:^6.0.2":
version: 6.0.2
resolution: "@capacitor/haptics@npm:6.0.2"
peerDependencies:
"@capacitor/core": ^6.0.0
checksum: 10/986003d8327d914762f5c0a4f4b6e494654fa0c27344c2a1764f06d08895bce035d8105c0f2a571b8b8bd3bd2f72b08bdc2ad4e1167aa186632a7a16bb6118a2
languageName: node
linkType: hard
"@capacitor/ios@npm:^6.1.2":
version: 6.2.0
resolution: "@capacitor/ios@npm:6.2.0"
@@ -12998,7 +13008,7 @@ __metadata:
languageName: node
linkType: hard
"@toeverything/theme@npm:^1.0.18, @toeverything/theme@npm:^1.0.19":
"@toeverything/theme@npm:^1.0.19, @toeverything/theme@npm:^1.0.21":
version: 1.0.21
resolution: "@toeverything/theme@npm:1.0.21"
checksum: 10/31b741d940b77716825abf7b1a4e64372add4ee4b61439fa59920403c5c615ef72f566857900663d2e5250e09a2e7eae031b0465b98d198b83fc754bac4a8c41