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

View File

@@ -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']()}

View File

@@ -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}>

View File

@@ -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';

View File

@@ -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>
);
};

View File

@@ -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'),
});

View File

@@ -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 };

View File

@@ -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');

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -0,0 +1 @@
export * from './swipe-helper';

View File

@@ -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();
}