feat(core): add ai onboarding (#6544)

This commit is contained in:
CatsJuice
2024-04-15 07:25:30 +00:00
parent 8bb597d7ad
commit 257e946d5d
19 changed files with 485 additions and 1 deletions

View File

@@ -0,0 +1,29 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const dialogOverlay = style({
background: `linear-gradient(95deg, transparent 0px, ${cssVar('backgroundPrimaryColor')} 400px)`,
});
export const slideTransition = style({
transition: 'all 0.3s',
selectors: {
'&.preEnter, &.exiting': {
opacity: 0,
position: 'absolute',
},
'&.preEnter.left, &.exiting.left': {
transform: 'translateX(-100%)',
},
'&.preEnter.right, &.exiting.right': {
transform: 'translateX(100%)',
},
'&.exited:not([data-force-render="true"])': {
display: 'none',
},
'&.exited[data-force-render="true"]': {
visibility: 'hidden',
},
},
});

View File

@@ -0,0 +1,9 @@
import type { BaseAIOnboardingDialogProps } from './type';
export const AIOnboardingEdgeless = ({
onDismiss: _,
}: BaseAIOnboardingDialogProps) => {
return (
<div>{/* TODO: open edgeless in cloud workspace for the first time */}</div>
);
};

View File

@@ -0,0 +1,79 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const dialog = style({
maxWidth: 400,
width: 'calc(100% - 32px)',
padding: 0,
boxShadow: 'none',
'::after': {
content: '""',
position: 'absolute',
borderRadius: 'inherit',
top: 0,
left: 0,
right: 0,
bottom: 0,
boxShadow: cssVar('menuShadow'),
pointerEvents: 'none',
},
});
export const dialogContent = style({
overflow: 'hidden',
width: '100%',
height: '100%',
borderRadius: 'inherit',
});
export const videoHeader = style({
borderRadius: 'inherit',
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
width: '100%',
height: 225,
overflow: 'hidden',
});
export const videoWrapper = style({
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
});
export const video = style({
position: 'absolute',
left: -2,
top: -2,
width: 'calc(100% + 4px)',
height: 'calc(100% + 4px)',
});
export const title = style({
padding: '20px 24px 8px 24px',
fontSize: cssVar('fontH6'),
fontWeight: 600,
lineHeight: '26px',
color: cssVar('textPrimaryColor'),
});
export const description = style({
padding: '0px 24px',
fontSize: cssVar('fontBase'),
lineHeight: '24px',
minHeight: 48,
fontWeight: 400,
color: cssVar('textPrimaryColor'),
});
export const link = style({
color: cssVar('textEmphasisColor'),
textDecoration: 'underline',
});
export const footer = style({
padding: '20px 28px',
gap: 12,
display: 'flex',
justifyContent: 'flex-end',
});
export const skipButton = style({
fontWeight: 500,
});

View File

@@ -0,0 +1,198 @@
import { Button, Modal } from '@affine/component';
import { openSettingModalAtom } from '@affine/core/atoms';
import { useBlurRoot } from '@affine/core/hooks/use-blur-root';
import { CurrentWorkspaceService } from '@affine/core/modules/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService } from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as baseStyles from './base-style.css';
import * as styles from './general.dialog.css';
import { Slider } from './slider';
import type { BaseAIOnboardingDialogProps } from './type';
type PlayListItem = { video: string; title: ReactNode; desc: ReactNode };
type Translate = ReturnType<typeof useAFFiNEI18N>;
const getPlayList = (t: Translate): Array<PlayListItem> => [
{
video: '/onboarding/ai-onboarding.general.1.mov',
title: t['com.affine.ai-onboarding.general.1.title'](),
desc: t['com.affine.ai-onboarding.general.1.description'](),
},
{
video: '/onboarding/ai-onboarding.general.2.mov',
title: t['com.affine.ai-onboarding.general.2.title'](),
desc: t['com.affine.ai-onboarding.general.2.description'](),
},
{
video: '/onboarding/ai-onboarding.general.3.mov',
title: t['com.affine.ai-onboarding.general.3.title'](),
desc: t['com.affine.ai-onboarding.general.3.description'](),
},
{
video: '/onboarding/ai-onboarding.general.4.mov',
title: t['com.affine.ai-onboarding.general.4.title'](),
desc: t['com.affine.ai-onboarding.general.4.description'](),
},
{
video: '/onboarding/ai-onboarding.general.1.mov',
title: t['com.affine.ai-onboarding.general.5.title'](),
desc: (
<Trans
i18nKey="com.affine.ai-onboarding.general.5.description"
values={{ link: 'ai.affine.pro' }}
components={{
a: (
<a
className={styles.link}
href="https://ai.affine.pro"
target="_blank"
rel="noreferrer"
/>
),
}}
/>
),
},
];
export const AIOnboardingGeneral = ({
onDismiss,
}: BaseAIOnboardingDialogProps) => {
const videoWrapperRef = useRef<HTMLDivElement | null>(null);
const prevVideoRef = useRef<HTMLVideoElement | null>(null);
const currentWorkspace = useLiveData(
useService(CurrentWorkspaceService).currentWorkspace$
);
const isCloud = currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const t = useAFFiNEI18N();
const [open, setOpen] = useState(true);
const [index, setIndex] = useState(0);
const list = useMemo(() => getPlayList(t), [t]);
const setSettingModal = useSetAtom(openSettingModalAtom);
useBlurRoot(open && isCloud);
const isFirst = index === 0;
const isLast = index === list.length - 1;
const closeAndDismiss = useCallback(() => {
setOpen(false);
onDismiss();
}, [onDismiss]);
const goToPricingPlans = useCallback(() => {
setSettingModal({
open: true,
activeTab: 'plans',
scrollAnchor: 'aiPricingPlan',
});
closeAndDismiss();
}, [closeAndDismiss, setSettingModal]);
const onClose = useCallback(() => setOpen(false), []);
const onPrev = useCallback(() => {
setIndex(i => Math.max(0, i - 1));
}, []);
const onNext = useCallback(() => {
setIndex(i => Math.min(list.length - 1, i + 1));
}, [list.length]);
const videoRenderer = useCallback(
({ video }: PlayListItem) => (
<div className={styles.videoWrapper}>
<video src={video} className={styles.video} loop muted playsInline />
</div>
),
[]
);
const titleRenderer = useCallback(
({ title }: PlayListItem) => <h1 className={styles.title}>{title}</h1>,
[]
);
const descriptionRenderer = useCallback(
({ desc }: PlayListItem) => <p className={styles.description}>{desc}</p>,
[]
);
useEffect(() => {
const videoWrapper = videoWrapperRef.current;
if (!videoWrapper) return;
const videos = videoWrapper.querySelectorAll('video');
const video = videos[index];
if (!video) return;
if (prevVideoRef.current) {
prevVideoRef.current.pause();
}
video.play().catch(console.error);
prevVideoRef.current = video;
}, [index]);
return isCloud ? (
<Modal
open={open}
onOpenChange={setOpen}
contentOptions={{ className: styles.dialog }}
overlayOptions={{ className: baseStyles.dialogOverlay }}
>
<div className={styles.dialogContent}>
<Slider<PlayListItem>
rootRef={videoWrapperRef}
className={styles.videoHeader}
items={list}
activeIndex={index}
preload={5}
itemRenderer={videoRenderer}
/>
<main>
<Slider<PlayListItem>
items={list}
activeIndex={index}
itemRenderer={titleRenderer}
transitionDuration={400}
/>
<Slider<PlayListItem>
items={list}
activeIndex={index}
itemRenderer={descriptionRenderer}
transitionDuration={500}
/>
</main>
<footer className={styles.footer}>
{isLast ? (
<>
<Button onClick={closeAndDismiss}>
{t['com.affine.ai-onboarding.general.try-for-free']()}
</Button>
<Button onClick={goToPricingPlans} type="primary">
{t['com.affine.ai-onboarding.general.purchase']()}
</Button>
</>
) : (
<>
{isFirst ? (
<Button onClick={onClose} className={styles.skipButton}>
{t['com.affine.ai-onboarding.general.skip']()}
</Button>
) : (
<Button onClick={onPrev}>
{t['com.affine.ai-onboarding.general.prev']()}
</Button>
)}
<Button type="primary" onClick={onNext}>
{t['com.affine.ai-onboarding.general.next']()}
</Button>
</>
)}
</footer>
</div>
</Modal>
) : null;
};

View File

@@ -0,0 +1,39 @@
import { Suspense, useCallback, useState } from 'react';
import { AIOnboardingEdgeless } from './edgeless.dialog';
import { AIOnboardingGeneral } from './general.dialog';
import { AIOnboardingLocal } from './local.dialog';
import { AIOnboardingType } from './type';
const useDismiss = (key: AIOnboardingType) => {
const [dismiss, setDismiss] = useState(localStorage.getItem(key) === 'true');
const onDismiss = useCallback(() => {
setDismiss(true);
localStorage.setItem(key, 'true');
}, [key]);
return [dismiss, onDismiss] as const;
};
export const AIOnboarding = () => {
const [dismissGeneral, onDismissGeneral] = useDismiss(
AIOnboardingType.GENERAL
);
const [dismissEdgeless, onDismissEdgeless] = useDismiss(
AIOnboardingType.EDGELESS
);
const [dismissLocal, onDismissLocal] = useDismiss(AIOnboardingType.LOCAL);
return (
<Suspense>
{dismissGeneral ? null : (
<AIOnboardingGeneral onDismiss={onDismissGeneral} />
)}
{dismissEdgeless ? null : (
<AIOnboardingEdgeless onDismiss={onDismissEdgeless} />
)}
{dismissLocal ? null : <AIOnboardingLocal onDismiss={onDismissLocal} />}
</Suspense>
);
};

View File

@@ -0,0 +1,5 @@
import type { BaseAIOnboardingDialogProps } from './type';
export const AIOnboardingLocal = (_: BaseAIOnboardingDialogProps) => {
return <div>{/* TODO: open local workspace for the first time */}</div>;
};

View File

@@ -0,0 +1,16 @@
import { style } from '@vanilla-extract/css';
export const slider = style({
overflow: 'clip',
});
export const sliderContent = style({
display: 'flex',
height: '100%',
willChange: 'transform',
});
export const slideItem = style({
width: 0,
flex: 1,
});

View File

@@ -0,0 +1,58 @@
import clsx from 'clsx';
import { type HTMLAttributes, type Ref } from 'react';
import * as styles from './slider.css';
export interface SliderProps<T> extends HTMLAttributes<HTMLDivElement> {
items: T[];
activeIndex?: number;
itemRenderer?: (item: T, index: number) => React.ReactNode;
/**
* preload next and previous slides
*/
preload?: number;
transitionDuration?: number;
transitionTimingFunction?: string;
rootRef?: Ref<HTMLDivElement>;
}
/**
* TODO: extract to @affine/ui
* @returns
*/
export const Slider = <T,>({
rootRef,
items,
className,
preload = 1,
activeIndex = 0,
transitionDuration = 300,
transitionTimingFunction = 'cubic-bezier(.33,.36,0,1)',
itemRenderer,
...attrs
}: SliderProps<T>) => {
const count = items.length;
const unit = Math.floor(100 / count);
return (
<div ref={rootRef} className={clsx(className, styles.slider)} {...attrs}>
<div
className={styles.sliderContent}
style={{
width: `${items.length * 100}%`,
transform: `translateX(-${activeIndex * unit}%)`,
transition: `transform ${transitionDuration}ms ${transitionTimingFunction}`,
}}
>
{items?.map((item, index) => (
<div key={index} className={styles.slideItem}>
{preload === undefined || Math.abs(index - activeIndex) <= preload
? itemRenderer?.(item, index)
: null}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,8 @@
export interface BaseAIOnboardingDialogProps {
onDismiss: () => void;
}
export enum AIOnboardingType {
GENERAL = 'dismissAiOnboarding',
EDGELESS = 'dismissAiOnboardingEdgeless',
LOCAL = 'dismissAiOnboardingLocal',
}

View File

@@ -0,0 +1,14 @@
import { useEffect } from 'react';
export const useBlurRoot = (blur: boolean) => {
// blur modal background, can't use css: `backdrop-filter: blur()`,
// because it won't behave as expected on client side (texts over transparent window are not blurred)
useEffect(() => {
const appDom = document.querySelector('#app') as HTMLElement;
if (!appDom) return;
appDom.style.filter = blur ? 'blur(7px)' : 'none';
return () => {
appDom.style.filter = 'none';
};
}, [blur]);
};

View File

@@ -22,6 +22,7 @@ import { matchPath } from 'react-router-dom';
import { Map as YMap } from 'yjs';
import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms';
import { AIOnboarding } from '../components/affine/ai-onboarding';
import { AppContainer } from '../components/affine/app-container';
import { SyncAwareness } from '../components/affine/awareness';
import {
@@ -100,6 +101,8 @@ export const WorkspaceLayout = function WorkspaceLayout({
</Suspense>
<Suspense fallback={<WorkspaceFallback />}>
<WorkspaceLayoutInner>{children}</WorkspaceLayoutInner>
{/* should show after workspace loaded */}
<AIOnboarding />
</Suspense>
</SWRConfigProvider>
);