feat(core): onboarding paper enter animation (#5248)

This commit is contained in:
Cats Juice 2023-12-19 07:18:00 +00:00
parent 15dd20ef48
commit 841385666e
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
15 changed files with 766 additions and 28 deletions

View File

@ -50,6 +50,7 @@
"@toeverything/theme": "^0.7.20",
"@vanilla-extract/css": "^1.13.0",
"@vanilla-extract/dynamic": "^2.0.3",
"animejs": "^3.2.2",
"async-call-rpc": "^6.3.1",
"bytes": "^3.1.2",
"clsx": "^2.0.0",
@ -93,6 +94,7 @@
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.3.93",
"@testing-library/react": "^14.0.0",
"@types/animejs": "^3",
"@types/bytes": "^3.1.3",
"@types/image-blob-reduce": "^4.1.3",
"@types/lodash-es": "^4.17.9",

View File

@ -0,0 +1,228 @@
import { article, articleWrapper, text, title } from '../curve-paper/paper.css';
import type { ArticleId, ArticleOption } from '../types';
const ids = ['0', '1', '2', '3', '4'] as Array<ArticleId>;
/** locate paper */
const paperLocations = {
'0': {
x: 0,
y: 0,
},
'1': {
x: -240,
y: -100,
},
'2': {
x: 240,
y: -100,
},
'3': {
x: -480,
y: 40,
},
'4': {
x: 480,
y: 50,
},
};
/** paper enter animation config */
const paperEnterAnimationOriginal = {
'0': {
curveCenter: 4,
curve: 292,
delay: 800,
fromZ: 1230,
fromX: -76,
fromY: 100,
fromRotateX: 185,
fromRotateY: -166,
fromRotateZ: 252,
toZ: 0,
// toX: 12,
// toY: -30,
toRotateZ: 6,
duration: '2s',
easing: 'ease',
},
'1': {
curveCenter: 4,
curve: 280,
delay: 0,
fromZ: 3697,
fromX: 25,
fromY: -93,
fromRotateX: 331,
fromRotateY: 360,
fromRotateZ: -257,
toZ: 0,
// toX: -18,
// toY: -28,
toRotateZ: -8,
duration: '2s',
easing: 'ease',
},
'2': {
curveCenter: 3,
curve: 660,
delay: 1700,
fromZ: 57379,
fromX: 2,
fromY: -77,
fromRotateX: 0,
fromRotateY: 0,
fromRotateZ: 0,
toZ: 0,
// toX: -3,
// toY: -21,
toRotateZ: 2,
duration: '2s',
easing: 'ease',
},
'3': {
curveCenter: 4,
curve: 260,
delay: 1500,
fromZ: 4303,
fromX: -37,
fromY: -100,
fromRotateX: 360,
fromRotateY: 360,
fromRotateZ: 8,
toZ: 0,
// toX: -30,
// toY: -9,
toRotateZ: 2,
duration: '2s',
easing: 'ease',
},
'4': {
curveCenter: 3,
curve: 270,
delay: 1571,
fromZ: 1876,
fromX: 65,
fromY: 48,
fromRotateX: 101,
fromRotateY: 188,
fromRotateZ: -200,
toZ: 0,
// toX: 24,
// toY: -2,
toRotateZ: 8,
duration: '2s',
easing: 'ease',
},
};
export type PaperEnterAnimation = (typeof paperEnterAnimationOriginal)[0];
export const paperEnterAnimations = paperEnterAnimationOriginal as Record<
any,
PaperEnterAnimation
>;
/** Brief content */
const paperBriefs = {
'0': (
<div className={articleWrapper}>
<article className={article}>
<h1 className={title}>Breath of the Wild: Redefining Game Design</h1>
<p className={text}>
With all the time you spend watching TV, he tells me, you could
have written a novel by now. Its hard to disagree with the sentiment
writing a novel is undoubtedly a better use of time than watching TV
but what about the hidden assumption? Such comments imply that time
is fungible that time spent watching TV can just as easily be
spent writing a novel. And sadly, thats just not the case.
</p>
</article>
</div>
),
'1': (
<div className={articleWrapper}>
<article className={article}>
<h1 className={title}>Learning with earning with retrieval practice</h1>
<p className={text}>
Are there any specific techniques to make the process of learning more
effective?
</p>
<p className={text}>
Students often re-read, underline, or highlight materials, thinking
that it will help them learn better. But, the best method for really
turning information into long-term memory is to use what is called
retrieval practice.
</p>
</article>
</div>
),
'2': (
<div className={articleWrapper}>
<article className={article}>
<h1 className={title}>
Local-first software
<br />
You own your data, in spite of the cloud
</h1>
<p className={text}>
Cloud apps like Google Docs and Trello are popular because they enable
real-time collaboration with colleagues, and they make it easy for us
to access our work from all of our devices. However, by centralizing
data storage on servers, cloud apps also take away ownership and
agency from users. If a service shuts down, the software stops
functioning, and data created with that software is lost.
</p>
</article>
</div>
),
'3': (
<div className={articleWrapper}>
<article className={article}>
<h1 className={title}>More Is Different</h1>
<p className={text}>
Broken symmetry and the nature of the hierarchical structure of
science
</p>
<p className={text}>
The reductionist hypothesis may still be a topic for controversy among
philosophers, but among the great majority of active scientists I
think it is accepted without questions. The workings of our minds and
bodies, and of all the animate or inanimate matter of which we have
any detailed knowledge, are assumed to be controlled by the same set
of fundamental laws, which except under certain extreme conditions we
feel we know pretty well.
</p>
</article>
</div>
),
'4': (
<div className={articleWrapper}>
<article className={article}>
<h1 className={title}>HOWTO: Be more productive</h1>
<p className={text}>
With all the time you spend watching TV, he tells me, you could
have written a novel by now. Its hard to disagree with the sentiment
writing a novel is undoubtedly a better use of time than watching TV
but what about the hidden assumption? Such comments imply that time
is fungible that time spent watching TV can just as easily be
spent writing a novel. And sadly, thats just not the case.
</p>
</article>
</div>
),
};
export const articles: Record<ArticleId, ArticleOption> = ids.reduce(
(acc, id) => {
return {
...acc,
[id]: {
id,
location: paperLocations[id],
enterOptions: paperEnterAnimations[id],
brief: paperBriefs[id],
} satisfies ArticleOption,
};
},
{} as Record<ArticleId, ArticleOption>
);

View File

@ -0,0 +1,94 @@
import { createVar, style } from '@vanilla-extract/css';
import { onboardingVars } from '../style.css';
export const paperWidthVar = createVar();
export const paperHeightVar = createVar();
export const paper = style({
vars: {
[paperWidthVar]: onboardingVars.paper.w,
[paperHeightVar]: onboardingVars.paper.h,
},
width: paperWidthVar,
height: paperHeightVar,
position: 'relative',
});
export const segment = style({
width: '100%',
height: '100%',
background: onboardingVars.paper.bg,
position: 'absolute',
top: `calc(var(--segments-up) / var(--segments) * 100%)`,
selectors: {
['&[data-root="true"]']: {
height: `calc(1 / var(--segments) * 100%)`,
},
['&[data-direction="up"]']: {
top: 'unset',
bottom: `100%`,
transformOrigin: 'bottom',
},
['&[data-direction="down"]']: {
top: `100%`,
transformOrigin: 'top',
},
['&[data-top="true"]']: {
borderTopLeftRadius: onboardingVars.paper.r,
borderTopRightRadius: onboardingVars.paper.r,
},
['&[data-bottom="true"]']: {
borderBottomLeftRadius: onboardingVars.paper.r,
borderBottomRightRadius: onboardingVars.paper.r,
},
},
});
export const contentWrapper = style({
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'absolute',
});
export const content = style({
padding: '16px',
overflow: 'hidden',
fontFamily: 'var(--affine-font-family)',
selectors: {
[`${contentWrapper} > &`]: {
position: 'absolute',
width: paperWidthVar,
height: paperHeightVar,
top: `calc((var(--index)) * -100%)`,
},
},
});
export const articleWrapper = style({
width: '100%',
height: '100%',
overflow: 'hidden',
});
export const article = style({
display: 'flex',
flexDirection: 'column',
gap: '12px',
color: onboardingVars.paper.textColor,
});
export const title = style({
fontSize: '18px',
fontWeight: 600,
lineHeight: '26px',
});
export const text = style({
fontSize: '14px',
fontWeight: 400,
lineHeight: '22px',
});

View File

@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import * as styles from './paper.css';
import { Segments } from './segments';
export interface PaperProps {
segments: number;
centerIndex: number;
content: ReactNode;
}
export const Paper = (props: PaperProps) => {
return (
<div className={styles.paper}>
<Segments
level={props.segments}
root={true}
index={props.segments}
{...props}
/>
</div>
);
};

View File

@ -0,0 +1,43 @@
import type { PropsWithChildren, ReactNode } from 'react';
import * as styles from './paper.css';
export interface SegmentProps extends PropsWithChildren {
index: number;
level?: number;
direction?: 'up' | 'down';
content: ReactNode;
isTop?: boolean;
isBottom?: boolean;
[key: string]: any;
}
export function Segment({
children,
index,
direction,
content,
level,
isTop,
isBottom,
...attrs
}: SegmentProps) {
const style = { '--index': index } as React.CSSProperties;
return (
<div
className={styles.segment}
data-direction={direction}
data-level={level}
data-bottom={(direction === 'down' && level === 1) || isBottom}
data-top={(direction === 'up' && level === 1) || isTop}
{...attrs}
>
<div className={styles.contentWrapper} style={style}>
<div className={styles.content}>{content}</div>
</div>
{children}
</div>
);
}

View File

@ -0,0 +1,70 @@
import { type ReactNode } from 'react';
import { Segment } from './segment';
export interface SegmentsProps {
level?: number;
direction?: 'up' | 'down';
index: number;
root?: boolean;
centerIndex: number;
segments: number;
content: ReactNode;
}
export function Segments({
level,
direction,
root,
index,
centerIndex,
segments,
content,
}: SegmentsProps) {
if (!level) return null;
const inherits = { centerIndex, segments, content };
if (root) {
const up = centerIndex;
const down = segments - up - 1;
const vars = {
'--segments': segments,
'--segments-up': up,
'--segments-down': down,
};
return (
<Segment
data-root={true}
style={vars}
index={up}
content={content}
isTop={up === 0}
isBottom={down === 0}
>
<Segments index={up - 1} level={up} direction="up" {...inherits} />
<Segments index={up + 1} level={down} direction="down" {...inherits} />
</Segment>
);
}
const children =
level === 1 ? null : (
<Segments
direction={direction}
index={direction === 'up' ? index - 1 : index + 1}
level={level - 1}
{...inherits}
/>
);
return (
<Segment
direction={direction}
index={index}
content={content}
level={level}
>
{children}
</Segment>
);
}

View File

@ -0,0 +1,44 @@
import type { CSSProperties } from 'react';
import { articles } from './articles';
import { PaperSteps } from './paper-steps';
import * as styles from './style.css';
interface OnboardingProps {
onOpenApp?: () => void;
}
export const Onboarding = (_: OnboardingProps) => {
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`,
'--delay': `${enterOptions.delay}ms`,
'--duration': enterOptions.duration,
'--easing': enterOptions.easing,
'--offset-x': `${location.x || 0}px`,
'--offset-y': `${location.y || 0}px`,
} as CSSProperties;
return (
<div style={style} key={id}>
<PaperSteps article={article} show={true} />
</div>
);
})}
</div>
</div>
);
};

View File

@ -0,0 +1,18 @@
import { useCallback } from 'react';
import { AnimateIn } from './steps/animate-in';
import type { ArticleOption } from './types';
interface PaperStepsProps {
show?: boolean;
article: ArticleOption;
}
export const PaperSteps = ({ show, article }: PaperStepsProps) => {
const onFinished = useCallback(() => {
console.log('onFinished');
}, []);
if (!show) return null;
return <AnimateIn article={article} onFinished={onFinished} />;
};

View File

@ -0,0 +1,21 @@
import { keyframes, style } from '@vanilla-extract/css';
import { paperLocation } from '../style.css';
const moveInAnim = keyframes({
'0%': {
transform: `translateZ(var(--fromZ)) translateX(var(--fromX)) translateY(var(--fromY)) rotateX(var(--fromRotateX)) rotateY(var(--fromRotateY)) rotateZ(var(--fromRotateZ))`,
},
'100%': {
transform: `translateZ(var(--toZ)) translateX(0) translateY(0) rotateX(0deg) rotateY(0deg) rotateZ(var(--toRotateZ))`,
},
});
export const moveIn = style([
paperLocation,
{
animation: `${moveInAnim} var(--duration) ease forwards`,
animationDelay: 'var(--delay)',
transform: 'translateY(100vh)', // hide on init
},
]);

View File

@ -0,0 +1,66 @@
import anime from 'animejs';
import { useEffect } from 'react';
import { Paper, type PaperProps } from '../curve-paper/paper';
import * as paperStyles from '../curve-paper/paper.css';
import type { ArticleOption } from '../types';
import * as styles from './animate-in.css';
interface AnimateInProps {
paperProps?: PaperProps;
article: ArticleOption;
onFinished?: () => void;
}
const easing = 'spring(5, 100, 10, 0)';
const animeSync = (params: Parameters<typeof anime>[0]) => {
return new Promise(resolve => {
anime({ ...params, complete: () => resolve(null) });
});
};
export const AnimateIn = ({
article,
paperProps,
onFinished,
}: AnimateInProps) => {
const { id: _id, enterOptions, brief } = article;
const id = `onboardingMoveIn${_id}`;
const segments = 4;
const rotateX = enterOptions.curve / segments;
useEffect(() => {
Promise.all([
animeSync({
targets: `[data-id="${id}"] .${paperStyles.segment}[data-direction="up"]`,
rotateX: [-rotateX, 0],
easing,
delay: enterOptions.delay,
}),
animeSync({
targets: `[data-id="${id}"] ${paperStyles.segment}[data-direction="down"]`,
rotateX: [rotateX, 0],
easing,
delay: enterOptions.delay,
}),
])
.then(() => {
onFinished?.();
})
.catch(console.error);
}, [enterOptions.delay, id, rotateX, onFinished]);
const props = {
...paperProps,
segments,
content: brief,
centerIndex: Math.min(segments - 1, Math.max(0, enterOptions.curveCenter)),
};
return (
<div data-id={id} className={styles.moveIn}>
<Paper {...props} />
</div>
);
};

View File

@ -0,0 +1,74 @@
import { globalStyle, style } from '@vanilla-extract/css';
// in case that we need to support dark mode later
export const onboardingVars = {
window: {
bg: 'var(--affine-pure-white)',
shadow: 'var(--affine-shadow-1)',
transition: {
size: '0.3s ease',
},
},
paper: {
w: '230px',
h: '302px',
r: '8px',
bg: 'var(--affine-pure-white)',
// textColor: 'var(--affine-light-text-primary-color)',
textColor: '#121212',
},
web: {
bg: '#fafafa', // TODO: use var
},
};
export const perspective = style({
perspective: '10000px',
transformStyle: 'preserve-3d',
});
export const onboarding = style([
perspective,
{
width: '100vw',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
selectors: {
// hack background color for web
'&::after': {
content: '',
position: 'absolute',
inset: 0,
background: onboardingVars.web.bg,
transform: 'translateZ(-1000px) scale(2)',
},
'&[data-is-desktop="true"]::after': {
content: 'unset',
},
},
},
]);
globalStyle(`${onboarding} *`, {
perspective: '10000px',
transformStyle: 'preserve-3d',
});
export const offsetOrigin = style({
width: 0,
height: 0,
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const paperLocation = style({
position: 'absolute',
left: `calc(var(--offset-x) - ${onboardingVars.paper.w} / 2)`,
top: `calc(var(--offset-y) - ${onboardingVars.paper.h} / 2)`,
});

View File

@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
export type OnboardingStep = 'enter' | 'unfold' | 'mode-switch';
export type ArticleId = '0' | '1' | '2' | '3' | '4';
/**
* Paper enter animation options
*/
export interface PaperEnterOptions {
// animation-curve
curveCenter: number;
curve: number;
// animation-move
fromZ: number;
fromX: number;
fromY: number;
fromRotateX: number;
fromRotateY: number;
fromRotateZ: number;
toZ: number;
toRotateZ: number;
// move-in animation config
duration: number | string;
delay: number;
easing: string;
}
export interface ArticleOption {
/** article id */
id: ArticleId;
/** paper enter animation content */
brief: ReactNode;
/** paper enter animation configuration */
enterOptions: PaperEnterOptions;
/** Locate paper */
location: {
/** offset X */
x: number;
/** offset Y */
y: number;
};
}

View File

@ -1,6 +1,7 @@
import { Button } from '@affine/component/ui/button';
import { useCallback } from 'react';
import { redirect } from 'react-router-dom';
import { Onboarding } from '../components/affine/onboarding/onboarding';
import {
appConfigStorage,
useAppConfigStorage,
@ -18,9 +19,9 @@ export const loader = () => {
export const Component = () => {
const { jumpToIndex } = useNavigateHelper();
const [onBoarding, setOnboarding] = useAppConfigStorage('onBoarding');
const [, setOnboarding] = useAppConfigStorage('onBoarding');
const openApp = () => {
const openApp = useCallback(() => {
if (environment.isDesktop) {
window.apis.ui.handleOpenMainApp().catch(err => {
console.log('failed to open main app', err);
@ -29,24 +30,7 @@ export const Component = () => {
jumpToIndex(RouteLogic.REPLACE);
setOnboarding(false);
}
};
}, [jumpToIndex, setOnboarding]);
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
gap: '8px',
height: '100vh',
}}
>
<Button onClick={() => setOnboarding(!onBoarding)}>
Toggle onboarding
</Button>
onboarding page, onboarding mode is {onBoarding ? 'on' : 'off'}
<Button onClick={openApp}>Enter App</Button>
</div>
);
return <Onboarding onOpenApp={openApp} />;
};

View File

@ -1,5 +1,5 @@
import { assert } from 'console';
import { BrowserWindow } from 'electron';
import { BrowserWindow, screen } from 'electron';
import { join } from 'path';
import { mainWindowOrigin } from './constants';
@ -27,17 +27,22 @@ async function createOnboardingWindow(additionalArguments: string[]) {
assert(helperExposedMeta, 'helperExposedMeta should be defined');
// get user's screen size
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
const browserWindow = new BrowserWindow({
width: 800,
height: 600,
width,
height,
frame: false,
show: false,
closable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
skipTaskbar: true,
// transparent: true,
// skipTaskbar: true,
transparent: true,
backgroundColor: '#00FFFFFF',
hasShadow: false,
webPreferences: {
webgl: true,
preload: join(__dirname, './preload.js'),
@ -46,7 +51,10 @@ async function createOnboardingWindow(additionalArguments: string[]) {
});
browserWindow.on('ready-to-show', () => {
browserWindow.show();
// TODO: add a timeout to avoid flickering, window is ready, but dom is not ready
setTimeout(() => {
browserWindow.show();
}, 300);
});
await browserWindow.loadURL(

View File

@ -379,6 +379,7 @@ __metadata:
"@swc/core": "npm:^1.3.93"
"@testing-library/react": "npm:^14.0.0"
"@toeverything/theme": "npm:^0.7.20"
"@types/animejs": "npm:^3"
"@types/bytes": "npm:^3.1.3"
"@types/image-blob-reduce": "npm:^4.1.3"
"@types/lodash-es": "npm:^4.17.9"
@ -386,6 +387,7 @@ __metadata:
"@types/webpack-env": "npm:^1.18.2"
"@vanilla-extract/css": "npm:^1.13.0"
"@vanilla-extract/dynamic": "npm:^2.0.3"
animejs: "npm:^3.2.2"
async-call-rpc: "npm:^6.3.1"
bytes: "npm:^3.1.2"
clsx: "npm:^2.0.0"
@ -14324,6 +14326,13 @@ __metadata:
languageName: unknown
linkType: soft
"@types/animejs@npm:^3":
version: 3.1.12
resolution: "@types/animejs@npm:3.1.12"
checksum: 8ea5d0440236b87042ad012c0bfd90a38cf38688b8b28ad750d21eb01a3943c9256f0ec79ea0b40aad6e500ee177ed43d5aeec8f875f904b61f195e5e305d2ce
languageName: node
linkType: hard
"@types/argparse@npm:1.0.38":
version: 1.0.38
resolution: "@types/argparse@npm:1.0.38"
@ -16580,6 +16589,13 @@ __metadata:
languageName: node
linkType: hard
"animejs@npm:^3.2.2":
version: 3.2.2
resolution: "animejs@npm:3.2.2"
checksum: 7abdb56f415c666ba02f4e64fdbb10d457fed7e3711b0f006f97e48e5650097013397d890e8ceb31e9e06b73bf6dfd9202309d0dae0fc0b5190aa7c4e0ab7054
languageName: node
linkType: hard
"ansi-align@npm:^3.0.1":
version: 3.0.1
resolution: "ansi-align@npm:3.0.1"