mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
feat(mobile): mobile experimental feature setting (#8922)
close AF-1802 
This commit is contained in:
@@ -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',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user