diff --git a/packages/frontend/core/public/onboarding/ai-onboarding.general.1.mov b/packages/frontend/core/public/onboarding/ai-onboarding.general.1.mov new file mode 100644 index 0000000000..c40c4da634 Binary files /dev/null and b/packages/frontend/core/public/onboarding/ai-onboarding.general.1.mov differ diff --git a/packages/frontend/core/public/onboarding/ai-onboarding.general.2.mov b/packages/frontend/core/public/onboarding/ai-onboarding.general.2.mov new file mode 100644 index 0000000000..5aa94503a8 Binary files /dev/null and b/packages/frontend/core/public/onboarding/ai-onboarding.general.2.mov differ diff --git a/packages/frontend/core/public/onboarding/ai-onboarding.general.3.mov b/packages/frontend/core/public/onboarding/ai-onboarding.general.3.mov new file mode 100644 index 0000000000..eb6813f533 Binary files /dev/null and b/packages/frontend/core/public/onboarding/ai-onboarding.general.3.mov differ diff --git a/packages/frontend/core/public/onboarding/ai-onboarding.general.4.mov b/packages/frontend/core/public/onboarding/ai-onboarding.general.4.mov new file mode 100644 index 0000000000..0862aea60b Binary files /dev/null and b/packages/frontend/core/public/onboarding/ai-onboarding.general.4.mov differ diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/base-style.css.ts b/packages/frontend/core/src/components/affine/ai-onboarding/base-style.css.ts new file mode 100644 index 0000000000..6fb370d83c --- /dev/null +++ b/packages/frontend/core/src/components/affine/ai-onboarding/base-style.css.ts @@ -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', + }, + }, +}); diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx new file mode 100644 index 0000000000..899709367c --- /dev/null +++ b/packages/frontend/core/src/components/affine/ai-onboarding/edgeless.dialog.tsx @@ -0,0 +1,9 @@ +import type { BaseAIOnboardingDialogProps } from './type'; + +export const AIOnboardingEdgeless = ({ + onDismiss: _, +}: BaseAIOnboardingDialogProps) => { + return ( +
{/* TODO: open edgeless in cloud workspace for the first time */}
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.css.ts b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.css.ts new file mode 100644 index 0000000000..7500df128d --- /dev/null +++ b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx new file mode 100644 index 0000000000..ce3a9f0080 --- /dev/null +++ b/packages/frontend/core/src/components/affine/ai-onboarding/general.dialog.tsx @@ -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; + +const getPlayList = (t: Translate): Array => [ + { + 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: ( + + ), + }} + /> + ), + }, +]; + +export const AIOnboardingGeneral = ({ + onDismiss, +}: BaseAIOnboardingDialogProps) => { + const videoWrapperRef = useRef(null); + const prevVideoRef = useRef(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) => ( +
+
+ ), + [] + ); + const titleRenderer = useCallback( + ({ title }: PlayListItem) =>

{title}

, + [] + ); + const descriptionRenderer = useCallback( + ({ desc }: PlayListItem) =>

{desc}

, + [] + ); + + 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 ? ( + +
+ + rootRef={videoWrapperRef} + className={styles.videoHeader} + items={list} + activeIndex={index} + preload={5} + itemRenderer={videoRenderer} + /> + +
+ + items={list} + activeIndex={index} + itemRenderer={titleRenderer} + transitionDuration={400} + /> + + items={list} + activeIndex={index} + itemRenderer={descriptionRenderer} + transitionDuration={500} + /> +
+ +
+ {isLast ? ( + <> + + + + ) : ( + <> + {isFirst ? ( + + ) : ( + + )} + + + )} +
+
+
+ ) : null; +}; diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/index.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/index.tsx new file mode 100644 index 0000000000..e8de47d50e --- /dev/null +++ b/packages/frontend/core/src/components/affine/ai-onboarding/index.tsx @@ -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 ( + + {dismissGeneral ? null : ( + + )} + {dismissEdgeless ? null : ( + + )} + {dismissLocal ? null : } + + ); +}; diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/local.dialog.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/local.dialog.tsx new file mode 100644 index 0000000000..2e14f2f8e2 --- /dev/null +++ b/packages/frontend/core/src/components/affine/ai-onboarding/local.dialog.tsx @@ -0,0 +1,5 @@ +import type { BaseAIOnboardingDialogProps } from './type'; + +export const AIOnboardingLocal = (_: BaseAIOnboardingDialogProps) => { + return
{/* TODO: open local workspace for the first time */}
; +}; diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/slider.css.ts b/packages/frontend/core/src/components/affine/ai-onboarding/slider.css.ts new file mode 100644 index 0000000000..557b8df692 --- /dev/null +++ b/packages/frontend/core/src/components/affine/ai-onboarding/slider.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/slider.tsx b/packages/frontend/core/src/components/affine/ai-onboarding/slider.tsx new file mode 100644 index 0000000000..15fe40b10b --- /dev/null +++ b/packages/frontend/core/src/components/affine/ai-onboarding/slider.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import { type HTMLAttributes, type Ref } from 'react'; + +import * as styles from './slider.css'; + +export interface SliderProps extends HTMLAttributes { + items: T[]; + activeIndex?: number; + itemRenderer?: (item: T, index: number) => React.ReactNode; + /** + * preload next and previous slides + */ + preload?: number; + transitionDuration?: number; + transitionTimingFunction?: string; + + rootRef?: Ref; +} + +/** + * TODO: extract to @affine/ui + * @returns + */ +export const Slider = ({ + rootRef, + items, + className, + preload = 1, + activeIndex = 0, + transitionDuration = 300, + transitionTimingFunction = 'cubic-bezier(.33,.36,0,1)', + itemRenderer, + ...attrs +}: SliderProps) => { + const count = items.length; + const unit = Math.floor(100 / count); + + return ( +
+
+ {items?.map((item, index) => ( +
+ {preload === undefined || Math.abs(index - activeIndex) <= preload + ? itemRenderer?.(item, index) + : null} +
+ ))} +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/ai-onboarding/type.ts b/packages/frontend/core/src/components/affine/ai-onboarding/type.ts new file mode 100644 index 0000000000..cf1306738f --- /dev/null +++ b/packages/frontend/core/src/components/affine/ai-onboarding/type.ts @@ -0,0 +1,8 @@ +export interface BaseAIOnboardingDialogProps { + onDismiss: () => void; +} +export enum AIOnboardingType { + GENERAL = 'dismissAiOnboarding', + EDGELESS = 'dismissAiOnboardingEdgeless', + LOCAL = 'dismissAiOnboardingLocal', +} diff --git a/packages/frontend/core/src/hooks/use-blur-root.ts b/packages/frontend/core/src/hooks/use-blur-root.ts new file mode 100644 index 0000000000..63bb356070 --- /dev/null +++ b/packages/frontend/core/src/hooks/use-blur-root.ts @@ -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]); +}; diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index 9a2d43879d..1bdff51663 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -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({ }> {children} + {/* should show after workspace loaded */} + ); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 9e5335c835..3d983a0380 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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 {{link}} 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" } diff --git a/tests/kit/electron.ts b/tests/kit/electron.ts index c8770a8607..c9b5895660 100644 --- a/tests/kit/electron.ts +++ b/tests/kit/electron.ts @@ -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) { diff --git a/tests/kit/playwright.ts b/tests/kit/playwright.ts index ba5f827aaa..ad02e76e39 100644 --- a/tests/kit/playwright.ts +++ b/tests/kit/playwright.ts @@ -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'); }); }; diff --git a/tests/storybook/.storybook/preview.tsx b/tests/storybook/.storybook/preview.tsx index 651bef1678..1e18eb1f4b 100644 --- a/tests/storybook/.storybook/preview.tsx +++ b/tests/storybook/.storybook/preview.tsx @@ -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();