diff --git a/packages/frontend/apps/ios/App/Podfile b/packages/frontend/apps/ios/App/Podfile index 804c58d3b0..2c3e57dd14 100644 --- a/packages/frontend/apps/ios/App/Podfile +++ b/packages/frontend/apps/ios/App/Podfile @@ -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 diff --git a/packages/frontend/apps/ios/App/Podfile.lock b/packages/frontend/apps/ios/App/Podfile.lock index 21f407e744..934d930c0e 100644 --- a/packages/frontend/apps/ios/App/Podfile.lock +++ b/packages/frontend/apps/ios/App/Podfile.lock @@ -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 diff --git a/packages/frontend/apps/ios/package.json b/packages/frontend/apps/ios/package.json index 1c007e26d0..652a68885c 100644 --- a/packages/frontend/apps/ios/package.json +++ b/packages/frontend/apps/ios/package.json @@ -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", diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index c594817f9b..3175dcf416 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -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 diff --git a/packages/frontend/apps/mobile/src/app.tsx b/packages/frontend/apps/mobile/src/app.tsx index 6396cf2054..e05bdf78b9 100644 --- a/packages/frontend/apps/mobile/src/app.tsx +++ b/packages/frontend/apps/mobile/src/app.tsx @@ -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 diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index b22f9bb0eb..3faab6d909 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -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", diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index cfab2c4872..71d496d022 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -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", diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx index 5902721cfd..d644af9c88 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx @@ -233,7 +233,7 @@ export const useExplorerDocNodeOperationsMenu = ( ), }, { - index: 99, + index: 97, view: ( } @@ -244,7 +244,7 @@ export const useExplorerDocNodeOperationsMenu = ( ), }, { - index: 99, + index: 98, view: ( } onClick={handleDuplicate}> {t['com.affine.header.option.duplicate']()} diff --git a/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx b/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx index cecf8f6865..7fddd94179 100644 --- a/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx @@ -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(null); + const [menuOpen, setMenuOpen] = useState(false); const { emoji, name } = useMemo(() => { if (!extractEmojiAsIcon || !rawName) { @@ -160,15 +162,32 @@ export const ExplorerTreeNode = ({ ref={rootRef} {...otherProps} > -
- {to ? ( - - {content} - - ) : ( -
{content}
- )} -
+ setMenuOpen(true), [])} + menu={ + ({ open: menuOpen, onOpenChange: setMenuOpen }), + [menuOpen] + )} + items={menuOperations.map(({ view, index }) => ( + {view} + ))} + > + + + } + > +
+ {to ? ( + + {content} + + ) : ( +
{content}
+ )} +
+
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
diff --git a/packages/frontend/core/src/mobile/components/index.ts b/packages/frontend/core/src/mobile/components/index.ts index 81bb7db8a3..f0334eda3f 100644 --- a/packages/frontend/core/src/mobile/components/index.ts +++ b/packages/frontend/core/src/mobile/components/index.ts @@ -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'; diff --git a/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx b/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx new file mode 100644 index 0000000000..6e1b0339d9 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/swipe-menu/index.tsx @@ -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 { + 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 = 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(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(null); + const menuRef = useRef(null); + const contentRef = useRef(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 ( +
+
+ {children} +
+
+ {menu} +
+
+ ); +}; diff --git a/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts b/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts new file mode 100644 index 0000000000..2b7cf84954 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/swipe-menu/styles.css.ts @@ -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'), +}); diff --git a/packages/frontend/core/src/mobile/modules/haptics/index.ts b/packages/frontend/core/src/mobile/modules/haptics/index.ts new file mode 100644 index 0000000000..18849ccfa1 --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/haptics/index.ts @@ -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 }; diff --git a/packages/frontend/core/src/mobile/modules/haptics/providers/haptic.ts b/packages/frontend/core/src/mobile/modules/haptics/providers/haptic.ts new file mode 100644 index 0000000000..9c91ab216e --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/haptics/providers/haptic.ts @@ -0,0 +1,15 @@ +import { createIdentifier } from '@toeverything/infra'; + +export interface HapticProvider { + impact: (options?: { style?: 'HEAVY' | 'LIGHT' | 'MEDIUM' }) => Promise; + notification: (options?: { + type?: 'SUCCESS' | 'ERROR' | 'WARNING'; + }) => Promise; + vibrate: (options?: { duration?: number }) => Promise; + selectionStart: () => Promise; + selectionChanged: () => Promise; + selectionEnd: () => Promise; +} + +export const HapticProvider = + createIdentifier('HapticProvider'); diff --git a/packages/frontend/core/src/mobile/modules/haptics/services/haptics.ts b/packages/frontend/core/src/mobile/modules/haptics/services/haptics.ts new file mode 100644 index 0000000000..49e073319d --- /dev/null +++ b/packages/frontend/core/src/mobile/modules/haptics/services/haptics.ts @@ -0,0 +1,37 @@ +import { Service } from '@toeverything/infra'; + +import type { HapticProvider } from '../providers/haptic'; + +type ExtractArg = 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); + } +} diff --git a/packages/frontend/core/src/mobile/modules/index.ts b/packages/frontend/core/src/mobile/modules/index.ts index c316ca05fb..3c57e3c9eb 100644 --- a/packages/frontend/core/src/mobile/modules/index.ts +++ b/packages/frontend/core/src/mobile/modules/index.ts @@ -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); } diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx index 3a4a861618..80df2caa39 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/month.tsx @@ -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); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx index 061292e198..1d21537a79 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/viewport.tsx @@ -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 diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx index 63f277fd0e..7447dcc66e 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/week.tsx @@ -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); diff --git a/packages/frontend/core/src/mobile/utils/index.ts b/packages/frontend/core/src/mobile/utils/index.ts new file mode 100644 index 0000000000..72fef474f5 --- /dev/null +++ b/packages/frontend/core/src/mobile/utils/index.ts @@ -0,0 +1 @@ +export * from './swipe-helper'; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts b/packages/frontend/core/src/mobile/utils/swipe-helper.ts similarity index 57% rename from packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts rename to packages/frontend/core/src/mobile/utils/swipe-helper.ts index 2946ee138e..0802a5deed 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/journal-date-picker/swipe-helper.ts +++ b/packages/frontend/core/src/mobile/utils/swipe-helper.ts @@ -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(); } diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 28bb1fb5c1..8d36f8bea8 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -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 } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c7b8b7fc9a..6a425c271b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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