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)
This commit is contained in:
CatsJuice
2024-06-24 14:37:38 +00:00
parent 0918730274
commit b38c46649f
7 changed files with 204 additions and 126 deletions

View File

@@ -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,
};

View File

@@ -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: '<angle>',
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<SidebarTabName>(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 (
<ToolContainer>
<StyledIsland
spread={true}
data-testid="ai-island"
onClick={() => {
if (rightSidebarOpen) return;
rightSidebar.isOpen$;
rightSidebar.open();
if (activeTabName !== 'chat') {
setActiveTabName('chat');
}
}}
inEdgelessPage={!!docId && mode === 'edgeless'}
<div
className={aiIslandWrapper}
data-hide={hide}
data-animation={!aiChatHasEverOpened}
>
<StyledTriggerWrapper data-testid="faq-icon">
<div className={aiIslandAnimationBg} />
<button
className={aiIslandBtn}
data-testid="ai-island"
onClick={() => {
if (hide) return;
rightSidebar.open();
rightSidebar.setActiveTabName('chat');
}}
>
<AIIcon />
</StyledTriggerWrapper>
</StyledIsland>
</button>
</div>
</ToolContainer>
);
};

View File

@@ -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,
},
};
});

View File

@@ -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`,
},
},
});

View File

@@ -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',
},
},
});

View File

@@ -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<SidebarTabName>(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<boolean>(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);

View File

@@ -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<SidebarTabName>(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<AffineEditorContainer | null>(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<typeof rightSidebar.setActiveTabName>) =>
rightSidebar.setActiveTabName(...args),
[rightSidebar]
);
useEffect(() => {
if (isActiveView) {
setActiveBlockSuiteEditor(editor);