mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-21 05:01:34 +03:00
feat(mobile): a basic swipeable dialog for setting (#8923)
![CleanShot 2024-12-02 at 15.42.42.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/0a6e5700-dd25-4f36-824a-434603e01379.gif)
This commit is contained in:
parent
9b4cd83a07
commit
4744896031
@ -7,6 +7,7 @@ import { AllDocsIcon, MobileHomeIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Location } from 'react-router-dom';
|
||||
|
||||
import { VirtualKeyboardService } from '../../modules/virtual-keyboard/services/virtual-keyboard';
|
||||
@ -58,7 +59,7 @@ export const AppTabs = ({ background }: { background?: string }) => {
|
||||
const virtualKeyboardService = useService(VirtualKeyboardService);
|
||||
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<SafeArea
|
||||
bottom
|
||||
className={styles.appTabs}
|
||||
@ -81,7 +82,8 @@ export const AppTabs = ({ background }: { background?: string }) => {
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</SafeArea>
|
||||
</SafeArea>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Modal, Scrollable, Switch } from '@affine/component';
|
||||
import { PageHeader } from '@affine/core/mobile/components';
|
||||
import { Switch } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
@ -13,6 +12,7 @@ import { useCallback, useState } from 'react';
|
||||
|
||||
import { SettingGroup } from '../group';
|
||||
import { RowLayout } from '../row.layout';
|
||||
import { SwipeDialog } from '../swipe-dialog';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const ExperimentalFeatureSetting = () => {
|
||||
@ -28,42 +28,29 @@ export const ExperimentalFeatureSetting = () => {
|
||||
<ArrowRightSmallIcon fontSize={22} />
|
||||
</RowLayout>
|
||||
</SettingGroup>
|
||||
<Modal
|
||||
animation="slideRight"
|
||||
<SwipeDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
fullScreen
|
||||
contentOptions={{ className: styles.dialog }}
|
||||
withoutCloseButton
|
||||
title="Experimental Features"
|
||||
>
|
||||
<ExperimentalFeatureList onBack={() => setOpen(false)} />
|
||||
</Modal>
|
||||
<ExperimentalFeatureList />
|
||||
</SwipeDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ExperimentalFeatureList = ({ onBack }: { onBack: () => void }) => {
|
||||
const ExperimentalFeatureList = () => {
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
|
||||
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}>
|
||||
{Object.keys(AFFINE_FLAGS).map(key => (
|
||||
<ExperimentalFeaturesItem
|
||||
key={key}
|
||||
flag={featureFlagService.flags[key as keyof AFFINE_FLAGS]}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar orientation="vertical" />
|
||||
</Scrollable.Root>
|
||||
</div>
|
||||
<ul className={styles.content}>
|
||||
{Object.keys(AFFINE_FLAGS).map(key => (
|
||||
<ExperimentalFeaturesItem
|
||||
key={key}
|
||||
flag={featureFlagService.flags[key as keyof AFFINE_FLAGS]}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,29 +1,7 @@
|
||||
import {
|
||||
bodyEmphasized,
|
||||
bodyRegular,
|
||||
footnoteRegular,
|
||||
} from '@toeverything/theme/typography';
|
||||
import { bodyRegular, footnoteRegular } from '@toeverything/theme/typography';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
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({
|
||||
padding: '24px 16px',
|
||||
display: 'flex',
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { ConfigModal } from '@affine/core/components/mobile';
|
||||
import { AuthService } from '@affine/core/modules/cloud';
|
||||
import type {
|
||||
DialogComponentProps,
|
||||
@ -13,6 +12,7 @@ import { AppearanceGroup } from './appearance';
|
||||
import { ExperimentalFeatureSetting } from './experimental';
|
||||
import { OthersGroup } from './others';
|
||||
import * as styles from './style.css';
|
||||
import { SwipeDialog } from './swipe-dialog';
|
||||
import { UserProfile } from './user-profile';
|
||||
import { UserUsage } from './user-usage';
|
||||
|
||||
@ -38,13 +38,23 @@ export const SettingDialog = ({
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<ConfigModal
|
||||
<SwipeDialog
|
||||
title={t['com.affine.mobile.setting.header-title']()}
|
||||
open
|
||||
onOpenChange={() => close()}
|
||||
onBack={close}
|
||||
>
|
||||
<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',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: '24px 16px',
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user