From 1d8b1aa978e179666c67d999aab705c2684b8aae Mon Sep 17 00:00:00 2001 From: Jimmfly Date: Thu, 26 Sep 2024 02:29:22 +0800 Subject: [PATCH] feat(core): show floating sidebar when hovering sidebar swtich --- .../resize-panel/resize-panel.css.ts | 8 +- .../components/layouts/workspace-layout.tsx | 2 +- .../core/src/components/pure/header/index.tsx | 3 +- .../src/components/workspace/index.css.ts | 27 ++++++- .../core/src/components/workspace/index.tsx | 34 ++++++++- .../detail-page/detail-page-header.tsx | 11 +-- .../app-sidebar/entities/app-sidebar.ts | 9 +++ .../modules/app-sidebar/views/index.css.ts | 39 +++++++++- .../src/modules/app-sidebar/views/index.tsx | 73 +++++++++++++++---- .../views/sidebar-header/sidebar-switch.tsx | 39 ++++++++-- .../app-tabs-header/views/app-tabs-header.tsx | 8 +- .../workbench/view/route-container.tsx | 6 +- tests/affine-local/e2e/layout.spec.ts | 6 ++ 13 files changed, 218 insertions(+), 47 deletions(-) diff --git a/packages/frontend/component/src/components/resize-panel/resize-panel.css.ts b/packages/frontend/component/src/components/resize-panel/resize-panel.css.ts index 3bb16856c1..d17b0ac1c2 100644 --- a/packages/frontend/component/src/components/resize-panel/resize-panel.css.ts +++ b/packages/frontend/component/src/components/resize-panel/resize-panel.css.ts @@ -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 diff --git a/packages/frontend/core/src/components/layouts/workspace-layout.tsx b/packages/frontend/core/src/components/layouts/workspace-layout.tsx index f85d06cd99..e582e94561 100644 --- a/packages/frontend/core/src/components/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/components/layouts/workspace-layout.tsx @@ -185,7 +185,7 @@ const DesktopLayout = ({ children }: PropsWithChildren) => { - + } diff --git a/packages/frontend/core/src/components/pure/header/index.tsx b/packages/frontend/core/src/components/pure/header/index.tsx index 831961a867..3a59c56834 100644 --- a/packages/frontend/core/src/components/pure/header/index.tsx +++ b/packages/frontend/core/src/components/pure/header/index.tsx @@ -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 (
diff --git a/packages/frontend/core/src/components/workspace/index.css.ts b/packages/frontend/core/src/components/workspace/index.css.ts index c46fb50364..d6cc1bbc0d 100644 --- a/packages/frontend/core/src/components/workspace/index.css.ts +++ b/packages/frontend/core/src/components/workspace/index.css.ts @@ -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, }, diff --git a/packages/frontend/core/src/components/workspace/index.tsx b/packages/frontend/core/src/components/workspace/index.tsx index 68bc3bb6ee..58fee20021 100644 --- a/packages/frontend/core/src/components/workspace/index.tsx +++ b/packages/frontend/core/src/components/workspace/index.tsx @@ -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 {} export const MainContainer = forwardRef< HTMLDivElement, PropsWithChildren ->(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 (
diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx index d57ffc4f0b..dff76da2a2 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx @@ -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 ( -
+
{children}
); diff --git a/packages/frontend/core/src/modules/app-sidebar/entities/app-sidebar.ts b/packages/frontend/core/src/modules/app-sidebar/entities/app-sidebar.ts index 19ba89840b..8406bab6d3 100644 --- a/packages/frontend/core/src/modules/app-sidebar/entities/app-sidebar.ts +++ b/packages/frontend/core/src/modules/app-sidebar/entities/app-sidebar.ts @@ -32,6 +32,8 @@ export class AppSidebar extends Entity { hoverFloating$ = new LiveData(false); resizing$ = new LiveData(false); + showFloatToPinAnimation$ = new LiveData(false); + getCachedAppSidebarOpenState = () => { return this.appSidebarState.get(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); + }; } diff --git a/packages/frontend/core/src/modules/app-sidebar/views/index.css.ts b/packages/frontend/core/src/modules/app-sidebar/views/index.css.ts index 86808f3d1b..5e5f21df37 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/index.css.ts +++ b/packages/frontend/core/src/modules/app-sidebar/views/index.css.ts @@ -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, diff --git a/packages/frontend/core/src/modules/app-sidebar/views/index.tsx b/packages/frontend/core/src/modules/app-sidebar/views/index.tsx index efd9d91918..d8743f6d1a 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/index.tsx +++ b/packages/frontend/core/src/modules/app-sidebar/views/index.tsx @@ -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(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 ( <> -
+ {!hoverFloating && ( +
+ )} ); } diff --git a/packages/frontend/core/src/modules/app-sidebar/views/sidebar-header/sidebar-switch.tsx b/packages/frontend/core/src/modules/app-sidebar/views/sidebar-header/sidebar-switch.tsx index 91c7d85da6..6a1139f8e2 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/sidebar-header/sidebar-switch.tsx +++ b/packages/frontend/core/src/modules/app-sidebar/views/sidebar-header/sidebar-switch.tsx @@ -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(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 (
diff --git a/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx b/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx index da8ed70639..e3e1e24696 100644 --- a/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx +++ b/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx @@ -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} > diff --git a/packages/frontend/core/src/modules/workbench/view/route-container.tsx b/packages/frontend/core/src/modules/workbench/view/route-container.tsx index 89f501d61b..bc8fb88826 100644 --- a/packages/frontend/core/src/modules/workbench/view/route-container.tsx +++ b/packages/frontend/core/src/modules/workbench/view/route-container.tsx @@ -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 = () => {
{!BUILD_CONFIG.isElectron && viewPosition.isFirst && ( )} diff --git a/tests/affine-local/e2e/layout.spec.ts b/tests/affine-local/e2e/layout.spec.ts index 308c5c95f9..b5bb2aed34 100644 --- a/tests/affine-local/e2e/layout.spec.ts +++ b/tests/affine-local/e2e/layout.spec.ts @@ -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