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

This commit is contained in:
CatsJuice 2024-04-15 07:25:30 +00:00
parent 8bb597d7ad
commit 257e946d5d
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
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>
);

View File

@ -1279,5 +1279,20 @@
"unnamed": "unnamed",
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.",
"will be moved to Trash": "{{title}} will be moved to Trash",
"will delete member": "will delete member"
"will delete member": "will delete member",
"com.affine.ai-onboarding.general.1.title": "Meet AFFiNE AI",
"com.affine.ai-onboarding.general.1.description": "Lets you think bigger, create faster, work smarter and save time for every project.",
"com.affine.ai-onboarding.general.2.title": "Chat with AFFiNE AI",
"com.affine.ai-onboarding.general.2.description": "Get instant insights to all your questions.",
"com.affine.ai-onboarding.general.3.title": "Edit Inline with AFFiNE AI",
"com.affine.ai-onboarding.general.3.description": "Perfect tone, spelling, and summaries in seconds.",
"com.affine.ai-onboarding.general.4.title": "Make it Real with AFFiNE AI",
"com.affine.ai-onboarding.general.4.description": "From concept to completion, turn ideas into reality.",
"com.affine.ai-onboarding.general.5.title": "AFFiNE AI is ready",
"com.affine.ai-onboarding.general.5.description": "Go to <a>{{link}}</a> for learn more details about AFFiNE AI.",
"com.affine.ai-onboarding.general.skip": "Alert me later",
"com.affine.ai-onboarding.general.next": "Next",
"com.affine.ai-onboarding.general.prev": "Back",
"com.affine.ai-onboarding.general.try-for-free": "Tree for Free",
"com.affine.ai-onboarding.general.purchase": "Get Unlimited Usage"
}

View File

@ -36,6 +36,11 @@ export const test = base.extend<{
}>({
page: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
await page.evaluate(() => {
window.localStorage.setItem('dismissAiOnboarding', 'true');
window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
});
// wait for blocksuite to be loaded
await page.waitForSelector('v-line');
if (enableCoverage) {

View File

@ -36,6 +36,9 @@ type CurrentDocCollection = {
export const skipOnboarding = async (context: BrowserContext) => {
await context.addInitScript(() => {
window.localStorage.setItem('app_config', '{"onBoarding":false}');
window.localStorage.setItem('dismissAiOnboarding', 'true');
window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
});
};

View File

@ -67,6 +67,9 @@ localStorage.clear();
// do not show onboarding for storybook
window.localStorage.setItem('app_config', '{"onBoarding":false}');
window.localStorage.setItem('dismissAiOnboarding', 'true');
window.localStorage.setItem('dismissAiOnboardingEdgeless', 'true');
window.localStorage.setItem('dismissAiOnboardingLocal', 'true');
const services = new ServiceCollection();