From b38c46649f25014d47e86749c152a03ab73a421d Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Mon, 24 Jun 2024 14:37:38 +0000 Subject: [PATCH] feat(core): adjust ai help island style and behavior, add animation (#7310) - Move right-sidebar activeTab state into `RightSidebarService` - Remove `styled` usage and adjust the UI - Fix the issue that ai-button clicking not work when sidebar opened - Add an animation if AI-Chat panel hasn't been opened. ![CleanShot 2024-06-24 at 18.10.27.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/14c86fbc-ad4c-44bf-93fe-ca3450db908b.gif) --- .../frontend/component/src/styles/styled.tsx | 7 +- .../src/components/pure/ai-island/index.tsx | 95 ++++++++-------- .../src/components/pure/ai-island/style.ts | 43 ------- .../components/pure/ai-island/styles.css.ts | 107 ++++++++++++++++++ .../src/components/workspace/index.css.ts | 17 +-- .../right-sidebar/entities/right-sidebar.ts | 31 +++++ .../workspace/detail-page/detail-page.tsx | 30 ++--- 7 files changed, 204 insertions(+), 126 deletions(-) delete mode 100644 packages/frontend/core/src/components/pure/ai-island/style.ts create mode 100644 packages/frontend/core/src/components/pure/ai-island/styles.css.ts diff --git a/packages/frontend/component/src/styles/styled.tsx b/packages/frontend/component/src/styles/styled.tsx index f357a2d9e5..ac19013f27 100644 --- a/packages/frontend/component/src/styles/styled.tsx +++ b/packages/frontend/component/src/styles/styled.tsx @@ -1,3 +1,8 @@ import styled from '@emotion/styled'; -export { styled }; +export { + /** + * @deprecated `@emotion/styled` is planned to be removed in the future. Do not use for new components. + **/ + styled, +}; diff --git a/packages/frontend/core/src/components/pure/ai-island/index.tsx b/packages/frontend/core/src/components/pure/ai-island/index.tsx index a39b34a02f..e5517f8ef2 100644 --- a/packages/frontend/core/src/components/pure/ai-island/index.tsx +++ b/packages/frontend/core/src/components/pure/ai-island/index.tsx @@ -1,65 +1,66 @@ -import type { SidebarTabName } from '@affine/core/modules/multi-tab-sidebar'; import { RightSidebarService } from '@affine/core/modules/right-sidebar'; -import { - DocsService, - GlobalContextService, - GlobalStateService, - LiveData, - useLiveData, - useService, -} from '@toeverything/infra'; -import { useCallback } from 'react'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useEffect, useState } from 'react'; import { ToolContainer } from '../../workspace'; import { AIIcon } from './icons'; -import { StyledIsland, StyledTriggerWrapper } from './style'; +import { + aiIslandAnimationBg, + aiIslandBtn, + aiIslandWrapper, + borderAngle1, + borderAngle2, + borderAngle3, +} from './styles.css'; -export const RIGHT_SIDEBAR_TABS_ACTIVE_KEY = - 'app:settings:rightsidebar:tabs:active'; +if (typeof window !== 'undefined' && window.CSS) { + const getName = (nameWithVar: string) => nameWithVar.slice(4, -1); + const registerAngle = (varName: string, initialValue: number) => { + window.CSS.registerProperty({ + name: getName(varName), + syntax: '', + inherits: false, + initialValue: `${initialValue}deg`, + }); + }; + registerAngle(borderAngle1, 0); + registerAngle(borderAngle2, 90); + registerAngle(borderAngle3, 180); +} export const AIIsland = () => { - const docId = useLiveData( - useService(GlobalContextService).globalContext.docId.$ - ); - const docRecordList = useService(DocsService).list; - const doc = useLiveData(docId ? docRecordList.doc$(docId) : undefined); - const mode = useLiveData(doc?.mode$); + // to make sure ai island is hidden first and animate in + const [hide, setHide] = useState(true); - const globalState = useService(GlobalStateService).globalState; - const activeTabName = useLiveData( - LiveData.from( - globalState.watch(RIGHT_SIDEBAR_TABS_ACTIVE_KEY), - 'journal' - ) - ); - const setActiveTabName = useCallback( - (name: string) => { - globalState.set(RIGHT_SIDEBAR_TABS_ACTIVE_KEY, name); - }, - [globalState] - ); const rightSidebar = useService(RightSidebarService).rightSidebar; + const activeTabName = useLiveData(rightSidebar.activeTabName$); const rightSidebarOpen = useLiveData(rightSidebar.isOpen$); + const aiChatHasEverOpened = useLiveData(rightSidebar.aiChatHasEverOpened$); + + useEffect(() => { + setHide(rightSidebarOpen && activeTabName === 'chat'); + }, [activeTabName, rightSidebarOpen]); return ( - { - if (rightSidebarOpen) return; - rightSidebar.isOpen$; - rightSidebar.open(); - if (activeTabName !== 'chat') { - setActiveTabName('chat'); - } - }} - inEdgelessPage={!!docId && mode === 'edgeless'} +
- +
+ +
); }; diff --git a/packages/frontend/core/src/components/pure/ai-island/style.ts b/packages/frontend/core/src/components/pure/ai-island/style.ts deleted file mode 100644 index d35f1059e8..0000000000 --- a/packages/frontend/core/src/components/pure/ai-island/style.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { displayFlex, positionAbsolute, styled } from '@affine/component'; - -export const StyledIsland = styled('div')<{ - spread: boolean; - inEdgelessPage?: boolean; -}>(({ spread, inEdgelessPage }) => { - return { - width: '44px', - position: 'relative', - boxShadow: spread - ? 'var(--affine-menu-shadow)' - : inEdgelessPage - ? 'var(--affine-menu-shadow)' - : 'unset', - padding: '0 4px 44px', - borderRadius: '50%', - background: spread - ? 'var(--affine-background-overlay-panel-color)' - : 'var(--affine-background-primary-color)', - ':hover': { - background: spread ? undefined : 'var(--affine-white)', - boxShadow: spread ? undefined : 'var(--affine-menu-shadow)', - }, - }; -}); - -export const StyledTriggerWrapper = styled('div')<{ - spread?: boolean; -}>(({ spread }) => { - return { - width: '36px', - height: '36px', - cursor: 'pointer', - color: 'var(--affine-icon-color)', - borderRadius: '5px', - fontSize: '24px', - ...displayFlex('center', 'center'), - ...positionAbsolute({ left: '4px', bottom: '4px' }), - ':hover': { - backgroundColor: spread ? 'var(--affine-hover-color)' : undefined, - }, - }; -}); diff --git a/packages/frontend/core/src/components/pure/ai-island/styles.css.ts b/packages/frontend/core/src/components/pure/ai-island/styles.css.ts new file mode 100644 index 0000000000..54fd76a2e6 --- /dev/null +++ b/packages/frontend/core/src/components/pure/ai-island/styles.css.ts @@ -0,0 +1,107 @@ +import { cssVar } from '@toeverything/theme'; +import { createVar, keyframes, style } from '@vanilla-extract/css'; + +export const aiIslandWrapper = style({ + width: 44, + height: 44, + position: 'relative', + transform: 'translateY(0)', + transition: 'transform 0.2s ease', + + selectors: { + '&[data-hide="true"]': { + transform: 'translateY(120px)', + transitionDelay: '0.2s', + }, + }, +}); +export const aiIslandBtn = style({ + width: 'inherit', + height: 'inherit', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + color: cssVar('iconColor'), + border: `0.5px solid ${cssVar('borderColor')}`, + boxShadow: '0px 2px 2px rgba(0,0,0,0.05)', + background: cssVar('backgroundOverlayPanelColor'), + position: 'relative', + + selectors: { + [`${aiIslandWrapper}[data-animation="true"] &`]: { + borderColor: 'transparent', + }, + '&:hover::after': { + content: '""', + position: 'absolute', + inset: 0, + borderRadius: '50%', + background: cssVar('hoverColor'), + }, + }, +}); + +// -------- animation -------- +export const borderAngle1 = createVar('border-angle-1'); +export const borderAngle2 = createVar('border-angle-2'); +export const borderAngle3 = createVar('border-angle-3'); +const brightBlue = createVar('bright-blue'); +const brightGreen = createVar('bright-green'); +const brightRed = createVar('bright-red'); +const borderWidth = createVar('border-width'); + +const rotateBg1 = keyframes({ + to: { [borderAngle1.slice(4, -1)]: '360deg' }, +}); +const rotateBg2 = keyframes({ + to: { [borderAngle2.slice(4, -1)]: '450deg' }, +}); +const rotateBg3 = keyframes({ + to: { [borderAngle3.slice(4, -1)]: '540deg' }, +}); + +export const aiIslandAnimationBg = style({ + width: 'inherit', + height: 'inherit', + top: 0, + left: 0, + position: 'absolute', + borderRadius: '50%', + + vars: { + [borderAngle1]: '0deg', + [borderAngle2]: '90deg', + [borderAngle3]: '180deg', + [brightBlue]: 'rgb(0, 100, 255)', + [brightGreen]: '#1E96EB', + [brightRed]: 'rgb(0, 200, 255)', + [borderWidth]: '1.5px', + }, + backgroundColor: 'transparent', + backgroundImage: `conic-gradient(from ${borderAngle1} at 50% 50%, + transparent, + ${brightBlue} 10%, + transparent 30%, + transparent), + conic-gradient(from ${borderAngle2} at 50% 50%, + transparent, + ${brightGreen} 10%, + transparent 60%, + transparent), + conic-gradient(from ${borderAngle3} at 50% 50%, + transparent, + ${brightRed} 10%, + transparent 50%, + transparent)`, + + selectors: { + [`${aiIslandWrapper}[data-animation="true"] &`]: { + width: `calc(100% + 2 * ${borderWidth})`, + height: `calc(100% + 2 * ${borderWidth})`, + top: `calc(-1 * ${borderWidth})`, + left: `calc(-1 * ${borderWidth})`, + animation: `${rotateBg1} 3s linear infinite, ${rotateBg2} 8s linear infinite, ${rotateBg3} 13s linear infinite`, + }, + }, +}); diff --git a/packages/frontend/core/src/components/workspace/index.css.ts b/packages/frontend/core/src/components/workspace/index.css.ts index 05c2e9ce3a..b215a9bca1 100644 --- a/packages/frontend/core/src/components/workspace/index.css.ts +++ b/packages/frontend/core/src/components/workspace/index.css.ts @@ -87,27 +87,16 @@ export const mainContainerStyle = style({ }); export const toolStyle = style({ position: 'absolute', - right: '30px', - bottom: '30px', + right: 16, + bottom: 16, zIndex: 1, display: 'flex', flexDirection: 'column', + alignItems: 'center', gap: '12px', selectors: { '&.trash': { bottom: '78px', }, }, - '@media': { - 'screen and (max-width: 960px)': { - right: 'calc((100vw - 640px) * 3 / 19 + 14px)', - }, - 'screen and (max-width: 640px)': { - right: '5px', - bottom: '5px', - }, - print: { - display: 'none', - }, - }, }); diff --git a/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar.ts b/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar.ts index e40802876f..e43c19f188 100644 --- a/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar.ts +++ b/packages/frontend/core/src/modules/right-sidebar/entities/right-sidebar.ts @@ -1,13 +1,25 @@ import type { GlobalState } from '@toeverything/infra'; import { Entity, LiveData } from '@toeverything/infra'; +import type { SidebarTabName } from '../../multi-tab-sidebar'; import { RightSidebarView } from './right-sidebar-view'; const RIGHT_SIDEBAR_KEY = 'app:settings:rightsidebar'; +const RIGHT_SIDEBAR_TABS_ACTIVE_KEY = 'app:settings:rightsidebar:tabs:active'; +const RIGHT_SIDEBAR_AI_HAS_EVER_OPENED_KEY = + 'app:settings:rightsidebar:ai:has-ever-opened'; export class RightSidebar extends Entity { + _disposables: Array<() => void> = []; constructor(private readonly globalState: GlobalState) { super(); + + const sub = this.activeTabName$.subscribe(name => { + if (name === 'chat') { + this.globalState.set(RIGHT_SIDEBAR_AI_HAS_EVER_OPENED_KEY, true); + } + }); + this._disposables.push(() => sub.unsubscribe()); } readonly isOpen$ = LiveData.from( @@ -19,6 +31,25 @@ export class RightSidebar extends Entity { stack => stack[0] as RightSidebarView | undefined ); readonly hasViews$ = this.views$.map(stack => stack.length > 0); + readonly activeTabName$ = LiveData.from( + this.globalState.watch(RIGHT_SIDEBAR_TABS_ACTIVE_KEY), + null + ); + + /** To determine if AI chat has ever been opened, used to show the animation for the first time */ + readonly aiChatHasEverOpened$ = LiveData.from( + this.globalState.watch(RIGHT_SIDEBAR_AI_HAS_EVER_OPENED_KEY), + false + ); + + override dispose() { + super.dispose(); + this._disposables.forEach(dispose => dispose()); + } + + setActiveTabName(name: SidebarTabName) { + this.globalState.set(RIGHT_SIDEBAR_TABS_ACTIVE_KEY, name); + } open() { this._set(true); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index f6eda5a4c3..67b2df07de 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -2,10 +2,7 @@ import { Scrollable } from '@affine/component'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import { AIProvider } from '@affine/core/blocksuite/presets/ai'; import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding'; -import { - AIIsland, - RIGHT_SIDEBAR_TABS_ACTIVE_KEY, -} from '@affine/core/components/pure/ai-island'; +import { AIIsland } from '@affine/core/components/pure/ai-island'; import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import { RecentPagesService } from '@affine/core/modules/cmdk'; import type { PageRootService } from '@blocksuite/blocks'; @@ -27,8 +24,6 @@ import { FrameworkScope, globalBlockSuiteSchema, GlobalContextService, - GlobalStateService, - LiveData, useLiveData, useService, WorkspaceService, @@ -49,7 +44,6 @@ import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-r import { useActiveBlocksuiteEditor } from '../../../hooks/use-block-suite-editor'; import { usePageDocumentTitle } from '../../../hooks/use-global-state'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; -import type { SidebarTabName } from '../../../modules/multi-tab-sidebar'; import { MultiTabSidebarBody, MultiTabSidebarHeaderSwitcher, @@ -70,19 +64,8 @@ import * as styles from './detail-page.css'; import { DetailPageHeader } from './detail-page-header'; const DetailPageImpl = memo(function DetailPageImpl() { - const globalState = useService(GlobalStateService).globalState; - const activeTabName = useLiveData( - LiveData.from( - globalState.watch(RIGHT_SIDEBAR_TABS_ACTIVE_KEY), - 'journal' - ) - ); - const setActiveTabName = useCallback( - (name: string) => { - globalState.set(RIGHT_SIDEBAR_TABS_ACTIVE_KEY, name); - }, - [globalState] - ); + const rightSidebar = useService(RightSidebarService).rightSidebar; + const activeTabName = useLiveData(rightSidebar.activeTabName$); const doc = useService(DocService).doc; const docRecordList = useService(DocsService).list; @@ -90,7 +73,6 @@ const DetailPageImpl = memo(function DetailPageImpl() { const [editor, setEditor] = useState(null); const workspace = useService(WorkspaceService).workspace; const globalContext = useService(GlobalContextService).globalContext; - const rightSidebar = useService(RightSidebarService).rightSidebar; const docCollection = workspace.docCollection; const mode = useLiveData(doc.mode$); const { appSettings } = useAppSettingHelper(); @@ -99,6 +81,12 @@ const DetailPageImpl = memo(function DetailPageImpl() { // TODO(@eyhn): remove jotai here const [_, setActiveBlockSuiteEditor] = useActiveBlocksuiteEditor(); + const setActiveTabName = useCallback( + (...args: Parameters) => + rightSidebar.setActiveTabName(...args), + [rightSidebar] + ); + useEffect(() => { if (isActiveView) { setActiveBlockSuiteEditor(editor);