feat(mobile): a basic swipeable dialog for setting (#8923)

This commit is contained in:
@@ -7,6 +7,7 @@ import { AllDocsIcon, MobileHomeIcon } from '@blocksuite/icons/rc';
|
|||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import type { Location } from 'react-router-dom';
|
import type { Location } from 'react-router-dom';
|
||||||
|
|
||||||
import { VirtualKeyboardService } from '../../modules/virtual-keyboard/services/virtual-keyboard';
|
import { VirtualKeyboardService } from '../../modules/virtual-keyboard/services/virtual-keyboard';
|
||||||
@@ -58,7 +59,7 @@ export const AppTabs = ({ background }: { background?: string }) => {
|
|||||||
const virtualKeyboardService = useService(VirtualKeyboardService);
|
const virtualKeyboardService = useService(VirtualKeyboardService);
|
||||||
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
|
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<SafeArea
|
<SafeArea
|
||||||
bottom
|
bottom
|
||||||
className={styles.appTabs}
|
className={styles.appTabs}
|
||||||
@@ -81,7 +82,8 @@ export const AppTabs = ({ background }: { background?: string }) => {
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</SafeArea>
|
</SafeArea>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Modal, Scrollable, Switch } from '@affine/component';
|
import { Switch } from '@affine/component';
|
||||||
import { PageHeader } from '@affine/core/mobile/components';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +12,7 @@ import { useCallback, useState } from 'react';
|
|||||||
|
|
||||||
import { SettingGroup } from '../group';
|
import { SettingGroup } from '../group';
|
||||||
import { RowLayout } from '../row.layout';
|
import { RowLayout } from '../row.layout';
|
||||||
|
import { SwipeDialog } from '../swipe-dialog';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
export const ExperimentalFeatureSetting = () => {
|
export const ExperimentalFeatureSetting = () => {
|
||||||
@@ -28,30 +28,21 @@ export const ExperimentalFeatureSetting = () => {
|
|||||||
<ArrowRightSmallIcon fontSize={22} />
|
<ArrowRightSmallIcon fontSize={22} />
|
||||||
</RowLayout>
|
</RowLayout>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
<Modal
|
<SwipeDialog
|
||||||
animation="slideRight"
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
fullScreen
|
title="Experimental Features"
|
||||||
contentOptions={{ className: styles.dialog }}
|
|
||||||
withoutCloseButton
|
|
||||||
>
|
>
|
||||||
<ExperimentalFeatureList onBack={() => setOpen(false)} />
|
<ExperimentalFeatureList />
|
||||||
</Modal>
|
</SwipeDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ExperimentalFeatureList = ({ onBack }: { onBack: () => void }) => {
|
const ExperimentalFeatureList = () => {
|
||||||
const featureFlagService = useService(FeatureFlagService);
|
const featureFlagService = useService(FeatureFlagService);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
|
||||||
<PageHeader back={!!onBack} backAction={onBack} className={styles.header}>
|
|
||||||
<span className={styles.dialogTitle}>Experimental Features</span>
|
|
||||||
</PageHeader>
|
|
||||||
<Scrollable.Root className={styles.scrollArea}>
|
|
||||||
<Scrollable.Viewport>
|
|
||||||
<ul className={styles.content}>
|
<ul className={styles.content}>
|
||||||
{Object.keys(AFFINE_FLAGS).map(key => (
|
{Object.keys(AFFINE_FLAGS).map(key => (
|
||||||
<ExperimentalFeaturesItem
|
<ExperimentalFeaturesItem
|
||||||
@@ -60,10 +51,6 @@ const ExperimentalFeatureList = ({ onBack }: { onBack: () => void }) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Scrollable.Viewport>
|
|
||||||
<Scrollable.Scrollbar orientation="vertical" />
|
|
||||||
</Scrollable.Root>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,7 @@
|
|||||||
import {
|
import { bodyRegular, footnoteRegular } from '@toeverything/theme/typography';
|
||||||
bodyEmphasized,
|
|
||||||
bodyRegular,
|
|
||||||
footnoteRegular,
|
|
||||||
} from '@toeverything/theme/typography';
|
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const dialog = style({
|
|
||||||
padding: '0 !important',
|
|
||||||
background: cssVarV2('layer/background/mobile/primary'),
|
|
||||||
});
|
|
||||||
export const root = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100dvh',
|
|
||||||
});
|
|
||||||
export const header = style({
|
|
||||||
background: `${cssVarV2('layer/background/mobile/primary')} !important`,
|
|
||||||
});
|
|
||||||
export const dialogTitle = style([bodyEmphasized, {}]);
|
|
||||||
export const scrollArea = style({
|
|
||||||
height: 0,
|
|
||||||
flex: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const content = style({
|
export const content = style({
|
||||||
padding: '24px 16px',
|
padding: '24px 16px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ConfigModal } from '@affine/core/components/mobile';
|
|
||||||
import { AuthService } from '@affine/core/modules/cloud';
|
import { AuthService } from '@affine/core/modules/cloud';
|
||||||
import type {
|
import type {
|
||||||
DialogComponentProps,
|
DialogComponentProps,
|
||||||
@@ -13,6 +12,7 @@ import { AppearanceGroup } from './appearance';
|
|||||||
import { ExperimentalFeatureSetting } from './experimental';
|
import { ExperimentalFeatureSetting } from './experimental';
|
||||||
import { OthersGroup } from './others';
|
import { OthersGroup } from './others';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
import { SwipeDialog } from './swipe-dialog';
|
||||||
import { UserProfile } from './user-profile';
|
import { UserProfile } from './user-profile';
|
||||||
import { UserUsage } from './user-usage';
|
import { UserUsage } from './user-usage';
|
||||||
|
|
||||||
@@ -38,13 +38,23 @@ export const SettingDialog = ({
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigModal
|
<SwipeDialog
|
||||||
title={t['com.affine.mobile.setting.header-title']()}
|
title={t['com.affine.mobile.setting.header-title']()}
|
||||||
open
|
open
|
||||||
onOpenChange={() => close()}
|
onOpenChange={() => close()}
|
||||||
onBack={close}
|
|
||||||
>
|
>
|
||||||
<MobileSetting />
|
<MobileSetting />
|
||||||
</ConfigModal>
|
</SwipeDialog>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <ConfigModal
|
||||||
|
// title={t['com.affine.mobile.setting.header-title']()}
|
||||||
|
// open
|
||||||
|
// onOpenChange={() => close()}
|
||||||
|
// onBack={close}
|
||||||
|
// >
|
||||||
|
// <MobileSetting />
|
||||||
|
// </ConfigModal>
|
||||||
|
// );
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const root = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 16,
|
gap: 16,
|
||||||
|
padding: '24px 16px',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const baseSettingItem = style({
|
export const baseSettingItem = style({
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { bodyEmphasized } from '@toeverything/theme/typography';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { createVar, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
zIndex: cssVar('zIndexModal'),
|
||||||
|
width: '100dvw',
|
||||||
|
height: '100dvh',
|
||||||
|
});
|
||||||
|
export const overlay = style({
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
});
|
||||||
|
export const dialog = style([
|
||||||
|
overlay,
|
||||||
|
{
|
||||||
|
padding: 0,
|
||||||
|
background: cssVarV2('layer/background/mobile/primary'),
|
||||||
|
// initial state,
|
||||||
|
transform: 'translateX(100%)',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const content = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100dvh',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const header = style({
|
||||||
|
background: `${cssVarV2('layer/background/mobile/primary')} !important`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dialogTitle = style([bodyEmphasized, {}]);
|
||||||
|
export const scrollArea = style({
|
||||||
|
height: 0,
|
||||||
|
flex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const triggerSizeVar = createVar('triggerSize');
|
||||||
|
export const swipeBackTrigger = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: triggerSizeVar,
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 1,
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { Scrollable } from '@affine/component';
|
||||||
|
import { PageHeader } from '@affine/core/mobile/components';
|
||||||
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
|
import anime from 'animejs';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type PropsWithChildren,
|
||||||
|
type RefObject,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { SwipeHelper } from '../../utils';
|
||||||
|
import * as styles from './swipe-dialog.css';
|
||||||
|
|
||||||
|
export interface SwipeDialogProps extends PropsWithChildren {
|
||||||
|
triggerSize?: number;
|
||||||
|
title?: string;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayOpacityRange = [0, 0.1];
|
||||||
|
|
||||||
|
const tick = (
|
||||||
|
overlay: HTMLDivElement,
|
||||||
|
dialog: HTMLDivElement,
|
||||||
|
prev: HTMLElement | null,
|
||||||
|
deltaX: number
|
||||||
|
) => {
|
||||||
|
const limitedDeltaX = Math.min(overlay.clientWidth, Math.max(0, deltaX));
|
||||||
|
const percent = limitedDeltaX / overlay.clientWidth;
|
||||||
|
const opacity =
|
||||||
|
overlayOpacityRange[1] -
|
||||||
|
(overlayOpacityRange[1] - overlayOpacityRange[0]) * percent;
|
||||||
|
overlay.style.background = `rgba(0, 0, 0, ${opacity})`;
|
||||||
|
dialog.style.transform = `translateX(${limitedDeltaX}px)`;
|
||||||
|
|
||||||
|
const prevEl = prev ?? document.querySelector('#app');
|
||||||
|
if (prevEl) {
|
||||||
|
const range = [-80, 0];
|
||||||
|
const offset = range[0] + (range[1] - range[0]) * percent;
|
||||||
|
prevEl.style.transform = `translateX(${offset}px)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const reset = (
|
||||||
|
overlay: HTMLDivElement,
|
||||||
|
dialog: HTMLDivElement,
|
||||||
|
prev: HTMLElement | null
|
||||||
|
) => {
|
||||||
|
overlay && (overlay.style.background = 'transparent');
|
||||||
|
dialog && (dialog.style.transform = 'unset');
|
||||||
|
const prevEl = prev ?? document.querySelector('#app');
|
||||||
|
if (prevEl) {
|
||||||
|
prevEl.style.transform = 'unset';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAnimeProxy = (
|
||||||
|
overlay: HTMLDivElement,
|
||||||
|
dialog: HTMLDivElement,
|
||||||
|
prev: HTMLElement | null,
|
||||||
|
init: number
|
||||||
|
) => {
|
||||||
|
return new Proxy(
|
||||||
|
{ deltaX: init },
|
||||||
|
{
|
||||||
|
set(target, key, value) {
|
||||||
|
if (key === 'deltaX') {
|
||||||
|
target.deltaX = value;
|
||||||
|
tick(overlay, dialog, prev, value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = (
|
||||||
|
overlay: HTMLDivElement,
|
||||||
|
dialog: HTMLDivElement,
|
||||||
|
prev: HTMLElement | null,
|
||||||
|
deltaX: number,
|
||||||
|
complete?: () => void
|
||||||
|
) => {
|
||||||
|
anime({
|
||||||
|
targets: getAnimeProxy(
|
||||||
|
overlay,
|
||||||
|
dialog,
|
||||||
|
prev,
|
||||||
|
Math.min(overlay.clientWidth, Math.max(0, deltaX))
|
||||||
|
),
|
||||||
|
deltaX: 0,
|
||||||
|
easing: 'easeInOutSine',
|
||||||
|
duration: 230,
|
||||||
|
complete: () => {
|
||||||
|
complete?.();
|
||||||
|
setTimeout(() => {
|
||||||
|
reset(overlay, dialog, prev);
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = (
|
||||||
|
overlay: HTMLDivElement,
|
||||||
|
dialog: HTMLDivElement,
|
||||||
|
prev: HTMLElement | null,
|
||||||
|
deltaX: number,
|
||||||
|
complete?: () => void
|
||||||
|
) => {
|
||||||
|
anime({
|
||||||
|
targets: getAnimeProxy(
|
||||||
|
overlay,
|
||||||
|
dialog,
|
||||||
|
prev,
|
||||||
|
Math.min(overlay.clientWidth, Math.max(0, deltaX))
|
||||||
|
),
|
||||||
|
deltaX: overlay.clientWidth,
|
||||||
|
easing: 'easeInOutSine',
|
||||||
|
duration: 230,
|
||||||
|
complete: () => {
|
||||||
|
complete?.();
|
||||||
|
setTimeout(() => {
|
||||||
|
reset(overlay, dialog, prev);
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const SwipeDialogContext = createContext<{
|
||||||
|
stack: Array<RefObject<HTMLElement>>;
|
||||||
|
}>({
|
||||||
|
stack: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SwipeDialog = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
open,
|
||||||
|
triggerSize = 10,
|
||||||
|
onOpenChange,
|
||||||
|
}: SwipeDialogProps) => {
|
||||||
|
const swiperTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { stack } = useContext(SwipeDialogContext);
|
||||||
|
const prev = stack[stack.length - 1]?.current;
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
onOpenChange?.(false);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
const animateClose = useCallback(() => {
|
||||||
|
const overlay = overlayRef.current;
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (overlay && dialog) {
|
||||||
|
close(overlay, dialog, prev, 0, handleClose);
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}, [handleClose, prev]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const overlay = overlayRef.current;
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
const swipeBackTrigger = swiperTriggerRef.current;
|
||||||
|
if (!overlay || !dialog || !swipeBackTrigger) return;
|
||||||
|
|
||||||
|
const swipeHelper = new SwipeHelper();
|
||||||
|
return swipeHelper.init(swipeBackTrigger, {
|
||||||
|
preventScroll: true,
|
||||||
|
onSwipeStart: () => {},
|
||||||
|
onSwipe({ deltaX }) {
|
||||||
|
tick(overlay, dialog, prev, deltaX);
|
||||||
|
},
|
||||||
|
onSwipeEnd: ({ deltaX }) => {
|
||||||
|
const shouldClose = deltaX > overlay.clientWidth * 0.2;
|
||||||
|
if (shouldClose) {
|
||||||
|
close(overlay, dialog, prev, deltaX, handleClose);
|
||||||
|
} else {
|
||||||
|
cancel(overlay, dialog, prev, deltaX);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [handleClose, open, prev]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const overlay = overlayRef.current;
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (overlay && dialog) {
|
||||||
|
cancel(overlay, dialog, prev, overlay.clientWidth);
|
||||||
|
}
|
||||||
|
}, [open, prev]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeDialogContext.Provider value={{ stack: [...stack, dialogRef] }}>
|
||||||
|
{createPortal(
|
||||||
|
<div className={styles.root}>
|
||||||
|
<div className={styles.overlay} ref={overlayRef} />
|
||||||
|
<div role="dialog" className={styles.dialog} ref={dialogRef}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<PageHeader
|
||||||
|
back
|
||||||
|
backAction={animateClose}
|
||||||
|
className={styles.header}
|
||||||
|
>
|
||||||
|
<span className={styles.dialogTitle}>{title}</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Scrollable.Root className={styles.scrollArea}>
|
||||||
|
<Scrollable.Viewport>{children}</Scrollable.Viewport>
|
||||||
|
<Scrollable.Scrollbar orientation="vertical" />
|
||||||
|
</Scrollable.Root>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={swiperTriggerRef}
|
||||||
|
className={styles.swipeBackTrigger}
|
||||||
|
style={assignInlineVars({
|
||||||
|
[styles.triggerSizeVar]: `${triggerSize}px`,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</SwipeDialogContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user