mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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. 
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user