mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
feat(core): improve mobile perf (#15317)
#### PR Dependency Tree * **PR #15317** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Virtualized mobile navigation with shell navigation and interactive swipe menus; coordinated mobile back handling with interactive phases/state restoration. * Added shared auth request proxy and message-port based token handling across mobile and worker flows. * **Bug Fixes** * Hydrated remote worker error stacks for calls and observable errors. * Improved SQLite FTS/indexer and nbstore optional text handling; refined docs-search ref parsing and notification loading/retry. * **Refactor / UX** * Modal focus-preservation and pointer behavior updates; improved mobile menu controls and back gesture plugins. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
import type {
|
||||
DropdownMenuContentProps,
|
||||
DropdownMenuItemProps as MenuItemPropsPrimitive,
|
||||
DropdownMenuPortalProps,
|
||||
DropdownMenuProps,
|
||||
DropdownMenuSubContentProps,
|
||||
DropdownMenuSubProps,
|
||||
} from '@radix-ui/react-dropdown-menu';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export interface MenuRef {
|
||||
changeOpen: (open: boolean) => void;
|
||||
@@ -25,9 +24,12 @@ export interface MenuProps {
|
||||
}
|
||||
|
||||
export interface MenuItemProps extends Omit<
|
||||
MenuItemPropsPrimitive,
|
||||
'asChild' | 'textValue' | 'prefix'
|
||||
HTMLAttributes<HTMLElement>,
|
||||
'onSelect' | 'prefix'
|
||||
> {
|
||||
disabled?: boolean;
|
||||
textValue?: string;
|
||||
onSelect?: (event: Event) => void;
|
||||
type?: 'default' | 'warning' | 'danger';
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { type MouseEvent, useCallback, useContext } from 'react';
|
||||
|
||||
import type { MenuItemProps } from '../menu.types';
|
||||
import { useMenuItem } from '../use-menu-item';
|
||||
import { MobileMenuContext } from './context';
|
||||
|
||||
let preventDefaultFlag = false;
|
||||
const preventDefault = () => {
|
||||
preventDefaultFlag = true;
|
||||
};
|
||||
|
||||
export const MobileMenuItem = (props: MenuItemProps) => {
|
||||
const { setOpen, subMenus, setSubMenus } = useContext(MobileMenuContext);
|
||||
const { className, children, otherProps } = useMenuItem(props);
|
||||
const { onSelect, onClick, divide, ...restProps } = otherProps;
|
||||
const {
|
||||
onSelect,
|
||||
onClick,
|
||||
divide,
|
||||
textValue: _textValue,
|
||||
...restProps
|
||||
} = otherProps;
|
||||
|
||||
const onItemClick = useCallback(
|
||||
(e: any) => {
|
||||
onSelect?.(e);
|
||||
onClick?.({ ...e, preventDefault });
|
||||
if (preventDefaultFlag) {
|
||||
preventDefaultFlag = false;
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
onSelect?.(event.nativeEvent);
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented || event.nativeEvent.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (subMenus.length > 1) {
|
||||
// assume the user can only click the last menu
|
||||
// (mimic the back button)
|
||||
setSubMenus(subMenus.slice(0, -1));
|
||||
} else {
|
||||
if (subMenus.length > 1) {
|
||||
// assume the user can only click the last menu
|
||||
// (mimic the back button)
|
||||
setSubMenus(subMenus.slice(0, -1));
|
||||
} else {
|
||||
setOpen?.(false);
|
||||
}
|
||||
setOpen?.(false);
|
||||
}
|
||||
},
|
||||
[onClick, onSelect, setOpen, setSubMenus, subMenus]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="menuitem"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onItemClick}
|
||||
className={className}
|
||||
disabled={props.disabled}
|
||||
data-divider={divide}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import clsx from 'clsx';
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -71,13 +71,15 @@ export const MobileMenu = ({
|
||||
const activeIndex = subMenus.length;
|
||||
|
||||
// dynamic height for slider
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (sliderElement && finalOpen) {
|
||||
const active = sliderElement.querySelector(
|
||||
`.${styles.menuContent}[data-index="${activeIndex}"]`
|
||||
);
|
||||
if (!active) return;
|
||||
|
||||
setSliderHeight(active.getBoundingClientRect().height);
|
||||
|
||||
// for the situation that content is loaded asynchronously
|
||||
return observeResize(active, entry => {
|
||||
setSliderHeight(entry.borderBoxSize[0].blockSize);
|
||||
@@ -156,6 +158,7 @@ export const MobileMenu = ({
|
||||
onOpenChange={onOpenChange}
|
||||
width="100%"
|
||||
animation="slideBottom"
|
||||
contentAnimation="none"
|
||||
withoutCloseButton={true}
|
||||
contentOptions={{
|
||||
className: clsx(className, styles.mobileMenuModal),
|
||||
@@ -163,6 +166,7 @@ export const MobileMenu = ({
|
||||
}}
|
||||
contentWrapperStyle={contentWrapperStyle}
|
||||
disableAutoFocus={true}
|
||||
preserveEditingFocusOnAction
|
||||
>
|
||||
<div
|
||||
ref={setSliderElement}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const mobileMenuModal = style({
|
||||
export const slider = style({
|
||||
display: 'flex',
|
||||
alignItems: 'start',
|
||||
transition: 'all 0.23s',
|
||||
transition: 'transform 0.23s',
|
||||
});
|
||||
|
||||
export const menuContent = style({
|
||||
@@ -42,8 +42,13 @@ export const menuContent = style({
|
||||
});
|
||||
|
||||
export const mobileMenuItem = style({
|
||||
width: '100%',
|
||||
padding: '10px 20px',
|
||||
borderRadius: 0,
|
||||
appearance: 'none',
|
||||
font: 'inherit',
|
||||
lineHeight: '22px',
|
||||
touchAction: 'manipulation',
|
||||
|
||||
':hover': {
|
||||
vars: {
|
||||
|
||||
@@ -21,7 +21,7 @@ export const MobileMenuSub = ({
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
otherProps: { onClick, ...otherTriggerOptions },
|
||||
otherProps: { onClick, textValue: _textValue, ...otherTriggerOptions },
|
||||
} = useMenuItem({
|
||||
children: propsChildren,
|
||||
suffixIcon: <ArrowRightSmallPlusIcon />,
|
||||
@@ -35,9 +35,14 @@ export const MobileMenuSub = ({
|
||||
subContentOptions={contentOptions}
|
||||
title={title}
|
||||
>
|
||||
<div role="menuitem" className={className} {...otherTriggerOptions}>
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
disabled={triggerOptions?.disabled}
|
||||
{...otherTriggerOptions}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
</MobileMenuSubRaw>
|
||||
);
|
||||
};
|
||||
@@ -50,7 +55,7 @@ export const MobileMenuSubRaw = ({
|
||||
subOptions,
|
||||
subContentOptions,
|
||||
}: MenuSubProps & {
|
||||
onClick?: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onClick?: (e: MouseEvent<HTMLElement>) => void;
|
||||
title?: string;
|
||||
}) => {
|
||||
const contentOptions = subContentOptions ?? EMPTY_SUB_CONTENT_OPTIONS;
|
||||
@@ -67,7 +72,7 @@ export const MobileMenuSubRaw = ({
|
||||
}, [addSubMenu, subMenuContent]);
|
||||
|
||||
const onItemClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>) => {
|
||||
(e: MouseEvent<HTMLElement>) => {
|
||||
onClick?.(e);
|
||||
doAddSubMenu();
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface ModalConfig {
|
||||
* add global callback for modal open,
|
||||
* return a function to handle close/unmount callback
|
||||
*/
|
||||
onOpen?: () => OnClose;
|
||||
onOpen?: (close: () => void) => OnClose;
|
||||
/**
|
||||
* For mobile
|
||||
*/
|
||||
|
||||
@@ -25,6 +25,8 @@ import { SafeArea } from '../safe-area';
|
||||
import { InsideModalContext, ModalConfigContext } from './context';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type ModalAnimation = 'fadeScaleTop' | 'none' | 'slideBottom' | 'slideRight';
|
||||
|
||||
export interface ModalProps extends DialogProps {
|
||||
width?: CSSProperties['width'];
|
||||
height?: CSSProperties['height'];
|
||||
@@ -48,17 +50,29 @@ export interface ModalProps extends DialogProps {
|
||||
/**
|
||||
* @default 'fadeScaleTop'
|
||||
*/
|
||||
animation?: 'fadeScaleTop' | 'none' | 'slideBottom' | 'slideRight';
|
||||
animation?: ModalAnimation;
|
||||
contentAnimation?: ModalAnimation;
|
||||
/**
|
||||
* Whether to show the modal in full screen mode
|
||||
*/
|
||||
fullScreen?: boolean;
|
||||
disableAutoFocus?: boolean;
|
||||
preserveEditingFocusOnAction?: boolean;
|
||||
}
|
||||
type PointerDownOutsideEvent = Parameters<
|
||||
Exclude<DialogContentProps['onPointerDownOutside'], undefined>
|
||||
>[0];
|
||||
|
||||
const isTextEditingElement = (element: Element) =>
|
||||
element instanceof HTMLInputElement ||
|
||||
element instanceof HTMLTextAreaElement ||
|
||||
(element instanceof HTMLElement && element.isContentEditable);
|
||||
|
||||
const getEnabledModalAction = (element: Element) =>
|
||||
element.closest(
|
||||
'button:not(:disabled), [role="button"]:not([aria-disabled="true"]), [role="menuitem"]:not([aria-disabled="true"]), [data-modal-action]:not([aria-disabled="true"])'
|
||||
);
|
||||
|
||||
const getVar = (style: number | string = '', defaultValue = '') => {
|
||||
return style
|
||||
? typeof style === 'number'
|
||||
@@ -151,6 +165,7 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
style: contentStyle,
|
||||
className: contentClassName,
|
||||
onPointerDownOutside,
|
||||
onPointerDown,
|
||||
onEscapeKeyDown,
|
||||
...otherContentOptions
|
||||
} = {},
|
||||
@@ -164,8 +179,10 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
contentWrapperClassName,
|
||||
contentWrapperStyle,
|
||||
animation = BUILD_CONFIG.isMobileEdition ? 'slideBottom' : 'fadeScaleTop',
|
||||
contentAnimation = animation,
|
||||
fullScreen,
|
||||
disableAutoFocus,
|
||||
preserveEditingFocusOnAction = false,
|
||||
...otherProps
|
||||
} = props;
|
||||
const { className: closeButtonClassName, ...otherCloseButtonProps } =
|
||||
@@ -176,9 +193,9 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return modalConfigOnOpen?.();
|
||||
if (open) return modalConfigOnOpen?.(() => onOpenChange?.(false));
|
||||
return;
|
||||
}, [modalConfigOnOpen, open]);
|
||||
}, [modalConfigOnOpen, onOpenChange, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -211,6 +228,29 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
[onEscapeKeyDown, persistent]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
onPointerDown?.(event);
|
||||
if (
|
||||
!preserveEditingFocusOnAction ||
|
||||
event.defaultPrevented ||
|
||||
!(event.target instanceof Element)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement &&
|
||||
event.currentTarget.contains(activeElement) &&
|
||||
isTextEditingElement(activeElement) &&
|
||||
getEnabledModalAction(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[onPointerDown, preserveEditingFocusOnAction]
|
||||
);
|
||||
|
||||
const handleAutoFocus = useCallback(
|
||||
(e: Event) => {
|
||||
disableAutoFocus && e.preventDefault();
|
||||
@@ -244,19 +284,34 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
>
|
||||
<SafeArea
|
||||
bottom={BUILD_CONFIG.isMobileEdition}
|
||||
bottomOffset={dynamicKeyboardHeight ?? 12}
|
||||
bottomOffset={
|
||||
fullScreen
|
||||
? 0
|
||||
: dynamicKeyboardHeight === undefined
|
||||
? 12
|
||||
: `calc(${getVar(dynamicKeyboardHeight)} + 12px)`
|
||||
}
|
||||
data-full-screen={fullScreen}
|
||||
data-modal={modal}
|
||||
className={clsx(
|
||||
`anim-${animation}`,
|
||||
`anim-${contentAnimation}`,
|
||||
styles.modalContentWrapper,
|
||||
contentWrapperClassName
|
||||
)}
|
||||
data-mobile={BUILD_CONFIG.isMobileEdition ? '' : undefined}
|
||||
style={contentWrapperStyle}
|
||||
style={{
|
||||
...assignInlineVars({
|
||||
[styles.keyboardInsetVar]: getVar(
|
||||
dynamicKeyboardHeight,
|
||||
'0px'
|
||||
),
|
||||
}),
|
||||
...contentWrapperStyle,
|
||||
}}
|
||||
>
|
||||
<Dialog.Content
|
||||
onPointerDownOutside={handlePointerDownOutSide}
|
||||
onPointerDown={handlePointerDown}
|
||||
onEscapeKeyDown={handleEscapeKeyDown}
|
||||
className={clsx(styles.modalContent, contentClassName)}
|
||||
onOpenAutoFocus={handleAutoFocus}
|
||||
@@ -268,9 +323,13 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
),
|
||||
[styles.heightVar]: getVar(
|
||||
height,
|
||||
fullScreen ? '100dvh' : 'unset'
|
||||
fullScreen
|
||||
? `calc(100dvh - ${styles.keyboardInsetVar})`
|
||||
: 'unset'
|
||||
),
|
||||
[styles.minHeightVar]: getVar(minHeight, '26px'),
|
||||
[styles.minHeightVar]: fullScreen
|
||||
? `calc(100dvh - ${styles.keyboardInsetVar})`
|
||||
: getVar(minHeight, '26px'),
|
||||
}),
|
||||
...contentStyle,
|
||||
}}
|
||||
|
||||
@@ -86,6 +86,7 @@ export const PromptModal = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
preserveEditingFocusOnAction={BUILD_CONFIG.isMobileEdition}
|
||||
contentOptions={{
|
||||
className: styles.container,
|
||||
onPointerDownOutside: e => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { vtScopeSelector } from '../../utils/view-transition';
|
||||
export const widthVar = createVar('widthVar');
|
||||
export const heightVar = createVar('heightVar');
|
||||
export const minHeightVar = createVar('minHeightVar');
|
||||
export const keyboardInsetVar = createVar('keyboardInsetVar');
|
||||
|
||||
export const modalVTScope = generateIdentifier('modal');
|
||||
|
||||
@@ -89,14 +90,24 @@ export const modalContentWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
zIndex: cssVar('zIndexModal'),
|
||||
transition: 'padding-bottom 230ms ease-out',
|
||||
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
selectors: {
|
||||
'&[data-mobile]': {
|
||||
alignItems: 'flex-end',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 20px)',
|
||||
paddingBottom:
|
||||
'var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 20px))',
|
||||
},
|
||||
'&[data-full-screen="true"]': {
|
||||
alignItems: 'flex-start',
|
||||
padding: '0 !important',
|
||||
},
|
||||
'&.anim-none': {
|
||||
@@ -174,8 +185,8 @@ export const modalContent = style({
|
||||
'[data-full-screen="true"] &': {
|
||||
vars: {
|
||||
[widthVar]: '100dvw',
|
||||
[heightVar]: '100dvh',
|
||||
[minHeightVar]: '100dvh',
|
||||
[heightVar]: `calc(100dvh - ${keyboardInsetVar})`,
|
||||
[minHeightVar]: `calc(100dvh - ${keyboardInsetVar})`,
|
||||
},
|
||||
maxWidth: '100dvw',
|
||||
maxHeight: '100dvh',
|
||||
|
||||
@@ -14,7 +14,7 @@ export function MobileNotificationCenter() {
|
||||
position="top-center"
|
||||
style={{
|
||||
width: '100%',
|
||||
top: 'calc(env(safe-area-inset-top) + 16px)',
|
||||
top: 'calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 16px)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
theme={resolvedTheme}
|
||||
|
||||
@@ -12,10 +12,10 @@ export const safeArea = style({
|
||||
paddingBottom: `calc(${fallbackVar(bottomOffsetVar, '0px')} + 0px)`,
|
||||
},
|
||||
'&[data-standalone][data-top]': {
|
||||
paddingTop: `calc(env(safe-area-inset-top, 12px) + ${topOffsetVar})`,
|
||||
paddingTop: `calc(var(--safe-area-inset-top, env(safe-area-inset-top, 12px)) + ${topOffsetVar})`,
|
||||
},
|
||||
'&[data-standalone][data-bottom]': {
|
||||
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${fallbackVar(bottomOffsetVar, '0px')})`,
|
||||
paddingBottom: `calc(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) + ${fallbackVar(bottomOffsetVar, '0px')})`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user