feat(core): ai onboarding for edgeless mode (#6556)

This commit is contained in:
CatsJuice
2024-04-15 07:25:36 +00:00
parent 257e946d5d
commit b93e79c59d
14 changed files with 88362 additions and 55 deletions

View File

@@ -15,20 +15,19 @@ import {
export interface NotificationCardProps extends HTMLAttributes<HTMLDivElement> {
notification: Notification;
onDismiss?: () => void;
}
export const NotificationCard = ({
notification,
onDismiss,
}: NotificationCardProps) => {
export const NotificationCard = ({ notification }: NotificationCardProps) => {
const {
theme = 'info',
style = 'normal',
icon = <InformationFillDuotoneIcon />,
thumb,
action,
title,
footer,
alignMessage = 'title',
onDismiss,
} = notification;
const onActionClicked = useCallback(() => {
@@ -49,33 +48,41 @@ export const NotificationCard = ({
data-with-icon={icon ? '' : undefined}
className={styles.card}
>
<header className={styles.header}>
{icon ? (
<div className={clsx(styles.icon, styles.headAlignWrapper)}>
{icon}
</div>
) : null}
<div className={styles.title}>{title}</div>
{thumb}
<div className={styles.cardInner}>
<header className={styles.header}>
{icon ? (
<div className={clsx(styles.icon, styles.headAlignWrapper)}>
{icon}
</div>
) : null}
<div className={styles.title}>{title}</div>
{action ? (
<div className={clsx(styles.headAlignWrapper, styles.action)}>
<Button
className={styles.actionButton}
onClick={onActionClicked}
{...action.buttonProps}
>
{action.label}
</Button>
{action ? (
<div className={clsx(styles.headAlignWrapper, styles.action)}>
<Button
className={styles.actionButton}
onClick={onActionClicked}
{...action.buttonProps}
>
{action.label}
</Button>
</div>
) : null}
<div
data-float={!!thumb}
className={clsx(styles.headAlignWrapper, styles.closeButton)}
>
<IconButton onClick={onDismiss}>
<CloseIcon className={styles.closeIcon} width={16} height={16} />
</IconButton>
</div>
) : null}
<div className={styles.headAlignWrapper}>
<IconButton onClick={onDismiss}>
<CloseIcon className={styles.closeIcon} width={16} height={16} />
</IconButton>
</div>
</header>
<main className={styles.main}>{notification.message}</main>
<footer>{footer}</footer>
</header>
<main data-align={alignMessage} className={styles.main}>
{notification.message}
</main>
<footer>{footer}</footer>
</div>
</div>
);
};

View File

@@ -46,12 +46,11 @@ export function NotificationCenter({ width = 380 }: NotificationCenterProps) {
*/
export function notify(notification: Notification, options?: ExternalToast) {
return toast.custom(id => {
return (
<NotificationCard
notification={notification}
onDismiss={() => toast.dismiss(id)}
/>
);
const onDismiss = () => {
notification.onDismiss?.();
toast.dismiss(id);
};
return <NotificationCard notification={{ ...notification, onDismiss }} />;
}, options);
}

View File

@@ -10,12 +10,14 @@ export const card = style({
borderRadius: 8,
borderWidth: 1,
borderStyle: 'solid',
padding: 16,
boxShadow: cssVar('shadow1'),
backgroundColor: cardColor,
borderColor: cardBorderColor,
color: cardForeground,
});
export const cardInner = style({
padding: 16,
});
export const header = style({
display: 'flex',
@@ -68,17 +70,26 @@ export const actionButton = style({
boxShadow: 'none !important',
},
});
export const closeButton = style({
selectors: {
'&[data-float="true"]': {
position: 'absolute',
top: 16,
right: 16,
},
},
});
export const closeIcon = style({
color: `${cardForeground} !important`,
});
export const main = style({
marginTop: 5,
fontSize: 14,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
selectors: {
'[data-with-icon] &': {
'[data-with-icon] &[data-align="title"]': {
paddingLeft: 34,
},
},

View File

@@ -12,6 +12,7 @@ export interface Notification {
borderColor?: string;
background?: string;
foreground?: string;
alignMessage?: 'title' | 'icon';
action?: {
label: string;
onClick: (() => void) | (() => Promise<void>);
@@ -23,10 +24,14 @@ export interface Notification {
};
// custom slots
thumb?: ReactNode;
title?: ReactNode;
message?: ReactNode;
icon?: ReactNode;
footer?: ReactNode;
// events
onDismiss?: () => void;
}
export interface NotificationCenterProps {

View File

@@ -0,0 +1,12 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const thumb = style({
borderRadius: 'inherit',
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
width: '100%',
height: 211,
background: cssVar('backgroundOverlayPanelColor'),
overflow: 'hidden',
});

View File

@@ -1,9 +1,73 @@
import { notify } from '@affine/component';
import { CurrentWorkspaceService } from '@affine/core/modules/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { AiIcon } from '@blocksuite/icons';
import { Doc, LiveData, useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import Lottie from 'lottie-react';
import { useTheme } from 'next-themes';
import { useEffect, useMemo, useRef } from 'react';
import * as styles from './edgeless.dialog.css';
import mouseDark from './lottie/edgeless/mouse-dark.json';
import mouseLight from './lottie/edgeless/mouse-light.json';
import trackPadDark from './lottie/edgeless/trackpad-dark.json';
import trackPadLight from './lottie/edgeless/trackpad-light.json';
import type { BaseAIOnboardingDialogProps } from './type';
export const AIOnboardingEdgeless = ({
onDismiss: _,
}: BaseAIOnboardingDialogProps) => {
return (
<div>{/* TODO: open edgeless in cloud workspace for the first time */}</div>
);
const EdgelessOnboardingAnimation = () => {
const { resolvedTheme } = useTheme();
const isTrackPad = false;
const data = useMemo(() => {
if (isTrackPad) {
return resolvedTheme === 'dark' ? trackPadDark : trackPadLight;
}
return resolvedTheme === 'dark' ? mouseDark : mouseLight;
}, [isTrackPad, resolvedTheme]);
return <Lottie loop autoplay animationData={data} className={styles.thumb} />;
};
// avoid notifying multiple times
const notifyId$ = new LiveData<string | number | null>(null);
export const AIOnboardingEdgeless = ({
onDismiss,
}: BaseAIOnboardingDialogProps) => {
const t = useAFFiNEI18N();
const notifyId = useLiveData(notifyId$);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const currentWorkspace = useLiveData(
useService(CurrentWorkspaceService).currentWorkspace$
);
const isCloud = currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const doc = useService(Doc);
const mode = useLiveData(doc.mode$);
useEffect(() => {
if (notifyId) return;
if (isCloud && mode === 'edgeless') {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
const id = notify(
{
title: t['com.affine.ai-onboarding.edgeless.title'](),
message: t['com.affine.ai-onboarding.edgeless.message'](),
icon: <AiIcon color={cssVar('processingColor')} />,
thumb: <EdgelessOnboardingAnimation />,
alignMessage: 'icon',
onDismiss,
},
{ duration: 1000 * 60 * 10 }
);
notifyId$.next(id);
}, 1000);
}
}, [isCloud, mode, notifyId, onDismiss, t]);
return null;
};

View File

@@ -16,13 +16,10 @@ const useDismiss = (key: AIOnboardingType) => {
return [dismiss, onDismiss] as const;
};
export const AIOnboarding = () => {
export const WorkspaceAIOnboarding = () => {
const [dismissGeneral, onDismissGeneral] = useDismiss(
AIOnboardingType.GENERAL
);
const [dismissEdgeless, onDismissEdgeless] = useDismiss(
AIOnboardingType.EDGELESS
);
const [dismissLocal, onDismissLocal] = useDismiss(AIOnboardingType.LOCAL);
return (
@@ -30,10 +27,22 @@ export const AIOnboarding = () => {
{dismissGeneral ? null : (
<AIOnboardingGeneral onDismiss={onDismissGeneral} />
)}
{dismissEdgeless ? null : (
<AIOnboardingEdgeless onDismiss={onDismissEdgeless} />
)}
{dismissLocal ? null : <AIOnboardingLocal onDismiss={onDismissLocal} />}
</Suspense>
);
};
export const PageAIOnboarding = () => {
const [dismissEdgeless, onDismissEdgeless] = useDismiss(
AIOnboardingType.EDGELESS
);
return (
<Suspense>
{dismissEdgeless ? null : (
<AIOnboardingEdgeless onDismiss={onDismissEdgeless} />
)}
</Suspense>
);
};

View File

@@ -22,7 +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 { WorkspaceAIOnboarding } from '../components/affine/ai-onboarding';
import { AppContainer } from '../components/affine/app-container';
import { SyncAwareness } from '../components/affine/awareness';
import {
@@ -102,7 +102,7 @@ export const WorkspaceLayout = function WorkspaceLayout({
<Suspense fallback={<WorkspaceFallback />}>
<WorkspaceLayoutInner>{children}</WorkspaceLayoutInner>
{/* should show after workspace loaded */}
<AIOnboarding />
<WorkspaceAIOnboarding />
</Suspense>
</SWRConfigProvider>
);

View File

@@ -1,5 +1,6 @@
import { Scrollable } from '@affine/component';
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import type { PageRootService } from '@blocksuite/blocks';
import {
@@ -283,6 +284,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
<ImagePreviewModal pageId={currentPageId} docCollection={docCollection} />
<GlobalPageHistoryModal />
<PageAIOnboarding />
</>
);
});

View File

@@ -1294,5 +1294,7 @@
"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"
"com.affine.ai-onboarding.general.purchase": "Get Unlimited Usage",
"com.affine.ai-onboarding.edgeless.title": "Meet AFFiNE AI",
"com.affine.ai-onboarding.edgeless.message": "Lets you think bigger, create faster, work smarter and save time for every project."
}