feat(core): show floating sidebar when hovering sidebar swtich

This commit is contained in:
Jimmfly
2024-09-26 02:29:22 +08:00
parent b9e5a72cc1
commit 1d8b1aa978
13 changed files with 218 additions and 47 deletions
@@ -6,6 +6,7 @@ export const resizeHandleVerticalPadding = createVar(
'resize-handle-vertical-padding'
);
export const animationTimeout = createVar();
export const root = style({
vars: {
[panelWidthVar]: '256px',
@@ -30,8 +31,11 @@ export const root = style({
'&[data-open="false"][data-handle-position="left"]': {
marginRight: `calc(${panelWidthVar} * -1)`,
},
'&[data-enable-animation="true"]': {
transition: `margin-left ${animationTimeout} .05s, margin-right ${animationTimeout} .05s, width ${animationTimeout} .05s`,
'&[data-enable-animation="true"][data-is-floating="false"]': {
transition: `margin-left ${animationTimeout} .05s, margin-right ${animationTimeout} .05s, width ${animationTimeout} .05s,background ${animationTimeout} .05s,scale ${animationTimeout} .05s`,
},
'&[data-enable-animation="true"][data-is-floating="true"]': {
transition: 'margin-left 0.5s cubic-bezier(0.22,1,0.36,1)',
},
'&[data-transition-state="exited"]': {
// avoid focus on hidden panel
@@ -185,7 +185,7 @@ const DesktopLayout = ({ children }: PropsWithChildren) => {
<AppTabsHeader
left={
<>
<SidebarSwitch show />
<SidebarSwitch show enableOpenHoverSidebar />
<NavigationButtons />
</>
}
@@ -19,11 +19,12 @@ export const Header = ({ left, center, right }: HeaderPros) => {
const appSidebarService = useService(AppSidebarService).sidebar;
const open = useLiveData(appSidebarService.open$);
const appSidebarFloating = useLiveData(appSidebarService.responsiveFloating$);
const hoverFloating = useLiveData(appSidebarService.hoverFloating$);
return (
<div
className={clsx(style.header)}
data-open={open}
data-sidebar-floating={appSidebarFloating}
data-sidebar-floating={appSidebarFloating || hoverFloating}
data-testid="header"
>
<div className={clsx(style.headerSideContainer)}>
@@ -1,5 +1,7 @@
import { cssVar, lightCssVariables } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
import { createVar, globalStyle, keyframes, style } from '@vanilla-extract/css';
export const panelWidthVar = createVar('panel-width');
export const appStyle = style({
width: '100%',
@@ -40,7 +42,19 @@ globalStyle(`html[data-theme="dark"] ${appStyle}`, {
},
});
const anime = keyframes({
'0%': {
marginLeft: '8px',
},
'100%': {
marginLeft: '0',
},
});
export const mainContainerStyle = style({
vars: {
[panelWidthVar]: '256px',
},
position: 'relative',
zIndex: 0,
width: '100%',
@@ -48,7 +62,7 @@ export const mainContainerStyle = style({
flex: 1,
overflow: 'clip',
maxWidth: '100%',
transition: 'margin-left 0.2s ease',
selectors: {
'&[data-client-border="true"]': {
borderRadius: 6,
@@ -62,9 +76,14 @@ export const mainContainerStyle = style({
},
},
},
'&[data-client-border="true"][data-side-bar-open="true"]': {
marginLeft: 0,
'&[data-side-bar-open="true"][data-show-pin-sidebar-animation="true"]': {
marginLeft: panelWidthVar,
transition: 'margin-left 0.5s ease-in-out',
},
'&[data-client-border="true"][data-side-bar-open="true"][data-side-bar-floating="false"]':
{
animation: `${anime} 0.5s ease-in-out forwards`,
},
'&[data-client-border="true"][data-is-desktop="true"]': {
marginTop: 0,
},
@@ -6,11 +6,17 @@ import {
useLiveData,
useService,
} from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { clsx } from 'clsx';
import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react';
import { forwardRef } from 'react';
import { forwardRef, useMemo } from 'react';
import { appStyle, mainContainerStyle, toolStyle } from './index.css';
import {
appStyle,
mainContainerStyle,
panelWidthVar,
toolStyle,
} from './index.css';
export type WorkspaceRootProps = PropsWithChildren<{
className?: string;
@@ -48,19 +54,41 @@ export interface MainContainerProps extends HTMLAttributes<HTMLDivElement> {}
export const MainContainer = forwardRef<
HTMLDivElement,
PropsWithChildren<MainContainerProps>
>(function MainContainer({ className, children, ...props }, ref): ReactElement {
>(function MainContainer(
{ className, children, style, ...props },
ref
): ReactElement {
const appSidebarService = useService(AppSidebarService).sidebar;
const appSideBarOpen = useLiveData(appSidebarService.open$);
const appSidebarHoverFloating = useLiveData(appSidebarService.hoverFloating$);
const appSideBarWidth = useLiveData(appSidebarService.width$);
const showAppSideBarPinAnimation = useLiveData(
appSidebarService.showFloatToPinAnimation$
);
const { appSettings } = useAppSettingHelper();
const combinedStyle = useMemo(() => {
const dynamicStyle = assignInlineVars({
[panelWidthVar]: `${appSideBarWidth}px`,
});
return {
...style,
...dynamicStyle,
};
}, [appSideBarWidth, style]);
return (
<div
{...props}
style={combinedStyle}
className={clsx(mainContainerStyle, className)}
data-is-desktop={BUILD_CONFIG.isElectron}
data-transparent={false}
data-client-border={appSettings.clientBorder}
data-side-bar-open={appSideBarOpen}
data-side-bar-floating={appSidebarHoverFloating}
data-show-pin-sidebar-animation={showAppSideBarPinAnimation}
data-testid="main-container"
ref={ref}
>
@@ -16,7 +16,6 @@ import { useRegisterCopyLinkCommands } from '@affine/core/components/hooks/affin
import { useDocCollectionPageTitle } from '@affine/core/components/hooks/use-block-suite-workspace-page-title';
import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal';
import { HeaderDivider } from '@affine/core/components/pure/header';
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import { EditorService } from '@affine/core/modules/editor';
import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench';
import type { Doc } from '@blocksuite/affine/store';
@@ -34,16 +33,8 @@ const Header = forwardRef<
style?: React.CSSProperties;
}
>(({ children, style, className }, ref) => {
const appSidebarService = useService(AppSidebarService).sidebar;
const appSidebarFloating = useLiveData(appSidebarService.responsiveFloating$);
return (
<div
data-testid="header"
style={style}
className={className}
ref={ref}
data-sidebar-floating={appSidebarFloating}
>
<div data-testid="header" style={style} className={className} ref={ref}>
{children}
</div>
);
@@ -32,6 +32,8 @@ export class AppSidebar extends Entity {
hoverFloating$ = new LiveData<boolean>(false);
resizing$ = new LiveData<boolean>(false);
showFloatToPinAnimation$ = new LiveData<boolean>(false);
getCachedAppSidebarOpenState = () => {
return this.appSidebarState.get<boolean>(APP_SIDEBAR_STATE.OPEN);
};
@@ -58,6 +60,9 @@ export class AppSidebar extends Entity {
};
setHoverFloating = (hoverFloating: boolean) => {
if (hoverFloating) {
this.showFloatToPinAnimation$.next(false);
}
this.hoverFloating$.next(hoverFloating);
};
@@ -68,4 +73,8 @@ export class AppSidebar extends Entity {
setWidth = (width: number) => {
this.appSidebarState.set(APP_SIDEBAR_STATE.WIDTH, width);
};
setShowFloatToPinAnimation = (show: boolean) => {
this.showFloatToPinAnimation$.next(show);
};
}
@@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const floatingMaxWidth = 768;
export const navWrapperStyle = style({
@@ -10,16 +11,48 @@ export const navWrapperStyle = style({
},
selectors: {
'&[data-has-border=true]': {
borderRight: `0.5px solid ${cssVar('borderColor')}`,
borderRight: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
},
'&[data-is-floating="true"]': {
backgroundColor: cssVar('backgroundPrimaryColor'),
backgroundColor: cssVarV2('layer/background/primary'),
},
'&[data-client-border="true"]': {
paddingBottom: 8,
},
},
});
export const hoverNavWrapperStyle = style({
selectors: {
'&[data-is-floating="true"]': {
backgroundColor: cssVarV2('layer/background/primary'),
height: 'calc(100% - 60px)',
marginTop: '52px',
marginLeft: '4px',
boxShadow: cssVar('--affine-popover-shadow'),
borderRadius: '6px',
},
'&[data-is-floating="true"][data-show-pin-animation="true"]': {
marginLeft: '0',
},
'&[data-is-floating="true"][data-is-electron="true"]': {
height: '100%',
marginTop: '-4px',
},
'&[data-is-floating="true"][data-client-border="true"]': {
backgroundColor: cssVarV2('layer/background/overlayPanel'),
},
'&[data-is-floating="true"][data-client-border="true"]::before': {
content: '""',
position: 'absolute',
inset: 0,
opacity: `var(--affine-noise-opacity, 0)`,
backgroundRepeat: 'repeat',
backgroundSize: '50px',
// TODO(@Peng): figure out how to use vanilla-extract webpack plugin to inject img url
backgroundImage: `var(--noise-background)`,
},
},
});
export const navHeaderButton = style({
width: '32px',
height: '32px',
@@ -62,7 +95,7 @@ export const sidebarFloatMaskStyle = style({
left: 0,
right: '100%',
bottom: 0,
background: cssVar('backgroundModalColor'),
background: cssVarV2('layer/background/modal'),
selectors: {
'&[data-open="true"][data-is-floating="true"]': {
opacity: 1,
@@ -9,14 +9,16 @@ import {
useServiceOptional,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { debounce } from 'lodash-es';
import type { PropsWithChildren, ReactElement } from 'react';
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
import { AppSidebarService } from '../services/app-sidebar';
import * as styles from './fallback.css';
import {
floatingMaxWidth,
hoverNavWrapperStyle,
navBodyStyle,
navHeaderStyle,
navStyle,
@@ -32,6 +34,7 @@ export type History = {
const MAX_WIDTH = 480;
const MIN_WIDTH = 248;
const isMacosDesktop = BUILD_CONFIG.isElectron && environment.isMacOs;
export function AppSidebar({ children }: PropsWithChildren) {
const { appSettings } = useAppSettingHelper();
@@ -42,8 +45,21 @@ export function AppSidebar({ children }: PropsWithChildren) {
const open = useLiveData(appSidebarService.open$);
const width = useLiveData(appSidebarService.width$);
const floating = useLiveData(appSidebarService.responsiveFloating$);
const responsiveFloating = useLiveData(appSidebarService.responsiveFloating$);
const hoverFloating = useLiveData(appSidebarService.hoverFloating$);
const resizing = useLiveData(appSidebarService.resizing$);
const showFloatToPinAnimation = useLiveData(
appSidebarService.showFloatToPinAnimation$
);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const clearExistingTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
useEffect(() => {
// do not float app sidebar on desktop
@@ -78,7 +94,6 @@ export function AppSidebar({ children }: PropsWithChildren) {
}, [appSidebarService, open, width]);
const hasRightBorder = !BUILD_CONFIG.isElectron && !clientBorder;
const isMacosDesktop = BUILD_CONFIG.isElectron && environment.isMacOs;
const handleOpenChange = useCallback(
(open: boolean) => {
@@ -105,10 +120,34 @@ export function AppSidebar({ children }: PropsWithChildren) {
appSidebarService.setOpen(false);
}, [appSidebarService]);
const onMouseEnter = useCallback(() => {
if (!timeoutRef.current) {
return;
}
clearExistingTimeout();
}, [clearExistingTimeout]);
const onMouseLeave = useCallback(() => {
if (!hoverFloating) {
clearExistingTimeout();
return;
}
clearExistingTimeout();
timeoutRef.current = setTimeout(() => {
appSidebarService.setOpen(false);
}, 1500);
}, [hoverFloating, clearExistingTimeout, appSidebarService]);
useEffect(() => {
return () => {
clearExistingTimeout();
};
}, [clearExistingTimeout]);
return (
<>
<ResizePanel
floating={floating}
floating={responsiveFloating || hoverFloating}
open={open}
resizing={resizing}
maxWidth={MAX_WIDTH}
@@ -118,30 +157,38 @@ export function AppSidebar({ children }: PropsWithChildren) {
onOpen={handleOpenChange}
onResizing={handleResizing}
onWidthChange={handleWidthChange}
className={navWrapperStyle}
className={clsx(navWrapperStyle, {
[hoverNavWrapperStyle]: hoverFloating,
})}
resizeHandleOffset={0}
resizeHandleVerticalPadding={clientBorder ? 16 : 0}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
data-transparent
data-open={open}
data-has-border={hasRightBorder}
data-testid="app-sidebar-wrapper"
data-is-macos-electron={isMacosDesktop}
data-client-border={clientBorder}
data-is-electron={BUILD_CONFIG.isElectron}
data-show-pin-animation={showFloatToPinAnimation}
>
<nav className={navStyle} data-testid="app-sidebar">
{!BUILD_CONFIG.isElectron && <SidebarHeader />}
{!BUILD_CONFIG.isElectron && !hoverFloating && <SidebarHeader />}
<div className={navBodyStyle} data-testid="sliderBar-inner">
{children}
</div>
</nav>
</ResizePanel>
<div
data-testid="app-sidebar-float-mask"
data-open={open}
data-is-floating={floating}
className={sidebarFloatMaskStyle}
onClick={handleClose}
/>
{!hoverFloating && (
<div
data-testid="app-sidebar-float-mask"
data-open={open}
data-is-floating={responsiveFloating}
className={sidebarFloatMaskStyle}
onClick={handleClose}
/>
)}
</>
);
}
@@ -2,46 +2,71 @@ import { IconButton } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { SidebarIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { AppSidebarService } from '../../services/app-sidebar';
import * as styles from './sidebar-switch.css';
export const SidebarSwitch = ({
show,
enableOpenHoverSidebar,
className,
}: {
show: boolean;
enableOpenHoverSidebar?: boolean;
className?: string;
}) => {
const appSidebarService = useService(AppSidebarService).sidebar;
const open = useLiveData(appSidebarService.open$);
const hoverFloating = useLiveData(appSidebarService.hoverFloating$);
const switchRef = useRef<HTMLDivElement>(null);
const handleMouseEnter = useCallback(() => {
if (!enableOpenHoverSidebar || open) {
return;
}
appSidebarService.setHoverFloating(true);
appSidebarService.setOpen(true);
}, [appSidebarService, enableOpenHoverSidebar, open]);
const handleClickSwitch = useCallback(() => {
if (open && hoverFloating) {
appSidebarService.setShowFloatToPinAnimation(true);
const timeout = setTimeout(() => {
appSidebarService.setShowFloatToPinAnimation(false);
appSidebarService.setHoverFloating(false);
}, 500);
return () => {
clearTimeout(timeout);
};
}
return appSidebarService.toggleSidebar();
}, [appSidebarService, hoverFloating, open]);
const t = useI18n();
const tooltipContent = open
? t['com.affine.sidebarSwitch.collapse']()
: t['com.affine.sidebarSwitch.expand']();
const toggleSidebar = useCallback(() => {
appSidebarService.toggleSidebar();
}, [appSidebarService]);
return (
<div
ref={switchRef}
data-show={show}
className={styles.sidebarSwitchClip}
data-testid={`app-sidebar-arrow-button-${open ? 'collapse' : 'expand'}`}
data-enable-open-hover-sidebar={enableOpenHoverSidebar}
onMouseEnter={handleMouseEnter}
>
<IconButton
tooltip={tooltipContent}
tooltipShortcut={['$mod', '/']}
tooltipOptions={{ side: open ? 'bottom' : 'right' }}
tooltipOptions={{ side: open && !hoverFloating ? 'bottom' : 'right' }}
className={className}
size="24"
style={{
zIndex: 1,
}}
onClick={toggleSidebar}
onClick={handleClickSwitch}
>
<SidebarIcon />
</IconButton>
@@ -297,6 +297,7 @@ export const AppTabsHeader = ({
const sidebarWidth = useLiveData(appSidebarService.width$);
const sidebarOpen = useLiveData(appSidebarService.open$);
const sidebarResizing = useLiveData(appSidebarService.resizing$);
const hoverFloating = useLiveData(appSidebarService.hoverFloating$);
const isMacosDesktop = BUILD_CONFIG.isElectron && environment.isMacOs;
const isWindowsDesktop = BUILD_CONFIG.isElectron && environment.isWindows;
@@ -414,9 +415,12 @@ export const AppTabsHeader = ({
style={{
transition: sidebarResizing ? 'none' : undefined,
paddingLeft: 12 + trafficLightOffset,
width: sidebarOpen ? sidebarWidth : 120 + trafficLightOffset,
width:
sidebarOpen && !hoverFloating
? sidebarWidth
: 120 + trafficLightOffset,
// minus 16 to account for the padding on the right side of the header (for box shadow)
marginRight: sidebarOpen ? -16 : 0,
marginRight: sidebarOpen && !hoverFloating ? -16 : 0,
}}
className={styles.headerLeft}
>
@@ -44,6 +44,9 @@ export const RouteContainer = () => {
const viewPosition = useViewPosition();
const appSidebarService = useService(AppSidebarService).sidebar;
const leftSidebarOpen = useLiveData(appSidebarService.open$);
const leftSidebarHoverFloating = useLiveData(
appSidebarService.hoverFloating$
);
const workbench = useService(WorkbenchService).workbench;
const view = useService(ViewService).view;
const sidebarOpen = useLiveData(workbench.sidebarOpen$);
@@ -56,7 +59,8 @@ export const RouteContainer = () => {
<div className={styles.header}>
{!BUILD_CONFIG.isElectron && viewPosition.isFirst && (
<SidebarSwitch
show={!leftSidebarOpen}
show={leftSidebarHoverFloating || !leftSidebarOpen}
enableOpenHoverSidebar
className={styles.leftSidebarButton}
/>
)}
+6
View File
@@ -10,6 +10,9 @@ test('Collapse Sidebar', async ({ page }) => {
.locator('[data-testid=app-sidebar-arrow-button-collapse][data-show=true]')
.click();
const sliderBarArea = page.getByTestId('app-sidebar');
await sliderBarArea.hover();
await page.mouse.move(300, 300);
await page.waitForTimeout(5000);
await expect(sliderBarArea).not.toBeInViewport();
});
@@ -20,6 +23,9 @@ test('Expand Sidebar', async ({ page }) => {
.locator('[data-testid=app-sidebar-arrow-button-collapse][data-show=true]')
.click();
const sliderBarArea = page.getByTestId('sliderBar-inner');
await sliderBarArea.hover();
await page.mouse.move(300, 300);
await page.waitForTimeout(5000);
await expect(sliderBarArea).not.toBeInViewport();
await page