feat(mobile): disable swipe back gesture when there is no back in header (#8876)

close AF-1663, AF-1756

- new global `ModalConfigContext`
- new logic to judge whether inside modal
- render `✕` for PageHeader back if inside modal
- only enable `NavigationGesture` when there is `<` in PageHeader
This commit is contained in:
CatsJuice
2024-11-25 03:12:21 +00:00
parent 922db5ced4
commit b369ee0cca
23 changed files with 260 additions and 26 deletions

View File

@@ -1,5 +1,6 @@
export * from './app-tabs';
export * from './doc-card';
export * from './page-header';
export * from './rename';
export * from './search-input';
export * from './search-result';

View File

@@ -0,0 +1,132 @@
import { IconButton, SafeArea, useIsInsideModal } from '@affine/component';
import { ArrowLeftSmallIcon, CloseIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type HtmlHTMLAttributes,
type ReactNode,
useCallback,
useEffect,
} from 'react';
import { NavigationGestureService } from '../../modules/navigation-gesture';
import * as styles from './styles.css';
export interface PageHeaderProps
extends Omit<HtmlHTMLAttributes<HTMLHeadElement>, 'prefix'> {
/**
* whether to show back button
*/
back?: boolean;
/**
* Override back button action
*/
backAction?: () => void;
/**
* prefix content, shown after back button(if exists)
*/
prefix?: ReactNode;
/**
* suffix content
*/
suffix?: ReactNode;
/**
* Weather to center the content
* @default true
*/
centerContent?: boolean;
prefixClassName?: string;
prefixStyle?: React.CSSProperties;
suffixClassName?: string;
suffixStyle?: React.CSSProperties;
}
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
function PageHeader(
{
back,
backAction,
prefix,
suffix,
children,
className,
centerContent = true,
prefixClassName,
prefixStyle,
suffixClassName,
suffixStyle,
...attrs
},
ref
) {
const navigationGesture = useService(NavigationGestureService);
const isInsideModal = useIsInsideModal();
useEffect(() => {
if (isInsideModal) return;
const prev = navigationGesture.enabled$.value;
navigationGesture.setEnabled(!!back);
return () => {
navigationGesture.setEnabled(prev);
};
}, [back, isInsideModal, navigationGesture]);
const handleRouteBack = useCallback(() => {
backAction ? backAction() : history.back();
}, [backAction]);
return (
<>
<SafeArea
top
ref={ref}
className={clsx(styles.root, className)}
data-testid="mobile-page-header"
{...attrs}
>
<header className={styles.inner}>
<section
className={clsx(styles.prefix, prefixClassName)}
style={prefixStyle}
>
{back ? (
<IconButton
size={24}
style={{ padding: 10 }}
onClick={handleRouteBack}
icon={isInsideModal ? <CloseIcon /> : <ArrowLeftSmallIcon />}
data-testid="page-header-back"
/>
) : null}
{prefix}
</section>
<section
className={clsx(styles.content, { center: centerContent })}
>
{children}
</section>
<section
className={clsx(styles.suffix, suffixClassName)}
style={suffixStyle}
>
{suffix}
</section>
</header>
</SafeArea>
{/* Spacer */}
<SafeArea top>
<div className={styles.headerSpacer} />
</SafeArea>
</>
);
}
);

View File

@@ -0,0 +1,52 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
width: '100%',
position: 'fixed',
top: 0,
zIndex: 1,
backgroundColor: cssVarV2('layer/background/secondary'),
});
export const headerSpacer = style({
height: 44,
});
export const inner = style({
height: 44,
padding: '0 6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const content = style({
selectors: {
'&.center': {
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
width: 'fit-content',
maxWidth: 'calc(100% - 12px - 88px - 16px)',
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
},
'&:not(.center)': {
width: 0,
flex: 1,
},
},
});
export const spacer = style({
width: 0,
flex: 1,
});
export const prefix = style({
display: 'flex',
alignItems: 'center',
gap: 0,
});
export const suffix = style({
display: 'flex',
alignItems: 'center',
gap: 6,
});

View File

@@ -5,7 +5,7 @@ import {
Scrollable,
useThemeColorMeta,
} from '@affine/component';
import { PageHeader } from '@affine/core/components/mobile';
import { PageHeader } from '@affine/core/mobile/components';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';

View File

@@ -1,9 +1,11 @@
import type { Framework } from '@toeverything/infra';
import { configureMobileNavigationGestureModule } from './navigation-gesture';
import { configureMobileSearchModule } from './search';
import { configureMobileVirtualKeyboardModule } from './virtual-keyboard';
export function configureMobileModules(framework: Framework) {
configureMobileSearchModule(framework);
configureMobileVirtualKeyboardModule(framework);
configureMobileNavigationGestureModule(framework);
}

View File

@@ -0,0 +1,13 @@
import type { Framework } from '@toeverything/infra';
import { NavigationGestureProvider } from './providers/navigation-gesture';
import { NavigationGestureService } from './services/navigation-gesture';
export { NavigationGestureProvider, NavigationGestureService };
export function configureMobileNavigationGestureModule(framework: Framework) {
framework.service(
NavigationGestureService,
f => new NavigationGestureService(f.getOptional(NavigationGestureProvider))
);
}

View File

@@ -0,0 +1,10 @@
import { createIdentifier } from '@toeverything/infra';
export interface NavigationGestureProvider {
isEnabled: () => Promise<boolean>;
enable: () => Promise<void>;
disable: () => Promise<void>;
}
export const NavigationGestureProvider =
createIdentifier<NavigationGestureProvider>('NavigationGestureProvider');

View File

@@ -0,0 +1,58 @@
import { DebugLogger } from '@affine/debug';
import {
effect,
exhaustMapWithTrailing,
fromPromise,
LiveData,
Service,
} from '@toeverything/infra';
import { catchError, distinctUntilChanged, EMPTY, mergeMap } from 'rxjs';
import type { NavigationGestureProvider } from '../providers/navigation-gesture';
const logger = new DebugLogger('affine:navigation-gesture');
export class NavigationGestureService extends Service {
public enabled$ = new LiveData(false);
constructor(
private readonly navigationGestureProvider?: NavigationGestureProvider
) {
super();
}
setEnabled = effect(
distinctUntilChanged<boolean>(),
exhaustMapWithTrailing((enable: boolean) => {
return fromPromise(async () => {
if (!this.navigationGestureProvider) {
return;
}
if (enable) {
await this.enable();
} else {
await this.disable();
}
return;
}).pipe(
mergeMap(() => EMPTY),
catchError(err => {
logger.error('navigationGestureProvider error', err);
return EMPTY;
})
);
})
);
async enable() {
this.enabled$.next(true);
logger.debug(`Enable navigation gesture`);
return this.navigationGestureProvider?.enable();
}
async disable() {
this.enabled$.next(false);
logger.debug(`Disable navigation gesture`);
return this.navigationGestureProvider?.disable();
}
}

View File

@@ -7,9 +7,9 @@ import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-
import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state';
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { PageHeader } from '@affine/core/components/mobile';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper';
import { PageHeader } from '@affine/core/mobile/components';
import { EditorService } from '@affine/core/modules/editor';
import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchService } from '@affine/core/modules/workbench';
@@ -202,19 +202,22 @@ const DetailPageImpl = () => {
);
};
const skeleton = (
const getSkeleton = (back: boolean) => (
<>
<PageHeader back className={styles.header} />
<PageHeader back={back} className={styles.header} />
<PageDetailSkeleton />
</>
);
const notFound = (
const getNotFound = (back: boolean) => (
<>
<PageHeader back className={styles.header} />
<PageHeader back={back} className={styles.header} />
Page Not Found (TODO)
</>
);
const skeleton = getSkeleton(false);
const skeletonWithBack = getSkeleton(true);
const notFound = getNotFound(false);
const notFoundWithBack = getNotFound(true);
const MobileDetailPage = ({
pageId,
@@ -237,12 +240,12 @@ const MobileDetailPage = ({
return (
<div className={styles.root}>
<DetailPageWrapper
skeleton={skeleton}
notFound={notFound}
skeleton={date ? skeleton : skeletonWithBack}
notFound={date ? notFound : notFoundWithBack}
pageId={pageId}
>
<PageHeader
back
back={!date}
className={styles.header}
suffix={
<>

View File

@@ -1,7 +1,7 @@
import { IconButton, MobileMenu } from '@affine/component';
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
import { PageHeader } from '@affine/core/components/mobile';
import { isEmptyCollection } from '@affine/core/desktop/pages/workspace/collection';
import { PageHeader } from '@affine/core/mobile/components';
import type { Collection } from '@affine/env/filter';
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons/rc';

View File

@@ -1,5 +1,5 @@
import { IconButton, MobileMenu } from '@affine/component';
import { PageHeader } from '@affine/core/components/mobile';
import { PageHeader } from '@affine/core/mobile/components';
import type { Tag } from '@affine/core/modules/tag';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';