From 5213431d513e9894f61cc4244b52beac99c0f369 Mon Sep 17 00:00:00 2001 From: JimmFly Date: Wed, 9 Oct 2024 03:48:17 +0000 Subject: [PATCH] feat(core): show floating sidebar when hovering sidebar swtich (#8393) web: https://github.com/user-attachments/assets/3cafe094-7938-4241-8d57-cfd5ccaadf25 client: https://github.com/user-attachments/assets/ca218a45-de92-4e0a-ad83-c0f47aee2962 --- .../resize-panel/resize-panel.css.ts | 36 +++++---- .../components/resize-panel/resize-panel.tsx | 23 +----- .../core/src/components/pure/header/index.tsx | 8 +- .../src/components/workspace/index.css.ts | 6 +- .../core/src/components/workspace/index.tsx | 6 +- .../detail-page/detail-page-header.tsx | 11 +-- .../app-sidebar/entities/app-sidebar.ts | 38 ++++----- .../modules/app-sidebar/views/index.css.ts | 36 ++++++++- .../src/modules/app-sidebar/views/index.tsx | 81 +++++++++++++------ .../views/sidebar-header/sidebar-switch.tsx | 24 ++++-- .../view/sidebar/sidebar-container.tsx | 2 +- .../workbench/view/sidebar/sidebar-header.tsx | 14 +--- tests/affine-desktop/e2e/tabs.spec.ts | 2 + tests/affine-local/e2e/layout.spec.ts | 6 ++ 14 files changed, 173 insertions(+), 120 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..d8bb76a88f 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', @@ -15,23 +16,28 @@ export const root = style({ width: panelWidthVar, minWidth: panelWidthVar, height: '100%', + zIndex: 4, + transform: 'translateX(0)', + maxWidth: '50%', selectors: { - '&[data-is-floating="true"]': { - position: 'absolute', - width: `calc(${panelWidthVar})`, - zIndex: 4, - }, - '&[data-open="true"]': { - maxWidth: '50%', - }, - '&[data-open="false"][data-handle-position="right"]': { - marginLeft: `calc(${panelWidthVar} * -1)`, - }, - '&[data-open="false"][data-handle-position="left"]': { - marginRight: `calc(${panelWidthVar} * -1)`, - }, + '&[data-open="false"][data-handle-position="right"],&[data-is-floating="true"][data-handle-position="right"]': + { + marginLeft: `calc(${panelWidthVar} * -1)`, + }, + '&[data-open="false"][data-handle-position="left"],&[data-is-floating="true"][data-handle-position="left"]': + { + marginRight: `calc(${panelWidthVar} * -1)`, + }, + '&[data-open="true"][data-handle-position="right"][data-is-floating="true"]': + { + transform: `translateX(${panelWidthVar})`, + }, + '&[data-open="true"][data-handle-position="left"][data-is-floating="true"]': + { + transform: `translateX(-${panelWidthVar})`, + }, '&[data-enable-animation="true"]': { - transition: `margin-left ${animationTimeout} .05s, margin-right ${animationTimeout} .05s, width ${animationTimeout} .05s`, + transition: `margin-left ${animationTimeout}, margin-right ${animationTimeout}, transform ${animationTimeout}, background ${animationTimeout}`, }, '&[data-transition-state="exited"]': { // avoid focus on hidden panel diff --git a/packages/frontend/component/src/components/resize-panel/resize-panel.tsx b/packages/frontend/component/src/components/resize-panel/resize-panel.tsx index 5b1f182070..2d9dea2ad2 100644 --- a/packages/frontend/component/src/components/resize-panel/resize-panel.tsx +++ b/packages/frontend/component/src/components/resize-panel/resize-panel.tsx @@ -1,13 +1,6 @@ import { assignInlineVars } from '@vanilla-extract/dynamic'; import clsx from 'clsx'; -import { - forwardRef, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from 'react'; +import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react'; import { useTransition } from 'react-transition-state'; import * as styles from './resize-panel.css'; @@ -129,17 +122,6 @@ const ResizeHandle = ({ ); }; -// delay initial animation to avoid flickering -function useEnableAnimation() { - const [enable, setEnable] = useState(false); - useEffect(() => { - window.setTimeout(() => { - setEnable(true); - }, 500); - }, []); - return enable; -} - const animationTimeout = 300; export const ResizePanel = forwardRef( @@ -152,7 +134,7 @@ export const ResizePanel = forwardRef( maxWidth, width, floating, - enableAnimation: _enableAnimation = true, + enableAnimation = true, open, unmountOnExit, onOpen, @@ -165,7 +147,6 @@ export const ResizePanel = forwardRef( }, ref ) { - const enableAnimation = useEnableAnimation() && _enableAnimation; const safeWidth = Math.min(maxWidth, Math.max(minWidth, width)); const [{ status }, toggle] = useTransition({ timeout: animationTimeout, diff --git a/packages/frontend/core/src/components/pure/header/index.tsx b/packages/frontend/core/src/components/pure/header/index.tsx index 831961a867..5560ce8002 100644 --- a/packages/frontend/core/src/components/pure/header/index.tsx +++ b/packages/frontend/core/src/components/pure/header/index.tsx @@ -18,14 +18,8 @@ interface HeaderPros { export const Header = ({ left, center, right }: HeaderPros) => { const appSidebarService = useService(AppSidebarService).sidebar; const open = useLiveData(appSidebarService.open$); - const appSidebarFloating = useLiveData(appSidebarService.responsiveFloating$); return ( -
+
{left}
diff --git a/packages/frontend/core/src/components/workspace/index.css.ts b/packages/frontend/core/src/components/workspace/index.css.ts index c46fb50364..daef387a2d 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, style } from '@vanilla-extract/css'; + +export const panelWidthVar = createVar('panel-width'); export const appStyle = style({ width: '100%', @@ -48,7 +50,7 @@ export const mainContainerStyle = style({ flex: 1, overflow: 'clip', maxWidth: '100%', - transition: 'margin-left 0.2s ease', + selectors: { '&[data-client-border="true"]': { borderRadius: 6, diff --git a/packages/frontend/core/src/components/workspace/index.tsx b/packages/frontend/core/src/components/workspace/index.tsx index 68bc3bb6ee..77c3a8f1cd 100644 --- a/packages/frontend/core/src/components/workspace/index.tsx +++ b/packages/frontend/core/src/components/workspace/index.tsx @@ -49,9 +49,9 @@ export const MainContainer = forwardRef< HTMLDivElement, PropsWithChildren >(function MainContainer({ className, children, ...props }, ref): ReactElement { - const appSidebarService = useService(AppSidebarService).sidebar; - const appSideBarOpen = useLiveData(appSidebarService.open$); const { appSettings } = useAppSettingHelper(); + const appSidebarService = useService(AppSidebarService).sidebar; + const open = useLiveData(appSidebarService.open$); 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..1508db7d95 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 @@ -3,8 +3,6 @@ import { map } from 'rxjs'; import type { AppSidebarState } from '../providers/storage'; -const isMobile = !BUILD_CONFIG.isElectron && window.innerWidth < 768; - enum APP_SIDEBAR_STATE { OPEN = 'open', WIDTH = 'width', @@ -15,11 +13,15 @@ export class AppSidebar extends Entity { super(); } + /** + * whether the sidebar is open, + * even if the sidebar is not open, hovering can show the floating sidebar + */ open$ = LiveData.from( this.appSidebarState .watch(APP_SIDEBAR_STATE.OPEN) - .pipe(map(value => value ?? !isMobile)), - !isMobile + .pipe(map(value => value ?? true)), + true ); width$ = LiveData.from( @@ -28,8 +30,16 @@ export class AppSidebar extends Entity { .pipe(map(value => value ?? 248)), 248 ); - responsiveFloating$ = new LiveData(isMobile); - hoverFloating$ = new LiveData(false); + + /** + * hovering can show the floating sidebar, without open it + */ + hovering$ = new LiveData(false); + + /** + * small screen mode, will disable hover effect + */ + smallScreenMode$ = new LiveData(false); resizing$ = new LiveData(false); getCachedAppSidebarOpenState = () => { @@ -42,23 +52,15 @@ export class AppSidebar extends Entity { setOpen = (open: boolean) => { this.appSidebarState.set(APP_SIDEBAR_STATE.OPEN, open); - if (!open && this.hoverFloating$.value) { - const timeout = setTimeout(() => { - this.setHoverFloating(false); - }, 500); - return () => { - clearTimeout(timeout); - }; - } return; }; - setResponsiveFloating = (floating: boolean) => { - this.responsiveFloating$.next(floating); + setSmallScreenMode = (smallScreenMode: boolean) => { + this.smallScreenMode$.next(smallScreenMode); }; - setHoverFloating = (hoverFloating: boolean) => { - this.hoverFloating$.next(hoverFloating); + setHovering = (hoverFloating: boolean) => { + this.hovering$.next(hoverFloating); }; setResizing = (resizing: boolean) => { 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..b0f9c4a145 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,45 @@ 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-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 +92,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..c74adfd11e 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, useState } 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,35 @@ export function AppSidebar({ children }: PropsWithChildren) { const open = useLiveData(appSidebarService.open$); const width = useLiveData(appSidebarService.width$); - const floating = useLiveData(appSidebarService.responsiveFloating$); + const smallScreenMode = useLiveData(appSidebarService.smallScreenMode$); + const hovering = useLiveData(appSidebarService.hovering$) && open !== true; const resizing = useLiveData(appSidebarService.resizing$); + const [deferredHovering, setDeferredHovering] = useState(false); + useEffect(() => { + if (open) { + // if open, we don't need to show the floating sidebar + setDeferredHovering(false); + return; + } + // we make a little delay here. + // this allow the sidebar close animation to complete. + const timeout = setTimeout(() => { + setDeferredHovering(hovering); + }, 150); + return () => { + clearTimeout(timeout); + }; + }, [hovering, open]); + + const sidebarState = smallScreenMode + ? open + ? 'floating-with-mask' + : 'close' + : open + ? 'open' + : deferredHovering + ? 'floating' + : 'close'; useEffect(() => { // do not float app sidebar on desktop @@ -55,19 +85,8 @@ export function AppSidebar({ children }: PropsWithChildren) { const isFloatingMaxWidth = window.matchMedia( `(max-width: ${floatingMaxWidth}px)` ).matches; - const isOverflowWidth = window.matchMedia( - `(max-width: ${width / 0.4}px)` - ).matches; - const isFloating = isFloatingMaxWidth || isOverflowWidth; - if ( - open === undefined && - appSidebarService.getCachedAppSidebarOpenState() === undefined - ) { - // give the initial value, - // so that the sidebar can be closed on mobile by default - appSidebarService.setOpen(!isFloating); - } - appSidebarService.setResponsiveFloating(isFloating); + const isFloating = isFloatingMaxWidth; + appSidebarService.setSmallScreenMode(isFloating); } const dOnResize = debounce(onResize, 50); @@ -75,10 +94,9 @@ export function AppSidebar({ children }: PropsWithChildren) { return () => { window.removeEventListener('resize', dOnResize); }; - }, [appSidebarService, open, width]); + }, [appSidebarService]); const hasRightBorder = !BUILD_CONFIG.isElectron && !clientBorder; - const isMacosDesktop = BUILD_CONFIG.isElectron && environment.isMacOs; const handleOpenChange = useCallback( (open: boolean) => { @@ -105,11 +123,21 @@ export function AppSidebar({ children }: PropsWithChildren) { appSidebarService.setOpen(false); }, [appSidebarService]); + const onMouseEnter = useCallback(() => { + appSidebarService.setHovering(true); + }, [appSidebarService]); + + const onMouseLeave = useCallback(() => { + appSidebarService.setHovering(false); + }, [appSidebarService]); + return ( <>