mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(core): onboarding paper unfolding animation (#5264)
This commit is contained in:
@@ -1,43 +1,76 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { type CSSProperties, useCallback, useState } from 'react';
|
||||
|
||||
import { articles } from './articles';
|
||||
import { PaperSteps } from './paper-steps';
|
||||
import * as styles from './style.css';
|
||||
import type { ArticleId, ArticleOption } from './types';
|
||||
|
||||
interface OnboardingProps {
|
||||
onOpenApp?: () => void;
|
||||
}
|
||||
|
||||
export const Onboarding = (_: OnboardingProps) => {
|
||||
const [status, setStatus] = useState<{
|
||||
activeId: ArticleId | null;
|
||||
unfoldingId: ArticleId | null;
|
||||
}>({ activeId: null, unfoldingId: null });
|
||||
|
||||
const onFoldChange = useCallback((id: ArticleId, v: boolean) => {
|
||||
setStatus(s => {
|
||||
return {
|
||||
activeId: v ? null : s.activeId,
|
||||
unfoldingId: v ? null : id,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onFoldChanged = useCallback((id: ArticleId, v: boolean) => {
|
||||
setStatus(s => {
|
||||
return {
|
||||
activeId: v ? null : id,
|
||||
unfoldingId: s.unfoldingId,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.onboarding} data-is-desktop={environment.isDesktop}>
|
||||
<div className={styles.offsetOrigin}>
|
||||
{Object.entries(articles).map(([id, article]) => {
|
||||
const { enterOptions, location } = article;
|
||||
const style = {
|
||||
'--fromX': `${enterOptions.fromX}vw`,
|
||||
'--fromY': `${enterOptions.fromY}vh`,
|
||||
'--fromZ': `${enterOptions.fromZ}px`,
|
||||
'--toZ': `${enterOptions.toZ}px`,
|
||||
'--fromRotateX': `${enterOptions.fromRotateX}deg`,
|
||||
'--fromRotateY': `${enterOptions.fromRotateY}deg`,
|
||||
'--fromRotateZ': `${enterOptions.fromRotateZ}deg`,
|
||||
'--toRotateZ': `${enterOptions.toRotateZ}deg`,
|
||||
{(Object.entries(articles) as Array<[ArticleId, ArticleOption]>).map(
|
||||
([id, article]) => {
|
||||
const { enterOptions, location } = article;
|
||||
const style = {
|
||||
zIndex: status.unfoldingId === id ? 1 : 0,
|
||||
|
||||
'--delay': `${enterOptions.delay}ms`,
|
||||
'--duration': enterOptions.duration,
|
||||
'--easing': enterOptions.easing,
|
||||
'--fromX': `${enterOptions.fromX}vw`,
|
||||
'--fromY': `${enterOptions.fromY}vh`,
|
||||
'--fromZ': `${enterOptions.fromZ}px`,
|
||||
'--toZ': `${enterOptions.toZ}px`,
|
||||
'--fromRotateX': `${enterOptions.fromRotateX}deg`,
|
||||
'--fromRotateY': `${enterOptions.fromRotateY}deg`,
|
||||
'--fromRotateZ': `${enterOptions.fromRotateZ}deg`,
|
||||
'--toRotateZ': `${enterOptions.toRotateZ}deg`,
|
||||
|
||||
'--offset-x': `${location.x || 0}px`,
|
||||
'--offset-y': `${location.y || 0}px`,
|
||||
} as CSSProperties;
|
||||
'--delay': `${enterOptions.delay}ms`,
|
||||
'--duration': enterOptions.duration,
|
||||
'--easing': enterOptions.easing,
|
||||
|
||||
return (
|
||||
<div style={style} key={id}>
|
||||
<PaperSteps article={article} show={true} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
'--offset-x': `${location.x || 0}px`,
|
||||
'--offset-y': `${location.y || 0}px`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div style={style} key={id}>
|
||||
<PaperSteps
|
||||
article={article}
|
||||
show={status.activeId === null || status.activeId === id}
|
||||
onFoldChange={onFoldChange}
|
||||
onFoldChanged={onFoldChanged}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { AnimateIn } from './steps/animate-in';
|
||||
import type { ArticleOption } from './types';
|
||||
import { Unfolding } from './steps/unfolding';
|
||||
import type { ArticleId, OnboardingStep } from './types';
|
||||
import { type ArticleOption } from './types';
|
||||
|
||||
interface PaperStepsProps {
|
||||
show?: boolean;
|
||||
article: ArticleOption;
|
||||
onFoldChange?: (id: ArticleId, v: boolean) => void;
|
||||
onFoldChanged?: (id: ArticleId, v: boolean) => void;
|
||||
}
|
||||
|
||||
export const PaperSteps = ({ show, article }: PaperStepsProps) => {
|
||||
const onFinished = useCallback(() => {
|
||||
console.log('onFinished');
|
||||
export const PaperSteps = ({
|
||||
show,
|
||||
article,
|
||||
onFoldChange,
|
||||
onFoldChanged,
|
||||
}: PaperStepsProps) => {
|
||||
const [stage, setStage] = useState<OnboardingStep>('enter');
|
||||
|
||||
const onEntered = useCallback(() => {
|
||||
setStage('unfold');
|
||||
}, []);
|
||||
|
||||
const _onFoldChange = useCallback(
|
||||
(v: boolean) => {
|
||||
onFoldChange?.(article.id, v);
|
||||
},
|
||||
[onFoldChange, article.id]
|
||||
);
|
||||
|
||||
const _onFoldChanged = useCallback(
|
||||
(v: boolean) => {
|
||||
onFoldChanged?.(article.id, v);
|
||||
},
|
||||
[onFoldChanged, article.id]
|
||||
);
|
||||
|
||||
if (!show) return null;
|
||||
return <AnimateIn article={article} onFinished={onFinished} />;
|
||||
return stage === 'enter' ? (
|
||||
<AnimateIn article={article} onFinished={onEntered} />
|
||||
) : stage === 'unfold' ? (
|
||||
<Unfolding
|
||||
article={article}
|
||||
onChange={_onFoldChange}
|
||||
onChanged={_onFoldChanged}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
import { onboardingVars, paperLocation } from '../style.css';
|
||||
|
||||
const unfolding = onboardingVars.unfolding;
|
||||
|
||||
const shadowIn = keyframes({
|
||||
from: { boxShadow: `0px 0px 0px rgba(0, 0, 0, 0)` },
|
||||
to: { boxShadow: `0px 0px 4px rgba(66, 65, 73, 0.14)` },
|
||||
});
|
||||
const borderIn = keyframes({
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 1 },
|
||||
});
|
||||
const fadeOut = keyframes({
|
||||
from: { opacity: 1 },
|
||||
to: { opacity: 0 },
|
||||
});
|
||||
|
||||
export const unfoldingWrapper = style([
|
||||
paperLocation,
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
transform: 'rotate(var(--toRotateZ))',
|
||||
cursor: 'pointer',
|
||||
|
||||
backgroundColor: onboardingVars.paper.bg,
|
||||
borderRadius: onboardingVars.paper.r,
|
||||
width: onboardingVars.paper.w,
|
||||
height: onboardingVars.paper.h,
|
||||
|
||||
// animate in
|
||||
boxShadow: `0px 0px 0px rgba(0, 0, 0, 0)`,
|
||||
animation: `${shadowIn} 0.5s ease forwards`,
|
||||
|
||||
transition: `all 0.23s ease, width ${unfolding.sizeTransition}, height ${unfolding.sizeTransition}, transform ${unfolding.transformTransition}`,
|
||||
|
||||
'::before': {
|
||||
// hack border
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: `1px solid ${onboardingVars.paper.borderColor}`,
|
||||
borderRadius: 'inherit',
|
||||
animation: `${borderIn} 0.5s ease forwards`,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
|
||||
selectors: {
|
||||
'&[data-fold="false"]': {
|
||||
vars: {
|
||||
'--toRotateZ': '0deg',
|
||||
},
|
||||
width: onboardingVars.article.w,
|
||||
height: onboardingVars.article.h,
|
||||
left: `calc(0 - ${onboardingVars.article.w} / 2)`,
|
||||
top: `calc(0 - ${onboardingVars.article.h} / 2)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const unfoldingContent = style({
|
||||
width: onboardingVars.paper.w,
|
||||
height: onboardingVars.paper.h,
|
||||
|
||||
padding: '16px',
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'var(--affine-font-family)',
|
||||
|
||||
selectors: {
|
||||
'&.leave': {
|
||||
animation: `${fadeOut} 0.1s ease forwards`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import type { ArticleOption } from '../types';
|
||||
import * as styles from './unfolding.css';
|
||||
|
||||
interface UnfoldingProps {
|
||||
onChange?: (e: boolean) => void;
|
||||
onChanged?: (e: boolean) => void;
|
||||
article: ArticleOption;
|
||||
}
|
||||
|
||||
export const Unfolding = ({ article, onChange, onChanged }: UnfoldingProps) => {
|
||||
const [fold, setFold] = useState(true);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleFold = useCallback(() => {
|
||||
setFold(!fold);
|
||||
return !fold;
|
||||
}, [fold]);
|
||||
|
||||
const onPaperClick = useCallback(() => {
|
||||
const isFold = toggleFold();
|
||||
onChange?.(isFold);
|
||||
|
||||
if (ref.current) {
|
||||
const handler = () => {
|
||||
onChanged?.(isFold);
|
||||
};
|
||||
ref.current.addEventListener('transitionend', handler, { once: true });
|
||||
return () => ref.current?.removeEventListener('transitionend', handler);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [toggleFold, onChange, onChanged]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-fold={fold}
|
||||
className={styles.unfoldingWrapper}
|
||||
onClick={onPaperClick}
|
||||
>
|
||||
<div className={clsx(styles.unfoldingContent, !fold && 'leave')}>
|
||||
{article.brief}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,10 +16,24 @@ export const onboardingVars = {
|
||||
bg: 'var(--affine-pure-white)',
|
||||
// textColor: 'var(--affine-light-text-primary-color)',
|
||||
textColor: '#121212',
|
||||
borderColor: '#E3E2E4',
|
||||
},
|
||||
unfolding: {
|
||||
sizeTransition: '0.3s ease',
|
||||
transformTransition: '0.3s ease',
|
||||
},
|
||||
web: {
|
||||
bg: '#fafafa', // TODO: use var
|
||||
},
|
||||
|
||||
article: {
|
||||
w: '1200px',
|
||||
h: '800px',
|
||||
},
|
||||
edgeless: {
|
||||
w: '1200px',
|
||||
h: '800px',
|
||||
},
|
||||
};
|
||||
|
||||
export const perspective = style({
|
||||
|
||||
Reference in New Issue
Block a user