mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-24 19:22:49 +03:00
feat(core): add ai onboarding (#6544)
This commit is contained in:
parent
8bb597d7ad
commit
257e946d5d
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
});
|
@ -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;
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
import type { BaseAIOnboardingDialogProps } from './type';
|
||||
|
||||
export const AIOnboardingLocal = (_: BaseAIOnboardingDialogProps) => {
|
||||
return <div>{/* TODO: open local workspace for the first time */}</div>;
|
||||
};
|
@ -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,
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
export interface BaseAIOnboardingDialogProps {
|
||||
onDismiss: () => void;
|
||||
}
|
||||
export enum AIOnboardingType {
|
||||
GENERAL = 'dismissAiOnboarding',
|
||||
EDGELESS = 'dismissAiOnboardingEdgeless',
|
||||
LOCAL = 'dismissAiOnboardingLocal',
|
||||
}
|
14
packages/frontend/core/src/hooks/use-blur-root.ts
Normal file
14
packages/frontend/core/src/hooks/use-blur-root.ts
Normal 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]);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user