feat(mobile): mobile experimental feature setting (#8922)

close AF-1802

![CleanShot 2024-11-26 at 10.02.27.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/09d24e35-a524-497d-b5aa-840bf74f128f.gif)
This commit is contained in:
CatsJuice
2024-11-28 07:25:06 +00:00
parent c95e6ec518
commit 71ab75e30e
10 changed files with 236 additions and 12 deletions
@@ -174,7 +174,7 @@ export const AFFINE_FLAGS = {
'com.affine.settings.workspace.experimental-features.enable-theme-editor.name', 'com.affine.settings.workspace.experimental-features.enable-theme-editor.name',
description: description:
'com.affine.settings.workspace.experimental-features.enable-theme-editor.description', 'com.affine.settings.workspace.experimental-features.enable-theme-editor.description',
configurable: isCanaryBuild, configurable: isCanaryBuild && !isMobile,
defaultState: isCanaryBuild, defaultState: isCanaryBuild,
}, },
enable_local_workspace: { enable_local_workspace: {
@@ -48,7 +48,7 @@ export interface ModalProps extends DialogProps {
/** /**
* @default 'fadeScaleTop' * @default 'fadeScaleTop'
*/ */
animation?: 'fadeScaleTop' | 'none' | 'slideBottom'; animation?: 'fadeScaleTop' | 'none' | 'slideBottom' | 'slideRight';
/** /**
* Whether to show the modal in full screen mode * Whether to show the modal in full screen mode
*/ */
@@ -51,12 +51,23 @@ const contentHideSlideBottom = keyframes({
from: { transform: 'translateY(0)' }, from: { transform: 'translateY(0)' },
to: { transform: 'translateY(100%)' }, to: { transform: 'translateY(100%)' },
}); });
const contentShowSlideRight = keyframes({
from: { transform: 'translateX(100%)' },
to: { transform: 'translateX(0)' },
});
const contentHideSlideRight = keyframes({
from: { transform: 'translateX(0)' },
to: { transform: 'translateX(100%)' },
});
const modalContentViewTransitionNameFadeScaleTop = generateIdentifier( const modalContentViewTransitionNameFadeScaleTop = generateIdentifier(
'modal-content-fade-scale-top' 'modal-content-fade-scale-top'
); );
const modalContentViewTransitionNameSlideBottom = generateIdentifier( const modalContentViewTransitionNameSlideBottom = generateIdentifier(
'modal-content-slide-bottom' 'modal-content-slide-bottom'
); );
const modalContentViewTransitionNameSlideRight = generateIdentifier(
'modal-content-slide-right'
);
export const modalOverlay = style({ export const modalOverlay = style({
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
@@ -105,6 +116,13 @@ export const modalContentWrapper = style({
[`${vtScopeSelector(modalVTScope)} &.anim-slideBottom.vt-active`]: { [`${vtScopeSelector(modalVTScope)} &.anim-slideBottom.vt-active`]: {
viewTransitionName: modalContentViewTransitionNameSlideBottom, viewTransitionName: modalContentViewTransitionNameSlideBottom,
}, },
'&.anim-slideRight': {
animation: `${contentShowSlideRight} 0.23s ease`,
animationFillMode: 'forwards',
},
[`${vtScopeSelector(modalVTScope)} &.anim-slideRight.vt-active`]: {
viewTransitionName: modalContentViewTransitionNameSlideRight,
},
}, },
}); });
globalStyle( globalStyle(
@@ -121,6 +139,13 @@ globalStyle(
animationFillMode: 'forwards', animationFillMode: 'forwards',
} }
); );
globalStyle(
`::view-transition-old(${modalContentViewTransitionNameSlideRight})`,
{
animation: `${contentHideSlideRight} 0.23s ease`,
animationFillMode: 'forwards',
}
);
export const modalContent = style({ export const modalContent = style({
vars: { vars: {
@@ -1,6 +1,18 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css'; import { createVar, style } from '@vanilla-extract/css';
export const switchHeightVar = createVar('switchSize');
export const switchPaddingVar = createVar('switchPadding');
const switchWidthVar = createVar('switchWidth');
const dotSizeVar = createVar('dotSize');
export const labelStyle = style({ export const labelStyle = style({
vars: {
[switchHeightVar]: '26px',
[switchPaddingVar]: '3px',
[switchWidthVar]: `calc((${switchHeightVar} - ${switchPaddingVar}) * 2)`,
[dotSizeVar]: `calc(${switchHeightVar} - ${switchPaddingVar} * 2)`,
},
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '10px',
@@ -12,8 +24,8 @@ export const inputStyle = style({
}); });
export const switchStyle = style({ export const switchStyle = style({
position: 'relative', position: 'relative',
width: '46px', height: switchHeightVar,
height: '26px', width: switchWidthVar,
background: cssVar('toggleDisableBackgroundColor'), background: cssVar('toggleDisableBackgroundColor'),
borderRadius: '37px', borderRadius: '37px',
transition: '200ms all', transition: '200ms all',
@@ -22,12 +34,12 @@ export const switchStyle = style({
transition: 'all .2s cubic-bezier(0.27, 0.2, 0.25, 1.51)', transition: 'all .2s cubic-bezier(0.27, 0.2, 0.25, 1.51)',
content: '""', content: '""',
position: 'absolute', position: 'absolute',
width: '20px', width: dotSizeVar,
height: '20px', height: dotSizeVar,
borderRadius: '50%', borderRadius: '50%',
top: '50%', top: '50%',
background: cssVar('toggleCircleBackgroundColor'), background: cssVar('toggleCircleBackgroundColor'),
transform: 'translate(3px, -50%)', transform: `translate(${switchPaddingVar}, -50%)`,
}, },
}, },
}); });
@@ -36,7 +48,7 @@ export const switchCheckedStyle = style({
selectors: { selectors: {
'&:before': { '&:before': {
borderColor: cssVar('pureBlack10'), borderColor: cssVar('pureBlack10'),
transform: 'translate(23px,-50%)', transform: `translate(calc(${switchHeightVar} - ${switchPaddingVar}), -50%)`,
}, },
}, },
}); });
@@ -1,7 +1,8 @@
// components/switch.tsx // components/switch.tsx
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import type { HTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, ReactNode } from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import * as styles from './index.css'; import * as styles from './index.css';
@@ -10,6 +11,14 @@ export type SwitchProps = Omit<HTMLAttributes<HTMLLabelElement>, 'onChange'> & {
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
children?: ReactNode; children?: ReactNode;
disabled?: boolean; disabled?: boolean;
/**
* The height of the switch (including the padding)
*/
size?: number;
/**
* The padding of the switch
*/
padding?: number;
}; };
export const Switch = ({ export const Switch = ({
@@ -18,8 +27,13 @@ export const Switch = ({
children, children,
className, className,
disabled, disabled,
style,
size: propsSize,
padding: propsPadding,
...otherProps ...otherProps
}: SwitchProps) => { }: SwitchProps) => {
const size = propsSize ?? (BUILD_CONFIG.isMobileEdition ? 24 : 26);
const padding = propsPadding ?? (BUILD_CONFIG.isMobileEdition ? 2 : 3);
const [checkedState, setCheckedState] = useState(checkedProp); const [checkedState, setCheckedState] = useState(checkedProp);
const checked = onChangeProp ? checkedProp : checkedState; const checked = onChangeProp ? checkedProp : checkedState;
@@ -35,8 +49,23 @@ export const Switch = ({
[disabled, onChangeProp] [disabled, onChangeProp]
); );
const labelStyle = useMemo(
() => ({
...assignInlineVars({
[styles.switchHeightVar]: `${size}px`,
[styles.switchPaddingVar]: `${padding}px`,
}),
...style,
}),
[size, padding, style]
);
return ( return (
<label className={clsx(styles.labelStyle, className)} {...otherProps}> <label
className={clsx(styles.labelStyle, className)}
style={labelStyle}
{...otherProps}
>
{children} {children}
<input <input
className={clsx(styles.inputStyle)} className={clsx(styles.inputStyle)}
@@ -0,0 +1,96 @@
import { Modal, Scrollable, Switch } from '@affine/component';
import { PageHeader } from '@affine/core/mobile/components';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import {
AFFINE_FLAGS,
FeatureFlagService,
type Flag,
useLiveData,
useService,
} from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { SettingGroup } from '../group';
import { RowLayout } from '../row.layout';
import * as styles from './styles.css';
export const ExperimentalFeatureSetting = () => {
const [open, setOpen] = useState(false);
return (
<>
<SettingGroup title="Experimental">
<RowLayout
label={'Experimental Features'}
onClick={() => setOpen(true)}
>
<ArrowRightSmallIcon fontSize={22} />
</RowLayout>
</SettingGroup>
<Modal
animation="slideRight"
open={open}
onOpenChange={setOpen}
fullScreen
contentOptions={{ className: styles.dialog }}
withoutCloseButton
>
<ExperimentalFeatureList onBack={() => setOpen(false)} />
</Modal>
</>
);
};
const ExperimentalFeatureList = ({ onBack }: { onBack: () => void }) => {
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>
);
};
const ExperimentalFeaturesItem = ({ flag }: { flag: Flag }) => {
const t = useI18n();
const value = useLiveData(flag.$);
const onChange = useCallback(
(checked: boolean) => {
flag.set(checked);
},
[flag]
);
if (flag.configurable === false || flag.hide) {
return null;
}
return (
<li>
<div className={styles.itemBlock}>
{t[flag.displayName]()}
<Switch checked={value} onChange={onChange} />
</div>
{flag.description ? (
<div className={styles.itemDescription}>{t[flag.description]()}</div>
) : null}
</li>
);
};
@@ -0,0 +1,52 @@
import {
bodyEmphasized,
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',
flexDirection: 'column',
gap: 16,
});
// item
export const itemBlock = style([
bodyRegular,
{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '19px 12px',
background: cssVarV2('layer/background/mobile/secondary'),
borderRadius: 12,
},
]);
export const itemDescription = style([
footnoteRegular,
{
marginTop: 4,
color: cssVarV2('text/tertiary'),
},
]);
@@ -10,6 +10,7 @@ import { useEffect } from 'react';
import { AboutGroup } from './about'; import { AboutGroup } from './about';
import { AppearanceGroup } from './appearance'; import { AppearanceGroup } from './appearance';
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 { UserProfile } from './user-profile'; import { UserProfile } from './user-profile';
@@ -25,6 +26,7 @@ const MobileSetting = () => {
<UserUsage /> <UserUsage />
<AppearanceGroup /> <AppearanceGroup />
<AboutGroup /> <AboutGroup />
<ExperimentalFeatureSetting />
<OthersGroup /> <OthersGroup />
</div> </div>
); );
@@ -8,11 +8,17 @@ export const RowLayout = ({
label, label,
children, children,
href, href,
}: PropsWithChildren<{ label: ReactNode; href?: string }>) => { onClick,
}: PropsWithChildren<{
label: ReactNode;
href?: string;
onClick?: () => void;
}>) => {
const content = ( const content = (
<ConfigModal.Row <ConfigModal.Row
data-testid="setting-row" data-testid="setting-row"
className={styles.baseSettingItem} className={styles.baseSettingItem}
onClick={onClick}
> >
<div className={styles.baseSettingItemName}>{label}</div> <div className={styles.baseSettingItemName}>{label}</div>
<div className={styles.baseSettingItemAction}> <div className={styles.baseSettingItemAction}>
@@ -35,6 +35,8 @@ export const baseSettingItemAction = style([
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
overflow: 'hidden', overflow: 'hidden',
flexShrink: 1, flexShrink: 1,
display: 'flex',
alignItems: 'center',
}, },
]); ]);