From c1a65b6b7684770083d97cba39350c1d31959e43 Mon Sep 17 00:00:00 2001 From: Himself65 Date: Thu, 27 Apr 2023 16:46:08 -0500 Subject: [PATCH] feat(component): init app sidebar (#2135) --- apps/web/src/atoms/first-load.ts | 15 +- .../affine/sidebar-switch/index.tsx | 10 +- .../blocksuite/workspace-header/header.tsx | 16 +- .../blocksuite/workspace-header/styles.ts | 7 +- .../pure/workspace-slider-bar/index.tsx | 242 ------------------ .../pure/workspace-slider-bar/style.ts | 54 +--- .../src/components/root-app-sidebar/index.tsx | 216 ++++++++++++++++ apps/web/src/hooks/use-sidebar-status.ts | 28 -- apps/web/src/layouts/styles.ts | 21 +- apps/web/src/layouts/workspace-layout.tsx | 68 +---- packages/component/package.json | 2 + .../src/components/app-sidebar/index.css.ts | 96 +++++++ .../src/components/app-sidebar/index.jotai.ts | 7 + .../components/app-sidebar/index.stories.tsx | 53 ++++ .../src/components/app-sidebar/index.tsx | 108 ++++++++ .../app-sidebar/resize-indicator/index.css.ts | 37 +++ .../app-sidebar/resize-indicator/index.tsx | 86 +++++++ .../src/components/changeLog/index.css.ts | 11 +- packages/component/src/ui/button/styles.ts | 1 - tests/parallels/layout.spec.ts | 16 +- tests/parallels/quick-search.spec.ts | 12 +- tests/parallels/subpage.spec.ts | 2 +- yarn.lock | 11 + 23 files changed, 668 insertions(+), 451 deletions(-) create mode 100644 apps/web/src/components/root-app-sidebar/index.tsx delete mode 100644 apps/web/src/hooks/use-sidebar-status.ts create mode 100644 packages/component/src/components/app-sidebar/index.css.ts create mode 100644 packages/component/src/components/app-sidebar/index.jotai.ts create mode 100644 packages/component/src/components/app-sidebar/index.stories.tsx create mode 100644 packages/component/src/components/app-sidebar/index.tsx create mode 100644 packages/component/src/components/app-sidebar/resize-indicator/index.css.ts create mode 100644 packages/component/src/components/app-sidebar/resize-indicator/index.tsx diff --git a/apps/web/src/atoms/first-load.ts b/apps/web/src/atoms/first-load.ts index 5687cb8ee8..8c5f0e48cd 100644 --- a/apps/web/src/atoms/first-load.ts +++ b/apps/web/src/atoms/first-load.ts @@ -1,19 +1,14 @@ -import { atomWithSyncStorage } from '@affine/jotai'; +import { atomWithStorage } from 'jotai/utils'; export type Visibility = Record; const DEFAULT_VALUE = '0.0.0'; -export const lastVersionAtom = atomWithSyncStorage( - 'lastVersion', - DEFAULT_VALUE -); -export const guideHiddenAtom = atomWithSyncStorage( - 'guideHidden', - {} -); +export const lastVersionAtom = atomWithStorage('lastVersion', DEFAULT_VALUE); -export const guideHiddenUntilNextUpdateAtom = atomWithSyncStorage( +export const guideHiddenAtom = atomWithStorage('guideHidden', {}); + +export const guideHiddenUntilNextUpdateAtom = atomWithStorage( 'guideHiddenUntilNextUpdate', {} ); diff --git a/apps/web/src/components/affine/sidebar-switch/index.tsx b/apps/web/src/components/affine/sidebar-switch/index.tsx index d9af30cac3..93c155846a 100644 --- a/apps/web/src/components/affine/sidebar-switch/index.tsx +++ b/apps/web/src/components/affine/sidebar-switch/index.tsx @@ -1,6 +1,8 @@ import { Tooltip } from '@affine/component'; +import { appSidebarOpenAtom } from '@affine/component/app-sidebar'; import { getEnvironment } from '@affine/env'; import { useTranslation } from '@affine/i18n'; +import { useAtom } from 'jotai'; import { useCallback, useEffect, useState } from 'react'; import { @@ -8,7 +10,6 @@ import { useGuideHiddenUntilNextUpdate, useUpdateTipsOnVersionChange, } from '../../../hooks/use-is-first-load'; -import { useSidebarStatus } from '../../../hooks/use-sidebar-status'; import { SidebarSwitchIcon } from './icons'; import { StyledSidebarSwitch } from './style'; type SidebarSwitchProps = { @@ -24,7 +25,7 @@ export const SidebarSwitch = ({ ...props }: SidebarSwitchProps) => { useUpdateTipsOnVersionChange(); - const [open, setOpen] = useSidebarStatus(); + const [open, setOpen] = useAtom(appSidebarOpenAtom); const [tooltipVisible, setTooltipVisible] = useState(false); const [guideHidden, setGuideHidden] = useGuideHidden(); const [guideHiddenUntilNextUpdate, setGuideHiddenUntilNextUpdate] = @@ -56,9 +57,9 @@ export const SidebarSwitch = ({ visible={visible} disabled={!visible} onClick={useCallback(() => { - setOpen(!open); + setOpen(open => !open); setTooltipVisible(false); - if (guideHiddenUntilNextUpdate['quickSearchTips'] === false) { + if (!guideHiddenUntilNextUpdate['quickSearchTips']) { setGuideHiddenUntilNextUpdate({ ...guideHiddenUntilNextUpdate, quickSearchTips: true, @@ -70,7 +71,6 @@ export const SidebarSwitch = ({ }, [ guideHidden, guideHiddenUntilNextUpdate, - open, setGuideHidden, setGuideHiddenUntilNextUpdate, setOpen, diff --git a/apps/web/src/components/blocksuite/workspace-header/header.tsx b/apps/web/src/components/blocksuite/workspace-header/header.tsx index 5f50fa36b5..9234d8e9d6 100644 --- a/apps/web/src/components/blocksuite/workspace-header/header.tsx +++ b/apps/web/src/components/blocksuite/workspace-header/header.tsx @@ -1,7 +1,9 @@ +import { appSidebarOpenAtom } from '@affine/component/app-sidebar'; import { useTranslation } from '@affine/i18n'; import { WorkspaceFlavour } from '@affine/workspace/type'; import { CloseIcon } from '@blocksuite/icons'; import type { Page } from '@blocksuite/store'; +import { useAtom } from 'jotai'; import type { FC, HTMLAttributes, PropsWithChildren } from 'react'; import { forwardRef, @@ -12,10 +14,6 @@ import { useState, } from 'react'; -import { - useSidebarFloating, - useSidebarStatus, -} from '../../../hooks/use-sidebar-status'; import type { AffineOfficialWorkspace } from '../../../shared'; import { EditorOptionMenu } from './header-right-items/EditorOptionMenu'; import EditPage from './header-right-items/EditPage'; @@ -142,17 +140,11 @@ export const Header = forwardRef< useEffect(() => { setShowWarning(shouldShowWarning()); }, []); - const [open] = useSidebarStatus(); - const sidebarFloating = useSidebarFloating(); + const [open] = useAtom(appSidebarOpenAtom); const { t } = useTranslation(); return ( - + { diff --git a/apps/web/src/components/blocksuite/workspace-header/styles.ts b/apps/web/src/components/blocksuite/workspace-header/styles.ts index a678242c06..267cda0279 100644 --- a/apps/web/src/components/blocksuite/workspace-header/styles.ts +++ b/apps/web/src/components/blocksuite/workspace-header/styles.ts @@ -7,8 +7,7 @@ import { export const StyledHeaderContainer = styled('div')<{ hasWarning: boolean; - sidebarFloating: boolean; -}>(({ theme, hasWarning, sidebarFloating }) => { +}>(({ hasWarning }) => { return { height: hasWarning ? '96px' : '52px', flexShrink: 0, @@ -16,10 +15,6 @@ export const StyledHeaderContainer = styled('div')<{ top: 0, background: 'var(--affine-background-primary-color)', zIndex: 1, - WebkitAppRegion: sidebarFloating ? '' : 'drag', - button: { - WebkitAppRegion: 'no-drag', - }, }; }); export const StyledHeader = styled('div')<{ hasWarning: boolean }>( diff --git a/apps/web/src/components/pure/workspace-slider-bar/index.tsx b/apps/web/src/components/pure/workspace-slider-bar/index.tsx index 4c8d04eb4f..2fd3bd8632 100644 --- a/apps/web/src/components/pure/workspace-slider-bar/index.tsx +++ b/apps/web/src/components/pure/workspace-slider-bar/index.tsx @@ -1,47 +1,6 @@ -import { config } from '@affine/env'; -import { useTranslation } from '@affine/i18n'; -import { WorkspaceFlavour } from '@affine/workspace/type'; -import { - DeleteTemporarilyIcon, - FolderIcon, - PlusIcon, - SearchIcon, - SettingsIcon, - ShareIcon, -} from '@blocksuite/icons'; import type { Page, PageMeta } from '@blocksuite/store'; -import type React from 'react'; -import type { UIEvent } from 'react'; -import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; -import { - useSidebarFloating, - useSidebarResizing, - useSidebarStatus, - useSidebarWidth, -} from '../../../hooks/use-sidebar-status'; import type { AllWorkspace } from '../../../shared'; -import { ChangeLog } from './changeLog'; -import Favorite from './favorite'; -import { RouteNavigation } from './RouteNavigation'; -import { StyledListItem } from './shared-styles'; -import { - StyledLink, - StyledNewPageButton, - StyledScrollWrapper, - StyledSidebarHeader, - StyledSliderBar, - StyledSliderBarInnerWrapper, - StyledSliderBarWrapper, - StyledSliderModalBackground, -} from './style'; -import { WorkspaceSelector } from './WorkspaceSelector'; - -const SidebarSwitch = lazy(() => - import('../../affine/sidebar-switch').then(module => ({ - default: module.SidebarSwitch, - })) -); export type FavoriteListProps = { currentPageId: string | null; @@ -67,204 +26,3 @@ export type WorkSpaceSliderBarProps = { shared: (workspaceId: string) => string; }; }; - -export const WorkSpaceSliderBar: React.FC = ({ - isPublicWorkspace, - currentWorkspace, - currentPageId, - openPage, - createPage, - currentPath, - paths, - onOpenQuickSearchModal, - onOpenWorkspaceListModal, -}) => { - const currentWorkspaceId = currentWorkspace?.id || null; - const blockSuiteWorkspace = currentWorkspace?.blockSuiteWorkspace; - const { t } = useTranslation(); - const [sidebarOpen, setSidebarOpen] = useSidebarStatus(); - const onClickNewPage = useCallback(async () => { - const page = await createPage(); - openPage(page.id); - }, [createPage, openPage]); - const floatingSlider = useSidebarFloating(); - const [sliderWidth] = useSidebarWidth(); - const [isResizing] = useSidebarResizing(); - const [isScrollAtTop, setIsScrollAtTop] = useState(true); - const show = isPublicWorkspace ? false : sidebarOpen; - const actualWidth = floatingSlider ? 'calc(10vw + 400px)' : sliderWidth; - - useEffect(() => { - if (environment.isDesktop) { - window.apis?.onSidebarVisibilityChange(sidebarOpen); - } - }, [sidebarOpen]); - - useEffect(() => { - const keydown = (e: KeyboardEvent) => { - if ((e.key === '/' && e.metaKey) || (e.key === '/' && e.ctrlKey)) { - setSidebarOpen(!sidebarOpen); - } - }; - document.addEventListener('keydown', keydown, { capture: true }); - return () => - document.removeEventListener('keydown', keydown, { capture: true }); - }, [sidebarOpen, setSidebarOpen]); - - return ( - <> - - - - - - - - - - - - - { - onOpenQuickSearchModal(); - }, [onOpenQuickSearchModal])} - > - - {t('Quick search')} - - - - -
{t('Workspace Settings')}
-
-
- - - - {t('All pages')} - - - ) => { - (e.target as HTMLDivElement).scrollTop === 0 - ? setIsScrollAtTop(true) - : setIsScrollAtTop(false); - }} - > - {blockSuiteWorkspace && ( - - )} - -
- {config.enableLegacyCloud && - (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE && - currentWorkspace.public ? ( - - - - Published to web - - - ) : ( - - - - {t('Shared Pages')} - - - ))} - - - {t('Trash')} - - -
- - - {t('New Page')} - -
-
- setSidebarOpen(false)} - /> - - ); -}; - -export default WorkSpaceSliderBar; diff --git a/apps/web/src/components/pure/workspace-slider-bar/style.ts b/apps/web/src/components/pure/workspace-slider-bar/style.ts index 1a5ff0a36f..10785840e4 100644 --- a/apps/web/src/components/pure/workspace-slider-bar/style.ts +++ b/apps/web/src/components/pure/workspace-slider-bar/style.ts @@ -2,60 +2,10 @@ import { displayFlex, styled, textEllipsis } from '@affine/component'; import { baseTheme } from '@toeverything/theme'; import Link from 'next/link'; -const macosElectron = environment.isDesktop && environment.isMacOs; - -export const StyledSliderBarWrapper = styled('div')<{ - show: boolean; - floating: boolean; - resizing: boolean; -}>(({ theme, show, floating, resizing }) => { - return { - height: '100%', - position: 'absolute', - 'button, a': { - userSelect: 'none', - }, - zIndex: 'var(--affine-z-index-modal)', - transition: resizing ? '' : 'transform .3s, width .3s, max-width .3s', - transform: show ? 'translateX(0)' : 'translateX(-100%)', - maxWidth: floating ? 'calc(10vw + 400px)' : 'calc(100vw - 698px)', - background: - !floating && macosElectron - ? 'transparent' - : 'var(--affine-background-secondary-color)', - borderRight: macosElectron ? '' : '1px solid', - borderColor: 'var(--affine-border-color)', - }; -}); - -export const StyledSliderBar = styled('div')(() => { - return { - whiteSpace: 'nowrap', - width: '100%', - height: '100%', - padding: '0 4px', - flexShrink: 0, - display: 'flex', - flexDirection: 'column', - }; -}); -export const StyledSidebarHeader = styled('div')(() => { - return { - height: '52px', - flexShrink: 0, - padding: '0 16px 0 10px', - WebkitAppRegion: 'drag', - button: { - WebkitAppRegion: 'no-drag', - }, - ...displayFlex(macosElectron ? 'flex-end' : 'space-between', 'center'), - }; -}); export const StyledSliderBarInnerWrapper = styled('div')(() => { return { flexGrow: 1, - // overflowX: 'hidden', - // overflowY: 'auto', + margin: '0 2px', position: 'relative', height: 'calc(100% - 52px * 2)', display: 'flex', @@ -91,8 +41,6 @@ export const StyledNewPageButton = styled('button')(({ theme }) => { return { height: '52px', ...displayFlex('flex-start', 'center'), - borderTop: '1px solid', - borderColor: 'var(--affine-border-color)', padding: '0 8px 0 16px', svg: { fontSize: '20px', diff --git a/apps/web/src/components/root-app-sidebar/index.tsx b/apps/web/src/components/root-app-sidebar/index.tsx new file mode 100644 index 0000000000..32ffa42b0c --- /dev/null +++ b/apps/web/src/components/root-app-sidebar/index.tsx @@ -0,0 +1,216 @@ +import { + AppSidebar, + appSidebarOpenAtom, + ResizeIndicator, +} from '@affine/component/app-sidebar'; +import { config } from '@affine/env'; +import { useTranslation } from '@affine/i18n'; +import { WorkspaceFlavour } from '@affine/workspace/type'; +import { + DeleteTemporarilyIcon, + FolderIcon, + PlusIcon, + SearchIcon, + SettingsIcon, + ShareIcon, +} from '@blocksuite/icons'; +import type { Page } from '@blocksuite/store'; +import { useAtomValue } from 'jotai'; +import type { ReactElement, UIEvent } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import type { AllWorkspace } from '../../shared'; +import ChangeLog from '../pure/workspace-slider-bar/changeLog'; +import Favorite from '../pure/workspace-slider-bar/favorite'; +import { StyledListItem } from '../pure/workspace-slider-bar/shared-styles'; +import { + StyledLink, + StyledNewPageButton, + StyledScrollWrapper, + StyledSliderBarInnerWrapper, +} from '../pure/workspace-slider-bar/style'; +import { WorkspaceSelector } from '../pure/workspace-slider-bar/WorkspaceSelector'; + +export type RootAppSidebarProps = { + isPublicWorkspace: boolean; + onOpenQuickSearchModal: () => void; + onOpenWorkspaceListModal: () => void; + currentWorkspace: AllWorkspace | null; + currentPageId: string | null; + openPage: (pageId: string) => void; + createPage: () => Page; + currentPath: string; + paths: { + all: (workspaceId: string) => string; + favorite: (workspaceId: string) => string; + trash: (workspaceId: string) => string; + setting: (workspaceId: string) => string; + shared: (workspaceId: string) => string; + }; +}; + +/** + * This is for the whole affine app sidebar. + * This component wraps the app sidebar in `@affine/component` with logic and data. + * + * @todo(himself65): rewrite all styled component into @vanilla-extract/css + */ +export const RootAppSidebar = ({ + currentWorkspace, + currentPageId, + openPage, + createPage, + currentPath, + paths, + onOpenQuickSearchModal, + onOpenWorkspaceListModal, +}: RootAppSidebarProps): ReactElement => { + const currentWorkspaceId = currentWorkspace?.id || null; + const blockSuiteWorkspace = currentWorkspace?.blockSuiteWorkspace; + const { t } = useTranslation(); + const [isScrollAtTop, setIsScrollAtTop] = useState(true); + const onClickNewPage = useCallback(async () => { + const page = await createPage(); + openPage(page.id); + }, [createPage, openPage]); + const sidebarOpen = useAtomValue(appSidebarOpenAtom); + useEffect(() => { + if (environment.isDesktop) { + window.apis?.onSidebarVisibilityChange(sidebarOpen); + } + }, [sidebarOpen]); + const ref = useRef(null); + return ( + <> + + {t('New Page')} + + } + > + + + + { + onOpenQuickSearchModal(); + }, [onOpenQuickSearchModal])} + > + + {t('Quick search')} + + + + +
{t('Workspace Settings')}
+
+
+ + + + {t('All pages')} + + + ) => { + (e.target as HTMLDivElement).scrollTop === 0 + ? setIsScrollAtTop(true) + : setIsScrollAtTop(false); + }} + > + {blockSuiteWorkspace && ( + + )} + +
+ {config.enableLegacyCloud && + (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE && + currentWorkspace.public ? ( + + + + Published to web + + + ) : ( + + + + {t('Shared Pages')} + + + ))} + + + {t('Trash')} + + +
+
+ + + ); +}; diff --git a/apps/web/src/hooks/use-sidebar-status.ts b/apps/web/src/hooks/use-sidebar-status.ts deleted file mode 100644 index ea12985f48..0000000000 --- a/apps/web/src/hooks/use-sidebar-status.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useTheme } from '@mui/material'; -import { useMediaQuery } from '@react-hookz/web'; -import { atom, useAtom } from 'jotai'; -import { atomWithStorage } from 'jotai/utils'; - -const sideBarOpenAtom = atomWithStorage('sidebarOpen', true); -const sideBarWidthAtom = atomWithStorage('sidebarWidth', 256); -const sidebarResizingAtom = atom(false); - -export function useSidebarStatus() { - return useAtom(sideBarOpenAtom); -} - -export function useSidebarWidth() { - return useAtom(sideBarWidthAtom); -} - -export function useSidebarFloating() { - const theme = useTheme(); - return ( - useMediaQuery(theme.breakpoints.down('md').replace(/^@media( ?)/m, '')) ?? - false - ); -} - -export function useSidebarResizing() { - return useAtom(sidebarResizingAtom); -} diff --git a/apps/web/src/layouts/styles.ts b/apps/web/src/layouts/styles.ts index 47acb5b75f..caa6e8d829 100644 --- a/apps/web/src/layouts/styles.ts +++ b/apps/web/src/layouts/styles.ts @@ -10,6 +10,7 @@ export const StyledPage = styled('div')<{ resizing?: boolean }>( transition: 'background-color .5s', display: 'flex', flexGrow: '1', + flexDirection: 'row', '--affine-editor-width': '686px', [theme.breakpoints.down('sm')]: { '--affine-editor-width': '550px', @@ -40,17 +41,15 @@ export const StyledWrapper = styled('div')(() => { }; }); -export const MainContainerWrapper = styled('div')<{ resizing: boolean }>( - ({ resizing }) => { - return { - display: 'flex', - flexGrow: 1, - position: 'relative', - maxWidth: '100vw', - overflow: 'auto', - }; - } -); +export const MainContainerWrapper = styled('div')(() => { + return { + display: 'flex', + flexGrow: 1, + position: 'relative', + maxWidth: '100vw', + overflow: 'auto', + }; +}); export const MainContainer = styled('div')(({ theme }) => { return { diff --git a/apps/web/src/layouts/workspace-layout.tsx b/apps/web/src/layouts/workspace-layout.tsx index 386c2a2bff..0c4e8c37d6 100644 --- a/apps/web/src/layouts/workspace-layout.tsx +++ b/apps/web/src/layouts/workspace-layout.tsx @@ -33,17 +33,11 @@ import { } from '../atoms/public-workspace'; import { HelpIsland } from '../components/pure/help-island'; import { PageLoading } from '../components/pure/loading'; -import WorkSpaceSliderBar from '../components/pure/workspace-slider-bar'; +import { RootAppSidebar } from '../components/root-app-sidebar'; import { useCurrentWorkspace } from '../hooks/current/use-current-workspace'; import { useRouterHelper } from '../hooks/use-router-helper'; import { useRouterTitle } from '../hooks/use-router-title'; import { useRouterWithWorkspaceIdDefense } from '../hooks/use-router-with-workspace-id-defense'; -import { - useSidebarFloating, - useSidebarResizing, - useSidebarStatus, - useSidebarWidth, -} from '../hooks/use-sidebar-status'; import { useSyncRouterWithCurrentPageId } from '../hooks/use-sync-router-with-current-page-id'; import { useSyncRouterWithCurrentWorkspaceId } from '../hooks/use-sync-router-with-current-workspace-id'; import { useWorkspaces } from '../hooks/use-workspaces'; @@ -55,9 +49,6 @@ import { MainContainer, MainContainerWrapper, StyledPage, - StyledSliderResizer, - StyledSliderResizerInner, - StyledSpacer, StyledToolWrapper, } from './styles'; @@ -377,49 +368,14 @@ export const WorkspaceLayoutInner: FC = ({ children }) => { const handleOpenQuickSearchModal = useCallback(() => { setOpenQuickSearchModalAtom(true); }, [setOpenQuickSearchModalAtom]); - const [resizingSidebar, setIsResizing] = useSidebarResizing(); - const [sidebarOpen, setSidebarOpen] = useSidebarStatus(); - const sidebarFloating = useSidebarFloating(); - const [sidebarWidth, setSliderWidth] = useSidebarWidth(); - const actualSidebarWidth = !sidebarOpen - ? 0 - : sidebarFloating - ? 'calc(10vw + 400px)' - : sidebarWidth; - const mainWidth = - sidebarOpen && !sidebarFloating ? `calc(100% - ${sidebarWidth}px)` : '100%'; - const [resizing] = useSidebarResizing(); - - const onResizeStart = useCallback(() => { - let resized = false; - function onMouseMove(e: MouseEvent) { - const newWidth = Math.min(480, Math.max(e.clientX, 256)); - setSliderWidth(newWidth); - setIsResizing(true); - resized = true; - } - document.addEventListener('mousemove', onMouseMove); - document.addEventListener( - 'mouseup', - () => { - // if not resized, toggle sidebar - if (!resized) { - setSidebarOpen(o => !o); - } - setIsResizing(false); - document.removeEventListener('mousemove', onMouseMove); - }, - { once: true } - ); - }, [setIsResizing, setSidebarOpen, setSliderWidth]); return ( <> {title} - - + = ({ children }) => { currentPath={router.asPath.split('?')[0]} paths={isPublicWorkspace ? publicPathGenerator : pathGenerator} /> - - {!sidebarFloating && sidebarOpen && ( - - - - )} - - + }> {isLoading ? ( diff --git a/packages/component/package.json b/packages/component/package.json index 4a2682a746..ceebd21866 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -33,9 +33,11 @@ "@mui/base": "5.0.0-alpha.127", "@mui/icons-material": "^5.11.16", "@mui/material": "^5.12.2", + "@popperjs/core": "^2.11.7", "@radix-ui/react-avatar": "^1.0.2", "@toeverything/hooks": "workspace:*", "@toeverything/theme": "workspace:*", + "@vanilla-extract/dynamic": "^2.0.3", "clsx": "^1.2.1", "jotai": "^2.0.4", "lit": "^2.7.2", diff --git a/packages/component/src/components/app-sidebar/index.css.ts b/packages/component/src/components/app-sidebar/index.css.ts new file mode 100644 index 0000000000..4316ae3aa7 --- /dev/null +++ b/packages/component/src/components/app-sidebar/index.css.ts @@ -0,0 +1,96 @@ +import { baseTheme } from '@toeverything/theme'; +import { createVar, style } from '@vanilla-extract/css'; + +export const floatingMaxWidth = 768; +export const navWidthVar = createVar('nav-width'); + +export const navStyle = style({ + position: 'relative', + backgroundColor: 'var(--affine-background-secondary-color)', + width: navWidthVar, + minWidth: navWidthVar, + height: '100%', + display: 'flex', + flexDirection: 'column', + transition: 'margin-left .3s', + zIndex: parseInt(baseTheme.zIndexModal), + borderRight: '1px solid var(--affine-border-color)', + '@media': { + [`(max-width: ${floatingMaxWidth}px)`]: { + position: 'absolute', + width: `calc(10vw + ${navWidthVar})`, + selectors: { + '&[data-open="false"]': { + marginLeft: `calc((10vw + ${navWidthVar}) * -1)`, + }, + '&[data-is-macos-electron="true"]': { + backgroundColor: 'var(--affine-background-secondary-color)', + }, + }, + }, + }, + selectors: { + '&[data-open="false"]': { + marginLeft: `calc(${navWidthVar} * -1)`, + }, + '&[data-is-macos-electron="true"]': { + backgroundColor: 'transparent', + }, + }, + vars: { + [navWidthVar]: '256px', + }, +}); + +export const navHeaderStyle = style({ + flex: '0 0 auto', + height: '52px', + padding: '0px 16px 0px 10px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + selectors: { + '&[data-is-macos-electron="true"]': { + justifyContent: 'flex-end', + }, + }, +}); + +export const navBodyStyle = style({ + flex: '1 1 auto', +}); + +export const navFooterStyle = style({ + flex: '0 0 auto', + borderTop: '1px solid var(--affine-border-color)', +}); + +export const sidebarButtonStyle = style({ + width: '32px', + height: '32px', + color: 'var(--affine-icon-color)', +}); + +export const sidebarFloatMaskStyle = style({ + transition: 'opacity .15s', + opacity: 0, + pointerEvents: 'none', + position: 'fixed', + top: 0, + left: 0, + right: '100%', + bottom: 0, + zIndex: parseInt(baseTheme.zIndexModal) - 1, + background: 'var(--affine-background-modal-color)', + '@media': { + [`(max-width: ${floatingMaxWidth}px)`]: { + selectors: { + '&[data-open="true"]': { + opacity: 1, + pointerEvents: 'auto', + right: '0', + }, + }, + }, + }, +}); diff --git a/packages/component/src/components/app-sidebar/index.jotai.ts b/packages/component/src/components/app-sidebar/index.jotai.ts new file mode 100644 index 0000000000..864a3eb668 --- /dev/null +++ b/packages/component/src/components/app-sidebar/index.jotai.ts @@ -0,0 +1,7 @@ +import { atomWithStorage } from 'jotai/utils'; + +export const appSidebarOpenAtom = atomWithStorage('app-sidebar-open', true); +export const appSidebarWidthAtom = atomWithStorage( + 'app-sidebar-width', + 256 /* px */ +); diff --git a/packages/component/src/components/app-sidebar/index.stories.tsx b/packages/component/src/components/app-sidebar/index.stories.tsx new file mode 100644 index 0000000000..88274b7c20 --- /dev/null +++ b/packages/component/src/components/app-sidebar/index.stories.tsx @@ -0,0 +1,53 @@ +import { IconButton } from '@affine/component'; +import { SidebarIcon } from '@blocksuite/icons'; +import type { Meta, StoryFn } from '@storybook/react'; +import { useAtom } from 'jotai'; +import { useRef } from 'react'; + +import { AppSidebar, appSidebarOpenAtom, ResizeIndicator } from '.'; +import { navHeaderStyle, sidebarButtonStyle } from './index.css'; + +export default { + title: 'Components/AppSidebar', + component: AppSidebar, +} satisfies Meta; + +const Footer = () =>
Add Page
; + +export const Default: StoryFn = props => { + const [open, setOpen] = useAtom(appSidebarOpenAtom); + const ref = useRef(null); + return ( + <> +
+ } ref={ref}> + Test + + +
+
+ {!open && ( + { + setOpen(true); + }} + > + + + )} +
+
+
+ + ); +}; diff --git a/packages/component/src/components/app-sidebar/index.tsx b/packages/component/src/components/app-sidebar/index.tsx new file mode 100644 index 0000000000..57ced8b946 --- /dev/null +++ b/packages/component/src/components/app-sidebar/index.tsx @@ -0,0 +1,108 @@ +import { getEnvironment } from '@affine/env'; +import { + ArrowLeftSmallIcon, + ArrowRightSmallIcon, + SidebarIcon, +} from '@blocksuite/icons'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import { useAtom, useAtomValue } from 'jotai'; +import type { PropsWithChildren, ReactElement } from 'react'; +import type { ReactNode } from 'react'; +import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; + +import { IconButton } from '../../ui/button/IconButton'; +import { + navBodyStyle, + navFooterStyle, + navHeaderStyle, + navStyle, + navWidthVar, + sidebarButtonStyle, + sidebarFloatMaskStyle, +} from './index.css'; +import { appSidebarOpenAtom, appSidebarWidthAtom } from './index.jotai'; + +export { appSidebarOpenAtom }; + +export type AppSidebarProps = PropsWithChildren<{ + footer?: ReactNode | undefined; +}>; + +export const AppSidebar = forwardRef( + function AppSidebar(props, forwardedRef): ReactElement { + const ref = useRef(null); + const [open, setOpen] = useAtom(appSidebarOpenAtom); + + const appSidebarWidth = useAtomValue(appSidebarWidthAtom); + + const handleSidebarOpen = useCallback(() => { + setOpen(open => !open); + }, [setOpen]); + + useImperativeHandle(forwardedRef, () => ref.current as HTMLElement); + + const environment = getEnvironment(); + const isMacosDesktop = environment.isDesktop && environment.isMacOs; + return ( + <> + +
{ + setOpen(false); + }, [setOpen])} + /> + + ); + } +); + +export type { ResizeIndicatorProps } from './resize-indicator'; +export { ResizeIndicator } from './resize-indicator'; diff --git a/packages/component/src/components/app-sidebar/resize-indicator/index.css.ts b/packages/component/src/components/app-sidebar/resize-indicator/index.css.ts new file mode 100644 index 0000000000..3f1610e40a --- /dev/null +++ b/packages/component/src/components/app-sidebar/resize-indicator/index.css.ts @@ -0,0 +1,37 @@ +import { style } from '@vanilla-extract/css'; + +import { navWidthVar } from '../index.css'; + +export const spacerStyle = style({ + position: 'absolute', + width: '1px', + left: navWidthVar, + top: 0, + bottom: 0, + height: '100%', + zIndex: 'calc(var(--affine-z-index-modal) - 1)', + backgroundColor: 'var(--affine-border-color)', + opacity: 0, + cursor: 'col-resize', + '@media': { + '(max-width: 600px)': { + // do not allow resizing on mobile + display: 'none', + }, + }, + transition: 'opacity 0.15s ease 0.1s', + selectors: { + '&:hover': { + opacity: 1, + }, + '&[data-resizing="true"]': { + transition: 'width .3s, min-width .3s, max-width .3s', + }, + '&[data-open="false"]': { + display: 'none', + }, + '&[data-open="open"]': { + display: 'block', + }, + }, +}); diff --git a/packages/component/src/components/app-sidebar/resize-indicator/index.tsx b/packages/component/src/components/app-sidebar/resize-indicator/index.tsx new file mode 100644 index 0000000000..d07f46a0c2 --- /dev/null +++ b/packages/component/src/components/app-sidebar/resize-indicator/index.tsx @@ -0,0 +1,86 @@ +import type { Instance } from '@popperjs/core'; +import { createPopper } from '@popperjs/core'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import type { ReactElement, RefObject } from 'react'; +import { + useCallback, + useDeferredValue, + useEffect, + useRef, + useState, +} from 'react'; + +import { appSidebarOpenAtom, appSidebarWidthAtom } from '../index.jotai'; +import { spacerStyle } from './index.css'; + +export type ResizeIndicatorProps = { + targetElement: RefObject; +}; + +export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => { + const ref = useRef(null); + const popperRef = useRef(null); + const setWidth = useSetAtom(appSidebarWidthAtom); + const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom); + const [isResizing, setIsResizing] = useState(false); + useEffect(() => { + if (ref.current) { + if (props.targetElement.current) { + popperRef.current = createPopper( + props.targetElement.current, + ref.current, + { + placement: 'right', + } + ); + } + } + }, [props.targetElement]); + + const sidebarWidth = useDeferredValue(useAtomValue(appSidebarWidthAtom)); + useEffect(() => { + if (popperRef.current) { + popperRef.current.update(); + } + }, [sidebarWidth]); + + const onResizeStart = useCallback(() => { + let resized = false; + + function onMouseMove(e: MouseEvent) { + e.preventDefault(); + const newWidth = Math.min(480, Math.max(e.clientX, 256)); + setWidth(newWidth); + setIsResizing(true); + resized = true; + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener( + 'mouseup', + () => { + // if not resized, toggle sidebar + if (!resized) { + setSidebarOpen(o => !o); + } + if (popperRef.current) { + popperRef.current.update(); + } + setIsResizing(false); + document.removeEventListener('mousemove', onMouseMove); + }, + { once: true } + ); + }, [setSidebarOpen, setWidth]); + + return ( +
+ ); +}; diff --git a/packages/component/src/components/changeLog/index.css.ts b/packages/component/src/components/changeLog/index.css.ts index 8816170d44..14eadbdc71 100644 --- a/packages/component/src/components/changeLog/index.css.ts +++ b/packages/component/src/components/changeLog/index.css.ts @@ -66,16 +66,18 @@ export const changeLogWrapperSlideOutStyle = style({ animation: `${slideOut} .3s ease-in-out forwards`, }); export const changeLogSlideInStyle = style({ - width: '110%', + // fixme: if width is 100% and marginLeft is 0, + // the UI will overflow on app sidebar + width: '99%', + marginLeft: '2px', height: '32px', display: 'flex', - justifyContent: 'flex-start', + justifyContent: 'space-between', alignItems: 'center', color: 'var(--affine-primary-color)', backgroundColor: 'var(--affine-tertiary-color)', border: '1px solid var(--affine-primary-color)', borderRight: 'none', - marginLeft: '8px', paddingLeft: '8px', borderRadius: '16px 0 0 16px', cursor: 'pointer', @@ -89,7 +91,6 @@ export const changeLogSlideOutStyle = style({ animation: `${slideOut2} .3s ease-in-out forwards`, }); export const linkStyle = style({ - flexGrow: 1, textAlign: 'left', color: 'var(--affine-text-emphasis-color)', display: 'flex', @@ -103,6 +104,6 @@ export const iconStyle = style({ }); export const iconButtonStyle = style({ fontSize: '20px', - marginRight: '12%', + marginRight: '0', color: 'var(--affine-icon-color)', }); diff --git a/packages/component/src/ui/button/styles.ts b/packages/component/src/ui/button/styles.ts index b04bd92d68..a586b864cf 100644 --- a/packages/component/src/ui/button/styles.ts +++ b/packages/component/src/ui/button/styles.ts @@ -30,7 +30,6 @@ export const StyledIconButton = styled('button', { fontSize?: CSSProperties['fontSize']; }>( ({ - theme, width, height, borderRadius, diff --git a/tests/parallels/layout.spec.ts b/tests/parallels/layout.spec.ts index ca6419fe6b..02635a9c1c 100644 --- a/tests/parallels/layout.spec.ts +++ b/tests/parallels/layout.spec.ts @@ -7,15 +7,15 @@ import { waitMarkdownImported } from '../libs/page-logic'; test('Collapse Sidebar', async ({ page }) => { await openHomePage(page); await waitMarkdownImported(page); - await page.getByTestId('sliderBar-arrowButton-collapse').click(); - const sliderBarArea = page.getByTestId('sliderBar-root'); + await page.getByTestId('app-sidebar-arrow-button-collapse').click(); + const sliderBarArea = page.getByTestId('app-sidebar'); await expect(sliderBarArea).not.toBeInViewport(); }); test('Expand Sidebar', async ({ page }) => { await openHomePage(page); await waitMarkdownImported(page); - await page.getByTestId('sliderBar-arrowButton-collapse').click(); + await page.getByTestId('app-sidebar-arrow-button-collapse').click(); const sliderBarArea = page.getByTestId('sliderBar-inner'); await expect(sliderBarArea).not.toBeInViewport(); @@ -29,7 +29,7 @@ test('Click resizer can close sidebar', async ({ page }) => { const sliderBarArea = page.getByTestId('sliderBar-inner'); await expect(sliderBarArea).toBeVisible(); - await page.getByTestId('sliderBar-resizer').click(); + await page.getByTestId('app-sidebar-resizer').click(); await expect(sliderBarArea).not.toBeInViewport(); }); @@ -39,14 +39,14 @@ test('Drag resizer can resize sidebar', async ({ page }) => { const sliderBarArea = page.getByTestId('sliderBar-inner'); await expect(sliderBarArea).toBeVisible(); - const sliderResizer = page.getByTestId('sliderBar-resizer'); + const sliderResizer = page.getByTestId('app-sidebar-resizer'); await sliderResizer.hover(); await page.mouse.down(); await page.mouse.move(400, 300, { steps: 10, }); await page.mouse.up(); - const boundingBox = await page.getByTestId('sliderBar-root').boundingBox(); + const boundingBox = await page.getByTestId('app-sidebar').boundingBox(); expect(boundingBox?.width).toBe(400); }); @@ -54,9 +54,7 @@ test('Sidebar in between sm & md breakpoint', async ({ page }) => { await openHomePage(page); await waitMarkdownImported(page); const sliderBarArea = page.getByTestId('sliderBar-inner'); - const sliderBarModalBackground = page.getByTestId( - 'sliderBar-modalBackground' - ); + const sliderBarModalBackground = page.getByTestId('app-sidebar-float-mask'); await expect(sliderBarArea).toBeInViewport(); await expect(sliderBarModalBackground).not.toBeVisible(); diff --git a/tests/parallels/quick-search.spec.ts b/tests/parallels/quick-search.spec.ts index 35274b71e0..b3bb96353b 100644 --- a/tests/parallels/quick-search.spec.ts +++ b/tests/parallels/quick-search.spec.ts @@ -178,7 +178,9 @@ test('When opening the website for the first time, the first folding sidebar wil await waitMarkdownImported(page); const quickSearchTips = page.locator('[data-testid=quick-search-tips]'); await expect(quickSearchTips).not.toBeVisible(); - await page.getByTestId('sliderBar-arrowButton-collapse').click(); + await page.getByTestId('app-sidebar-arrow-button-collapse').click(); + await page.waitForTimeout(200); + await page.getByTestId('sliderBar-arrowButton-expand').click(); const sliderBarArea = page.getByTestId('sliderBar-inner'); await expect(sliderBarArea).not.toBeInViewport(); await expect(quickSearchTips).toBeVisible(); @@ -192,15 +194,17 @@ test('After appearing once, it will not appear a second time', async ({ await waitMarkdownImported(page); const quickSearchTips = page.locator('[data-testid=quick-search-tips]'); await expect(quickSearchTips).not.toBeVisible(); - await page.getByTestId('sliderBar-arrowButton-collapse').click(); + await page.getByTestId('app-sidebar-arrow-button-collapse').click(); + await page.waitForTimeout(200); + await page.getByTestId('sliderBar-arrowButton-expand').click(); const sliderBarArea = page.getByTestId('sliderBar'); await expect(sliderBarArea).not.toBeVisible(); await expect(quickSearchTips).toBeVisible(); await page.locator('[data-testid=quick-search-got-it]').click(); await expect(quickSearchTips).not.toBeVisible(); await page.reload(); - await page.locator('[data-testid=sliderBar-arrowButton-expand]').click(); - await page.getByTestId('sliderBar-arrowButton-collapse').click(); + await page.waitForSelector('v-line'); + await page.getByTestId('app-sidebar-arrow-button-collapse').click(); await expect(quickSearchTips).not.toBeVisible(); }); diff --git a/tests/parallels/subpage.spec.ts b/tests/parallels/subpage.spec.ts index 003f95efdb..29bcd2d8ab 100644 --- a/tests/parallels/subpage.spec.ts +++ b/tests/parallels/subpage.spec.ts @@ -7,7 +7,7 @@ import { waitMarkdownImported } from '../libs/page-logic'; test('Create subpage', async ({ page }) => { await openHomePage(page); await waitMarkdownImported(page); - await page.getByTestId('sliderBar-arrowButton-collapse').click(); + await page.getByTestId('app-sidebar-arrow-button-collapse').click(); const sliderBarArea = page.getByTestId('sliderBar-inner'); await expect(sliderBarArea).not.toBeInViewport(); }); diff --git a/yarn.lock b/yarn.lock index 58579de689..84a7fbb6d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -64,6 +64,7 @@ __metadata: "@mui/base": 5.0.0-alpha.127 "@mui/icons-material": ^5.11.16 "@mui/material": ^5.12.2 + "@popperjs/core": ^2.11.7 "@radix-ui/react-avatar": ^1.0.2 "@storybook/addon-actions": ^7.0.7 "@storybook/addon-coverage": ^0.0.8 @@ -84,6 +85,7 @@ __metadata: "@types/react-dnd": ^3.0.2 "@types/react-dom": 18.0.11 "@vanilla-extract/css": ^1.11.0 + "@vanilla-extract/dynamic": ^2.0.3 "@vitejs/plugin-react": ^4.0.0 clsx: ^1.2.1 concurrently: ^8.0.1 @@ -9259,6 +9261,15 @@ __metadata: languageName: node linkType: hard +"@vanilla-extract/dynamic@npm:^2.0.3": + version: 2.0.3 + resolution: "@vanilla-extract/dynamic@npm:2.0.3" + dependencies: + "@vanilla-extract/private": ^1.0.3 + checksum: 9ad4068d7e28361a7aca46b5f14094e74613428fb600e54227d8ba7a35926c0d7339de1876eb2b3563f6d97c0f08fa09d5ff597f76776f8edca37510165682b0 + languageName: node + linkType: hard + "@vanilla-extract/integration@npm:^6.0.0, @vanilla-extract/integration@npm:^6.0.2": version: 6.2.1 resolution: "@vanilla-extract/integration@npm:6.2.1"