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

@@ -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');
}}
style={{ cursor: 'pointer' }}
></PaperIcon>
<EdgelessIcon
color={mode === 'edgeless' ? '#6880FF' : '#a6abb7'}
onClick={() => {
handleModeSwitch('edgeless');
}}
style={{ cursor: 'pointer' }}
></EdgelessIcon>
</StyledModeSwitch>
<AnimateRadio
isHover={isHover}
labelLeft="Paper"
iconLeft={<PaperItem />}
labelRight="Edgeless"
iconRight={<EdgelessItem />}
style={{
marginRight: '12px',
}}
initialValue="left"
onChange={value => {
handleModeSwitch(value === 'left' ? 'page' : 'edgeless');
}}
/>
);
};
@@ -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>;