feat: modify mode radio animate

This commit is contained in:
QiShaoXuan 2022-10-17 12:55:34 +08:00
parent cf99129205
commit 9db0b21e35
12 changed files with 443 additions and 52 deletions

View File

@ -17,6 +17,7 @@
"@emotion/react": "^11.10.4",
"@emotion/server": "^11.10.0",
"@emotion/styled": "^11.10.4",
"css-spring": "^4.1.0",
"lit": "^2.3.1",
"next": "12.3.1",
"prettier": "^2.7.1",

View File

@ -2,7 +2,7 @@
-webkit-overflow-scrolling: touch;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
box-sizing: border-box;
transition: all 0.1s;
/*transition: all 0.1s;*/
}
html,
body,

View File

@ -20,33 +20,47 @@ import {
import { Popover } from '@/components/popover';
import { useTheme } from '@/styles';
import { useEditor } from '@/components/editor-provider';
import { AnimateRadio } from '@/components/animate-radio';
const EditorModeSwitch = () => {
const [mode, setMode] = useState<'page' | 'edgeless'>('page');
const PaperItem = ({ active }: { active?: boolean }) => {
const {
theme: {
colors: { highlight, disabled },
},
} = useTheme();
return <PaperIcon color={active ? highlight : disabled} />;
};
const EdgelessItem = ({ active }: { active?: boolean }) => {
const {
theme: {
colors: { highlight, disabled },
},
} = useTheme();
return <EdgelessIcon color={active ? highlight : disabled} />;
};
const EditorModeSwitch = ({ isHover }: { isHover: boolean }) => {
const handleModeSwitch = (mode: 'page' | 'edgeless') => {
const event = new CustomEvent('affine.switch-mode', { detail: mode });
window.dispatchEvent(event);
setMode(mode);
};
return (
<StyledModeSwitch>
<PaperIcon
color={mode === 'page' ? '#6880FF' : '#a6abb7'}
onClick={() => {
handleModeSwitch('page');
<AnimateRadio
isHover={isHover}
labelLeft="Paper"
iconLeft={<PaperItem />}
labelRight="Edgeless"
iconRight={<EdgelessItem />}
style={{
marginRight: '12px',
}}
style={{ cursor: 'pointer' }}
></PaperIcon>
<EdgelessIcon
color={mode === 'edgeless' ? '#6880FF' : '#a6abb7'}
onClick={() => {
handleModeSwitch('edgeless');
initialValue="left"
onChange={value => {
handleModeSwitch(value === 'left' ? 'page' : 'edgeless');
}}
style={{ cursor: 'pointer' }}
></EdgelessIcon>
</StyledModeSwitch>
/>
);
};
@ -102,6 +116,8 @@ const PopoverContent = () => {
export const Header = () => {
const [title, setTitle] = useState('');
const [isHover, setIsHover] = useState(false);
const { editor } = useEditor();
useEffect(() => {
@ -114,12 +130,19 @@ export const Header = () => {
}, [editor]);
return (
<StyledHeader>
<StyledHeader
onMouseEnter={() => {
setIsHover(true);
}}
onMouseLeave={() => {
setIsHover(false);
}}
>
<StyledLogo>
<LogoIcon color={'#6880FF'} onClick={() => {}} />
</StyledLogo>
<StyledTitle>
<EditorModeSwitch />
<EditorModeSwitch isHover={isHover} />
<StyledTitleWrapper>{title}</StyledTitleWrapper>
</StyledTitle>

View File

@ -0,0 +1,38 @@
import { CSSProperties, DOMAttributes } from 'react';
type IconProps = {
color?: string;
style?: CSSProperties;
} & DOMAttributes<SVGElement>;
export const ArrowIcon = ({
color,
style: propsStyle = {},
direction = 'right',
...props
}: IconProps & { direction?: 'left' | 'right' | 'middle' }) => {
const style = {
fill: color,
transform: `rotate(${direction === 'left' ? '0' : '180deg'})`,
opacity: direction === 'middle' ? 0 : 1,
...propsStyle,
};
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="6"
height="16"
viewBox="0 0 6 16"
fill="none"
{...props}
style={style}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.602933 0.305738C0.986547 0.0865297 1.47523 0.219807 1.69444 0.603421L5.41093 7.10728C5.72715 7.66066 5.72715 8.34 5.41093 8.89338L1.69444 15.3972C1.47523 15.7809 0.986547 15.9141 0.602933 15.6949C0.219319 15.4757 0.0860414 14.987 0.305249 14.6034L4.02174 8.09956C4.05688 8.03807 4.05688 7.96259 4.02174 7.9011L0.305249 1.39724C0.0860414 1.01363 0.219319 0.524946 0.602933 0.305738Z"
fill="#6880FF"
/>
</svg>
);
};

View File

@ -0,0 +1,151 @@
import { useState, useEffect, cloneElement } from 'react';
import {
StyledAnimateRadioContainer,
StyledRadioMiddle,
StyledMiddleLine,
StyledRadioItem,
StyledLabel,
StyledIcon,
} from './style';
import { ArrowIcon } from './icons';
import type {
RadioItemStatus,
AnimateRadioProps,
AnimateRadioItemProps,
} from './type';
const AnimateRadioItem = ({
active,
status,
icon,
label,
isLeft,
...props
}: AnimateRadioItemProps) => {
return (
<StyledRadioItem active={active} status={status} {...props}>
<StyledIcon shrink={status === 'stretch'} isLeft={isLeft}>
{cloneElement(icon, {
active,
})}
</StyledIcon>
<StyledLabel shrink={status !== 'stretch'}>{label}</StyledLabel>
</StyledRadioItem>
);
};
const RadioMiddle = ({
isHover,
direction,
}: {
isHover: boolean;
direction: 'left' | 'right' | 'middle';
}) => {
return (
<StyledRadioMiddle hidden={!isHover}>
<StyledMiddleLine hidden={direction !== 'middle'} />
<ArrowIcon
direction={direction}
style={{
position: 'absolute',
left: '0',
right: '0',
top: '0',
bottom: '0',
margin: 'auto',
}}
></ArrowIcon>
</StyledRadioMiddle>
);
};
export const AnimateRadio = ({
labelLeft,
labelRight,
iconLeft,
iconRight,
isHover,
style = {},
onChange,
initialValue = 'left',
}: AnimateRadioProps) => {
const [active, setActive] = useState(initialValue);
const modifyRadioItemStatus = (): RadioItemStatus => {
return {
left: !isHover && active === 'right' ? 'shrink' : 'normal',
right: !isHover && active === 'left' ? 'shrink' : 'normal',
};
};
const [radioItemStatus, setRadioItemStatus] = useState<RadioItemStatus>(
modifyRadioItemStatus
);
useEffect(() => {
setRadioItemStatus(modifyRadioItemStatus());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHover, active]);
return (
<StyledAnimateRadioContainer shrink={!isHover} style={style}>
<AnimateRadioItem
isLeft={true}
label={labelLeft}
icon={iconLeft}
active={active === 'left'}
status={radioItemStatus.left}
onClick={() => {
setActive('left');
onChange?.('left');
}}
onMouseEnter={() => {
setRadioItemStatus({
right: 'normal',
left: 'stretch',
});
}}
onMouseLeave={() => {
setRadioItemStatus({
...radioItemStatus,
left: 'normal',
});
}}
/>
<RadioMiddle
isHover={isHover}
direction={
radioItemStatus.left === 'stretch'
? 'left'
: radioItemStatus.right === 'stretch'
? 'right'
: 'middle'
}
/>
<AnimateRadioItem
isLeft={false}
label={labelRight}
icon={iconRight}
active={active === 'right'}
status={radioItemStatus.right}
onClick={() => {
setActive('right');
onChange?.('right');
}}
onMouseEnter={() => {
setRadioItemStatus({
left: 'normal',
right: 'stretch',
});
}}
onMouseLeave={() => {
setRadioItemStatus({
...radioItemStatus,
right: 'normal',
});
}}
/>
</StyledAnimateRadioContainer>
);
};
export default AnimateRadio;

View File

@ -0,0 +1,152 @@
import { keyframes, styled } from '@/styles';
import spring, { toString } from 'css-spring';
import type { ItemStatus } from './type';
const ANIMATE_DURATION = 300;
export const StyledAnimateRadioContainer = styled('div')<{ shrink: boolean }>(
({ shrink }) => {
const animateScaleStretch = keyframes`${toString(
spring({ width: '66px' }, { width: '132px' }, { preset: 'gentle' })
)}`;
const animateScaleShrink = keyframes(
`${toString(
spring({ width: '132px' }, { width: '66px' }, { preset: 'gentle' })
)}`
);
const shrinkStyle = shrink
? {
animation: `${animateScaleShrink} ${ANIMATE_DURATION}ms forwards`,
background: 'transparent',
}
: {
animation: `${animateScaleStretch} ${ANIMATE_DURATION}ms forwards`,
};
return {
height: '36px',
borderRadius: '18px',
background: '#F1F3FF',
position: 'relative',
display: 'flex',
transition: `background ${ANIMATE_DURATION}ms`,
...shrinkStyle,
};
}
);
export const StyledRadioMiddle = styled('div')<{
hidden: boolean;
}>(({ hidden }) => {
return {
width: '6px',
height: '100%',
position: 'relative',
opacity: hidden ? '0' : '1',
};
});
export const StyledMiddleLine = styled('div')<{ hidden: boolean }>(
({ hidden }) => {
return {
width: '1px',
height: '16px',
background: '#D0D7E3',
position: 'absolute',
left: '0',
right: '0',
top: '0',
bottom: '0',
margin: 'auto',
opacity: hidden ? '0' : '1',
};
}
);
export const StyledRadioItem = styled('div')<{
status: ItemStatus;
active: boolean;
}>(({ status, active, theme }) => {
const animateScaleStretch = keyframes`${toString(
spring({ width: '66px' }, { width: '116px' })
)}`;
const animateScaleOrigin = keyframes(
`${toString(spring({ width: '116px' }, { width: '66px' }))}`
);
const animateScaleShrink = keyframes(
`${toString(spring({ width: '66px' }, { width: '0px' }))}`
);
const dynamicStyle =
status === 'stretch'
? {
animation: `${animateScaleStretch} ${ANIMATE_DURATION}ms forwards`,
flexShrink: '0',
}
: status === 'shrink'
? {
animation: `${animateScaleShrink} ${ANIMATE_DURATION}ms forwards`,
opacity: '0',
}
: { animation: `${animateScaleOrigin} ${ANIMATE_DURATION}ms forwards` };
const {
colors: { highlight, disabled },
} = theme;
return {
height: '100%',
display: 'flex',
cursor: 'pointer',
overflow: 'hidden',
color: active ? highlight : disabled,
...dynamicStyle,
};
});
export const StyledLabel = styled('div')<{
shrink: boolean;
}>(({ shrink }) => {
const animateScaleStretch = keyframes`${toString(
spring({ scale: 0 }, { scale: 1 }, { preset: 'gentle' })
)}`;
const animateScaleShrink = keyframes(
`${toString(spring({ scale: 1 }, { scale: 0 }, { preset: 'gentle' }))}`
);
const shrinkStyle = shrink
? {
animation: `${animateScaleShrink} ${ANIMATE_DURATION}ms forwards`,
}
: {
animation: `${animateScaleStretch} ${ANIMATE_DURATION}ms forwards`,
};
return {
display: 'flex',
alignItems: 'center',
fontSize: '16px',
flexShrink: '0',
transition: `transform ${ANIMATE_DURATION}ms`,
fontWeight: 'normal',
...shrinkStyle,
};
});
export const StyledIcon = styled('div')<{
shrink: boolean;
isLeft: boolean;
}>(({ shrink, isLeft }) => {
const shrinkStyle = shrink
? {
width: '24px',
margin: isLeft ? '0 12px' : '0 5px',
}
: {
width: '66px',
};
return {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexShrink: '0',
...shrinkStyle,
};
});

View File

@ -0,0 +1,25 @@
import { CSSProperties, DOMAttributes, ReactElement } from 'react';
export type ItemStatus = 'normal' | 'stretch' | 'shrink';
export type RadioItemStatus = {
left: ItemStatus;
right: ItemStatus;
};
export type AnimateRadioProps = {
labelLeft: string;
labelRight: string;
iconLeft: ReactElement;
iconRight: ReactElement;
isHover: boolean;
initialValue?: 'left' | 'right';
style?: CSSProperties;
onChange?: (value: 'left' | 'right') => void;
};
export type AnimateRadioItemProps = {
active: boolean;
status: ItemStatus;
label: string;
icon: ReactElement;
isLeft: boolean;
} & DOMAttributes<HTMLDivElement>;

View File

@ -1,6 +1,6 @@
export type { ThemeMode, ThemeProviderProps, AffineTheme } from './types';
export { styled } from './styled';
export * from './styled';
export { ThemeProvider } from './themeProvider';
export { lightTheme, darkTheme } from './theme';
export { useTheme } from './hooks';

View File

@ -1,3 +1,3 @@
import emotionStyled from '@emotion/styled';
export { css, keyframes } from '@emotion/react';
export const styled = emotionStyled;

View File

@ -3,25 +3,30 @@ import { AffineTheme, ThemeMode } from './types';
export const lightTheme: AffineTheme = {
colors: {
primary: '#0070f3',
primary: '#3A4C5C',
highlight: '#7389FD',
disabled: '#9096A5',
background: '#fff',
},
};
export const darkTheme: AffineTheme = {
colors: {
primary: '#000',
primary: '#fff',
highlight: '#7389FD',
disabled: '#9096A5',
background: '#3d3c3f',
},
};
export const globalThemeConstant = (mode: ThemeMode, theme: AffineTheme) => {
const isDark = mode === 'dark';
return {
'--color-primary': theme.colors.primary,
'--page-background-color': isDark ? '#3d3c3f' : '#fff',
'--page-text-color': isDark ? '#fff' : '#3a4c5c',
'--page-background-color': theme.colors.background,
'--page-text-color': theme.colors.primary,
// editor style variables
'--affine-primary-color': isDark ? '#fff' : '#3a4c5c',
'--affine-primary-color': theme.colors.primary,
'--affine-muted-color': '#a6abb7',
'--affine-highlight-color': '#6880ff',
'--affine-placeholder-color': '#c7c7c7',
@ -34,22 +39,3 @@ export const globalThemeConstant = (mode: ThemeMode, theme: AffineTheme) => {
'Roboto Mono, apple-system, BlinkMacSystemFont,Helvetica Neue, Tahoma, PingFang SC, Microsoft Yahei, Arial,Hiragino Sans GB, sans-serif, Apple Color Emoji, Segoe UI Emoji,Segoe UI Symbol, Noto Color Emoji',
};
};
const editorStyleVariable = {
'--affine-primary-color': '#3a4c5c',
'--affine-muted-color': '#a6abb7',
'--affine-highlight-color': '#6880ff',
'--affine-placeholder-color': '#c7c7c7',
'--affine-selected-color': 'rgba(104, 128, 255, 0.1)',
'--affine-font-family':
'Avenir Next, apple-system, BlinkMacSystemFont,Helvetica Neue, Tahoma, PingFang SC, Microsoft Yahei, Arial,Hiragino Sans GB, sans-serif, Apple Color Emoji, Segoe UI Emoji,Segoe UI Symbol, Noto Color Emoji',
'--affine-font-family2':
'Roboto Mono, apple-system, BlinkMacSystemFont,Helvetica Neue, Tahoma, PingFang SC, Microsoft Yahei, Arial,Hiragino Sans GB, sans-serif, Apple Color Emoji, Segoe UI Emoji,Segoe UI Symbol, Noto Color Emoji',
};
const pageStyleVariable = {
'--page-background-color': '#fff',
'--page-text-color': '#3a4c5c',
};

View File

@ -14,6 +14,9 @@ export type ThemeProviderValue = {
export interface AffineTheme {
colors: {
primary: string;
highlight: string;
disabled: string;
background: string;
};
}

View File

@ -28,6 +28,7 @@ importers:
'@types/node': 18.7.18
'@types/react': 18.0.20
'@types/react-dom': 18.0.6
css-spring: ^4.1.0
eslint: 8.22.0
eslint-config-next: 12.3.1
eslint-config-prettier: ^8.5.0
@ -48,6 +49,7 @@ importers:
'@emotion/react': 11.10.4_w5j4k42lgipnm43s3brx6h3c34
'@emotion/server': 11.10.0_@emotion+css@11.10.0
'@emotion/styled': 11.10.4_yiaqs725o7pcd7rteavrnhgj4y
css-spring: 4.1.0
lit: 2.4.0
next: 12.3.1_biqbaboplfbrettd7655fr4n2y
prettier: 2.7.1
@ -906,6 +908,12 @@ packages:
which: 2.0.2
dev: true
/css-spring/4.1.0:
resolution: {integrity: sha512-RdA4NuRNk2xChTSo+52P2jlfr+rUgNY94mV7uHrCeDPvaUtFZgW6LMoCy4xEX3HphZ7LLkCHiUY5PBSegFBE3A==}
dependencies:
lodash: 4.17.21
dev: false
/csstype/3.1.1:
resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==}
@ -1172,7 +1180,7 @@ packages:
eslint-import-resolver-webpack:
optional: true
dependencies:
'@typescript-eslint/parser': 5.38.0_eslint@8.22.0
'@typescript-eslint/parser': 5.38.0_76twfck5d7crjqrmw4yltga7zm
debug: 3.2.7
eslint: 8.22.0
eslint-import-resolver-node: 0.3.6
@ -1191,7 +1199,7 @@ packages:
'@typescript-eslint/parser':
optional: true
dependencies:
'@typescript-eslint/parser': 5.38.0_eslint@8.22.0
'@typescript-eslint/parser': 5.38.0_76twfck5d7crjqrmw4yltga7zm
array-includes: 3.1.5
array.prototype.flat: 1.3.0
debug: 2.6.9
@ -1891,6 +1899,10 @@ packages:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
/lodash/4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: false
/loose-envify/1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true