feat: new sidebar (app shell) styles (#2303)

This commit is contained in:
Peng Xiao
2023-05-17 14:14:39 +08:00
committed by LongYinan
parent add5deae0f
commit 683343ad82
54 changed files with 1166 additions and 642 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ async function createWindow() {
y: mainWindowState.y, y: mainWindowState.y,
width: mainWindowState.width, width: mainWindowState.width,
minWidth: 640, minWidth: 640,
transparent: isMacOS(), minHeight: 480,
visualEffectState: 'active', visualEffectState: 'active',
vibrancy: 'under-window', vibrancy: 'under-window',
height: mainWindowState.height, height: mainWindowState.height,
@@ -7,7 +7,7 @@ export const StyledSidebarSwitch = styled(IconButton, {
})<{ visible: boolean }>(({ visible }) => { })<{ visible: boolean }>(({ visible }) => {
return { return {
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
WebkitAppRegion: 'no-drag', WebkitAppRegion: visible ? 'no-drag' : 'drag',
transition: 'all 0.2s ease-in-out', transition: 'all 0.2s ease-in-out',
}; };
}); });
@@ -4,7 +4,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { WorkspaceFlavour } from '@affine/workspace/type'; import { WorkspaceFlavour } from '@affine/workspace/type';
import { CloseIcon, MinusIcon, RoundedRectangleIcon } from '@blocksuite/icons'; import { CloseIcon, MinusIcon, RoundedRectangleIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store'; import type { Page } from '@blocksuite/store';
import { useAtom } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import type { FC, HTMLAttributes, PropsWithChildren } from 'react'; import type { FC, HTMLAttributes, PropsWithChildren } from 'react';
import { import {
forwardRef, forwardRef,
@@ -161,7 +161,7 @@ export const Header = forwardRef<
setShowWarning(shouldShowWarning()); setShowWarning(shouldShowWarning());
setShowGuideDownloadClientTip(shouldShowGuideDownloadClientTip); setShowGuideDownloadClientTip(shouldShowGuideDownloadClientTip);
}, [shouldShowGuideDownloadClientTip]); }, [shouldShowGuideDownloadClientTip]);
const [open] = useAtom(appSidebarOpenAtom); const open = useAtomValue(appSidebarOpenAtom);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const mode = useCurrentMode(); const mode = useCurrentMode();
@@ -189,7 +189,6 @@ export const Header = forwardRef<
className={styles.header} className={styles.header}
data-has-warning={showWarning} data-has-warning={showWarning}
data-testid="editor-header-items" data-testid="editor-header-items"
data-tauri-drag-region
data-is-edgeless={mode === 'edgeless'} data-is-edgeless={mode === 'edgeless'}
> >
<Suspense> <Suspense>
@@ -6,18 +6,9 @@ export const headerContainer = style({
position: 'sticky', position: 'sticky',
top: 0, top: 0,
background: 'var(--affine-background-primary-color)', background: 'var(--affine-background-primary-color)',
// @ts-ignore
WebkitAppRegion: 'drag', WebkitAppRegion: 'drag',
zIndex: 'var(--affine-z-index-popover)', zIndex: 'var(--affine-z-index-popover)',
'@media': {
'(max-width: 768px)': {
selectors: {
'&[data-open="true"]': {
// @ts-ignore
WebkitAppRegion: 'no-drag',
},
},
},
},
selectors: { selectors: {
'&[data-has-warning="true"]': { '&[data-has-warning="true"]': {
height: '96px', height: '96px',
@@ -6,7 +6,7 @@ export const StyledSelectorContainer = styled('div')(() => {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '0 6px', padding: '0 6px',
marginBottom: '16px', margin: '0 -6px',
borderRadius: '8px', borderRadius: '8px',
color: 'var(--affine-text-primary-color)', color: 'var(--affine-text-primary-color)',
':hover': { ':hover': {
@@ -1,13 +1,10 @@
import { MenuItem } from '@affine/component/app-sidebar';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { StyledCollapseItem } from '../shared-styles';
export const EmptyItem = () => { export const EmptyItem = () => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
return ( return (
<StyledCollapseItem disable={true} textWrap={true}> <MenuItem disabled={true}>{t['Favorite pages for easy access']()}</MenuItem>
{t['Favorite pages for easy access']()}
</StyledCollapseItem>
); );
}; };
@@ -1,20 +1,18 @@
import { MuiCollapse } from '@affine/component'; import { MenuLinkItem } from '@affine/component/app-sidebar';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms'; import { workspacePreferredModeAtom } from '../../../../atoms';
import type { FavoriteListProps } from '../index'; import type { FavoriteListProps } from '../index';
import { StyledCollapseItem } from '../shared-styles';
import EmptyItem from './empty-item'; import EmptyItem from './empty-item';
export const FavoriteList = ({ export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => {
pageMeta,
openPage,
showList,
}: FavoriteListProps) => {
const router = useRouter(); const router = useRouter();
const record = useAtomValue(workspacePreferredModeAtom); const record = useAtomValue(workspacePreferredModeAtom);
const pageMeta = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const workspaceId = currentWorkspace.id;
const favoriteList = useMemo( const favoriteList = useMemo(
() => pageMeta.filter(p => p.favorite && !p.trash), () => pageMeta.filter(p => p.favorite && !p.trash),
@@ -22,45 +20,25 @@ export const FavoriteList = ({
); );
return ( return (
<MuiCollapse <>
in={showList}
style={{
maxHeight: 300,
overflowY: 'auto',
marginLeft: '16px',
}}
>
{favoriteList.map((pageMeta, index) => { {favoriteList.map((pageMeta, index) => {
const active = router.query.pageId === pageMeta.id; const active = router.query.pageId === pageMeta.id;
const icon =
record[pageMeta.id] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
return ( return (
<div key={`${pageMeta}-${index}`}> <MenuLinkItem
<StyledCollapseItem key={`${pageMeta}-${index}`}
data-testid={`favorite-list-item-${pageMeta.id}`} data-testid={`favorite-list-item-${pageMeta.id}`}
active={active} active={active}
ref={ref => { href={`/workspace/${workspaceId}/${pageMeta.id}`}
if (ref && active) { icon={icon}
ref.scrollIntoView({ behavior: 'smooth' }); >
} <span>{pageMeta.title || 'Untitled'}</span>
}} </MenuLinkItem>
onClick={() => {
if (active) {
return;
}
openPage(pageMeta.id);
}}
>
{record[pageMeta.id] === 'edgeless' ? (
<EdgelessIcon />
) : (
<PageIcon />
)}
<span>{pageMeta.title || 'Untitled'}</span>
</StyledCollapseItem>
</div>
); );
})} })}
{favoriteList.length === 0 && <EmptyItem />} {favoriteList.length === 0 && <EmptyItem />}
</MuiCollapse> </>
); );
}; };
@@ -1,66 +1 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks'; export * from './favorite-list';
import { ArrowDownSmallIcon, FavoriteIcon } from '@blocksuite/icons';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useCallback, useState } from 'react';
import type { AllWorkspace } from '../../../../shared';
import type { WorkSpaceSliderBarProps } from '../index';
import { StyledCollapseButton, StyledListItem } from '../shared-styles';
import { StyledLink } from '../style';
import FavoriteList from './favorite-list';
export const Favorite = ({
currentPath,
paths,
currentPageId,
openPage,
currentWorkspace,
}: Pick<
WorkSpaceSliderBarProps,
'currentPath' | 'paths' | 'currentPageId' | 'openPage'
> & {
currentWorkspace: AllWorkspace;
}) => {
const currentWorkspaceId = currentWorkspace.id;
const pageMeta = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const [showSubFavorite, setOpenSubFavorite] = useState(true);
const t = useAFFiNEI18N();
return (
<>
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.favorite(currentWorkspaceId))
}
>
<StyledLink
href={{
pathname: currentWorkspaceId && paths.favorite(currentWorkspaceId),
}}
>
<FavoriteIcon />
{t['Favorites']()}
</StyledLink>
<StyledCollapseButton
onClick={useCallback(() => {
setOpenSubFavorite(!showSubFavorite);
}, [showSubFavorite])}
collapse={showSubFavorite}
>
<ArrowDownSmallIcon />
</StyledCollapseButton>
</StyledListItem>
<FavoriteList
currentPageId={currentPageId}
showList={showSubFavorite}
openPage={openPage}
pageMeta={pageMeta}
/>
</>
);
};
export default Favorite;
@@ -1,13 +0,0 @@
export const Arrow = () => {
return (
<svg
width="6"
height="10"
viewBox="0 0 6 10"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0.354 9.22997C0.201333 9.0773 0.125 8.91764 0.125 8.75097C0.125 8.5843 0.201333 8.42464 0.354 8.27197L3.625 5.00097L0.354 1.72997C0.201333 1.5773 0.125 1.41764 0.125 1.25097C0.125 1.0843 0.201333 0.924636 0.354 0.771969C0.506667 0.619302 0.666333 0.542969 0.833 0.542969C0.999667 0.542969 1.15933 0.619302 1.312 0.771969L4.979 4.43897C5.06233 4.52164 5.125 4.61164 5.167 4.70897C5.20833 4.8063 5.229 4.90364 5.229 5.00097C5.229 5.0983 5.20833 5.19564 5.167 5.29297C5.125 5.3903 5.06233 5.4803 4.979 5.56297L1.312 9.22997C1.15933 9.38264 0.999667 9.45897 0.833 9.45897C0.666333 9.45897 0.506667 9.38264 0.354 9.22997Z" />
</svg>
);
};
@@ -1,12 +1,9 @@
import type { Page, PageMeta } from '@blocksuite/store'; import type { Page } from '@blocksuite/store';
import type { AllWorkspace } from '../../../shared'; import type { AllWorkspace } from '../../../shared';
export type FavoriteListProps = { export type FavoriteListProps = {
currentPageId: string | null; currentWorkspace: AllWorkspace;
openPage: (pageId: string) => void;
showList: boolean;
pageMeta: PageMeta[];
}; };
export type WorkSpaceSliderBarProps = { export type WorkSpaceSliderBarProps = {
@@ -1,31 +0,0 @@
import { IconButton } from '@affine/component';
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import { StyledRouteNavigationWrapper } from './shared-styles';
export const RouteNavigation = () => {
if (!environment.isDesktop) {
return <></>;
}
return (
<StyledRouteNavigationWrapper>
<IconButton
size="middle"
onClick={() => {
window.history.back();
}}
>
<ArrowLeftSmallIcon />
</IconButton>
<IconButton
size="middle"
onClick={() => {
window.history.forward();
}}
style={{ marginLeft: '32px' }}
>
<ArrowRightSmallIcon />
</IconButton>
</StyledRouteNavigationWrapper>
);
};
@@ -1,7 +1,14 @@
import { import {
AddPageButton,
AppSidebar, AppSidebar,
appSidebarOpenAtom, appSidebarOpenAtom,
ResizeIndicator, AppUpdaterButton,
CategoryDivider,
MenuLinkItem,
QuickSearchInput,
SidebarContainer,
SidebarScrollableContainer,
updateAvailableAtom,
} from '@affine/component/app-sidebar'; } from '@affine/component/app-sidebar';
import { config } from '@affine/env'; import { config } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -9,27 +16,18 @@ import { WorkspaceFlavour } from '@affine/workspace/type';
import { import {
DeleteTemporarilyIcon, DeleteTemporarilyIcon,
FolderIcon, FolderIcon,
PlusIcon,
SearchIcon,
SettingsIcon, SettingsIcon,
ShareIcon, ShareIcon,
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store'; import type { Page } from '@blocksuite/store';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import type { ReactElement, UIEvent } from 'react'; import type { ReactElement } from 'react';
import type React from 'react'; import type React from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect } from 'react';
import type { AllWorkspace } from '../../shared'; import type { AllWorkspace } from '../../shared';
import ChangeLog from '../pure/workspace-slider-bar/changeLog'; import ChangeLog from '../pure/workspace-slider-bar/changeLog';
import Favorite from '../pure/workspace-slider-bar/favorite'; import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list';
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'; import { WorkspaceSelector } from '../pure/workspace-slider-bar/WorkspaceSelector';
export type RootAppSidebarProps = { export type RootAppSidebarProps = {
@@ -37,7 +35,6 @@ export type RootAppSidebarProps = {
onOpenQuickSearchModal: () => void; onOpenQuickSearchModal: () => void;
onOpenWorkspaceListModal: () => void; onOpenWorkspaceListModal: () => void;
currentWorkspace: AllWorkspace | null; currentWorkspace: AllWorkspace | null;
currentPageId: string | null;
openPage: (pageId: string) => void; openPage: (pageId: string) => void;
createPage: () => Page; createPage: () => Page;
currentPath: string; currentPath: string;
@@ -50,6 +47,26 @@ export type RootAppSidebarProps = {
}; };
}; };
const RouteMenuLinkItem = ({
currentPath,
path,
icon,
children,
...props
}: {
currentPath: string; // todo: pass through useRouter?
path?: string | null;
icon: ReactElement;
children?: ReactElement;
} & React.HTMLAttributes<HTMLDivElement>) => {
const active = currentPath === path;
return (
<MenuLinkItem {...props} active={active} href={path ?? ''} icon={icon}>
{children}
</MenuLinkItem>
);
};
/** /**
* This is for the whole affine app sidebar. * This is for the whole affine app sidebar.
* This component wraps the app sidebar in `@affine/component` with logic and data. * This component wraps the app sidebar in `@affine/component` with logic and data.
@@ -58,7 +75,6 @@ export type RootAppSidebarProps = {
*/ */
export const RootAppSidebar = ({ export const RootAppSidebar = ({
currentWorkspace, currentWorkspace,
currentPageId,
openPage, openPage,
createPage, createPage,
currentPath, currentPath,
@@ -69,7 +85,6 @@ export const RootAppSidebar = ({
const currentWorkspaceId = currentWorkspace?.id || null; const currentWorkspaceId = currentWorkspace?.id || null;
const blockSuiteWorkspace = currentWorkspace?.blockSuiteWorkspace; const blockSuiteWorkspace = currentWorkspace?.blockSuiteWorkspace;
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [isScrollAtTop, setIsScrollAtTop] = useState(true);
const onClickNewPage = useCallback(async () => { const onClickNewPage = useCallback(async () => {
const page = await createPage(); const page = await createPage();
openPage(page.id); openPage(page.id);
@@ -80,160 +95,78 @@ export const RootAppSidebar = ({
window.apis?.ui.handleSidebarVisibilityChange(sidebarOpen); window.apis?.ui.handleSidebarVisibilityChange(sidebarOpen);
} }
}, [sidebarOpen]); }, [sidebarOpen]);
const [ref, setRef] = useState<HTMLElement | null>(null);
const handleQuickSearchButtonKeyDown = useCallback( const clientUpdateAvailable = useAtomValue(updateAvailableAtom);
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpenQuickSearchModal();
}
},
[onOpenQuickSearchModal]
);
return ( return (
<> <>
<AppSidebar <AppSidebar>
ref={setRef} <SidebarContainer>
footer={
<StyledNewPageButton
data-testid="new-page-button"
onClick={onClickNewPage}
>
<PlusIcon /> {t['New Page']()}
</StyledNewPageButton>
}
>
<StyledSliderBarInnerWrapper data-testid="sliderBar-inner">
<WorkspaceSelector <WorkspaceSelector
currentWorkspace={currentWorkspace} currentWorkspace={currentWorkspace}
onClick={onOpenWorkspaceListModal} onClick={onOpenWorkspaceListModal}
/> />
<ChangeLog /> <ChangeLog />
<StyledListItem <QuickSearchInput
data-testid="slider-bar-quick-search-button" data-testid="slider-bar-quick-search-button"
onClick={useCallback(() => { onClick={onOpenQuickSearchModal}
onOpenQuickSearchModal(); />
}, [onOpenQuickSearchModal])} <RouteMenuLinkItem
onKeyDown={handleQuickSearchButtonKeyDown} icon={<FolderIcon />}
currentPath={currentPath}
path={currentWorkspaceId && paths.all(currentWorkspaceId)}
> >
<div <span data-testid="all-pages">{t['All pages']()}</span>
role="button" </RouteMenuLinkItem>
tabIndex={0} <RouteMenuLinkItem
style={{
display: 'flex',
flex: 1,
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<SearchIcon />
{t['Quick search']()}
</div>
</StyledListItem>
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.setting(currentWorkspaceId))
}
data-testid="slider-bar-workspace-setting-button" data-testid="slider-bar-workspace-setting-button"
style={{ icon={<SettingsIcon />}
marginBottom: '16px', currentPath={currentPath}
}} path={currentWorkspaceId && paths.setting(currentWorkspaceId)}
> >
<StyledLink <span data-testid="settings">{t['Settings']()}</span>
href={{ </RouteMenuLinkItem>
pathname: </SidebarContainer>
currentWorkspaceId && paths.setting(currentWorkspaceId),
}} <SidebarScrollableContainer>
> <CategoryDivider label={t['Favorites']()} />
<SettingsIcon /> {blockSuiteWorkspace && (
<div>{t['Workspace Settings']()}</div> <FavoriteList currentWorkspace={currentWorkspace} />
</StyledLink> )}
</StyledListItem>
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.all(currentWorkspaceId))
}
>
<StyledLink
href={{
pathname: currentWorkspaceId && paths.all(currentWorkspaceId),
}}
>
<FolderIcon />
<span data-testid="all-pages">{t['All pages']()}</span>
</StyledLink>
</StyledListItem>
<StyledScrollWrapper
showTopBorder={!isScrollAtTop}
onScroll={(e: UIEvent<HTMLDivElement>) => {
(e.target as HTMLDivElement).scrollTop === 0
? setIsScrollAtTop(true)
: setIsScrollAtTop(false);
}}
>
{blockSuiteWorkspace && (
<Favorite
currentPath={currentPath}
paths={paths}
currentPageId={currentPageId}
openPage={openPage}
currentWorkspace={currentWorkspace}
/>
)}
</StyledScrollWrapper>
<div style={{ height: 16 }}></div>
{config.enableLegacyCloud && {config.enableLegacyCloud &&
(currentWorkspace?.flavour === WorkspaceFlavour.AFFINE && (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE &&
currentWorkspace.public ? ( currentWorkspace.public ? (
<StyledListItem> <RouteMenuLinkItem
<StyledLink icon={<ShareIcon />}
href={{ currentPath={currentPath}
pathname: path={currentWorkspaceId && paths.setting(currentWorkspaceId)}
currentWorkspaceId && paths.setting(currentWorkspaceId),
}}
>
<ShareIcon />
<span data-testid="Published-to-web">Published to web</span>
</StyledLink>
</StyledListItem>
) : (
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.shared(currentWorkspaceId))
}
> >
<StyledLink <span data-testid="Published-to-web">Published to web</span>
href={{ </RouteMenuLinkItem>
pathname: ) : (
currentWorkspaceId && paths.shared(currentWorkspaceId), <RouteMenuLinkItem
}} icon={<ShareIcon />}
> currentPath={currentPath}
<ShareIcon /> path={currentWorkspaceId && paths.shared(currentWorkspaceId)}
<span data-testid="shared-pages">{t['Shared Pages']()}</span> >
</StyledLink> <span data-testid="shared-pages">{t['Shared Pages']()}</span>
</StyledListItem> </RouteMenuLinkItem>
))} ))}
<StyledListItem
active={ <CategoryDivider label={t['others']()} />
currentPath === <RouteMenuLinkItem
(currentWorkspaceId && paths.trash(currentWorkspaceId)) icon={<DeleteTemporarilyIcon />}
} currentPath={currentPath}
path={currentWorkspaceId && paths.trash(currentWorkspaceId)}
> >
<StyledLink <span data-testid="trash-page">{t['Trash']()}</span>
href={{ </RouteMenuLinkItem>
pathname: currentWorkspaceId && paths.trash(currentWorkspaceId), </SidebarScrollableContainer>
}} <SidebarContainer>
> {clientUpdateAvailable && <AppUpdaterButton />}
<DeleteTemporarilyIcon /> {t['Trash']()} <AddPageButton onClick={onClickNewPage} />
</StyledLink> </SidebarContainer>
</StyledListItem>
</StyledSliderBarInnerWrapper>
</AppSidebar> </AppSidebar>
<ResizeIndicator targetElement={ref} />
</> </>
); );
}; };
+9 -3
View File
@@ -1,3 +1,7 @@
import {
appSidebarOpenAtom,
appSidebarResizingAtom,
} from '@affine/component/app-sidebar';
import { import {
AppContainer, AppContainer,
MainContainer, MainContainer,
@@ -330,17 +334,19 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
setOpenQuickSearchModalAtom(true); setOpenQuickSearchModalAtom(true);
}, [setOpenQuickSearchModalAtom]); }, [setOpenQuickSearchModalAtom]);
const resizing = useAtomValue(appSidebarResizingAtom);
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
return ( return (
<> <>
<Head> <Head>
<title>{title}</title> <title>{title}</title>
</Head> </Head>
<AppContainer> <AppContainer resizing={resizing}>
<RootAppSidebar <RootAppSidebar
isPublicWorkspace={isPublicWorkspace} isPublicWorkspace={isPublicWorkspace}
onOpenQuickSearchModal={handleOpenQuickSearchModal} onOpenQuickSearchModal={handleOpenQuickSearchModal}
currentWorkspace={currentWorkspace} currentWorkspace={currentWorkspace}
currentPageId={currentPageId}
onOpenWorkspaceListModal={handleOpenWorkspaceListModal} onOpenWorkspaceListModal={handleOpenWorkspaceListModal}
openPage={useCallback( openPage={useCallback(
(pageId: string) => { (pageId: string) => {
@@ -353,7 +359,7 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
currentPath={router.asPath.split('?')[0]} currentPath={router.asPath.split('?')[0]}
paths={isPublicWorkspace ? publicPathGenerator : pathGenerator} paths={isPublicWorkspace ? publicPathGenerator : pathGenerator}
/> />
<MainContainer> <MainContainer sidebarOpen={sidebarOpen}>
<Suspense fallback={<WorkspaceFallback />}>{children}</Suspense> <Suspense fallback={<WorkspaceFallback />}>{children}</Suspense>
<ToolContainer> <ToolContainer>
{/* fixme(himself65): remove this */} {/* fixme(himself65): remove this */}
@@ -0,0 +1,30 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
background: 'var(--affine-white-30)',
alignItems: 'center',
borderRadius: '8px',
border: '1px solid var(--affine-black-10)',
fontSize: 'var(--affine-font-sm)',
width: '100%',
height: '52px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 24px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
},
},
});
export const icon = style({
marginRight: '18px',
color: 'var(--affine-icon-color)',
fontSize: '24px',
});
export const spacer = style({
flex: 1,
});
@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { AddPageButton } from '.';
export default {
title: 'Components/AppSidebar/AddPageButton',
component: AddPageButton,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<AddPageButton onClick={() => alert('opened')} />
</main>
);
};
@@ -0,0 +1,31 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { PlusIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import * as styles from './index.css';
interface AddPageButtonProps {
onClick?: () => void;
className?: string;
style?: React.CSSProperties;
}
// Although it is called an input, it is actually a button.
export function AddPageButton({
onClick,
className,
style,
}: AddPageButtonProps) {
const t = useAFFiNEI18N();
return (
<button
data-testid="new-page-button"
style={style}
className={clsx([styles.root, className])}
onClick={onClick}
>
<PlusIcon className={styles.icon} /> {t['New Page']()}
</button>
);
}
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 122 116">
<path id="b" stroke="#fff" stroke-linecap="round" stroke-width="0" d="M17.9256 115C17.434 111.774 13.1701 104.086 13.4282 95.6465C13.6862 87.207 18.6628 76.0721 17.9256 64.3628C17.1883 52.6535 8.7772 35.9512 9.00452 25.3907C9.23185 14.8302 16.2114 5.06512 17.9256 1"/>
<path id="d" stroke="#fff" stroke-linecap="round" stroke-width="0" d="M84.1628 115C85.2376 112.055 94.5618 98.8394 93.9975 91.1338C93.4332 83.4281 82.5505 73.2615 84.1628 62.5704C85.775 51.8793 96.4803 35.4248 95.9832 25.7826C95.4861 16.1404 87.9113 4.71163 84.1628 1"/>
<path id="f" stroke="#fff" stroke-linecap="round" stroke-width="0" d="M37.0913 115C37.9604 111.921 44.4347 99.4545 45.3816 92.9773C48.9305 68.7011 35.7877 73.9552 37.0913 62.7781C38.3949 51.6011 47.3889 36.9895 46.9869 26.9091C46.585 16.8286 40.1222 4.88034 37.0913 1"/>
<path id="h" stroke="#fff" stroke-linecap="round" stroke-width="0" d="M112.443 115C111.698 112.235 108.25 106.542 107.715 93.7582C107.241 82.4286 107.229 83.9543 112.443 66.1429C116.085 44.0408 100.661 42.5908 101.006 33.539C101.35 24.4871 109.843 4.48439 112.443 1"/>
<g>
<circle r="1.5" fill="rgba(96, 70, 254, 0.3)">
<animateMotion dur="10s" repeatCount="indefinite">
<mpath href="#b" />
</animateMotion>
</circle>
</g>
<g>
<circle r="1" fill="rgba(96, 70, 254, 0.3)" fill-opacity="1" shape-rendering="crispEdges">
<animateMotion dur="8s" repeatCount="indefinite">
<mpath href="#d" />
</animateMotion>
</circle>
</g>
<g>
<circle r=".5" fill="rgba(96, 70, 254, 0.3)" fill-opacity="1" shape-rendering="crispEdges">
<animateMotion dur="4s" repeatCount="indefinite">
<mpath href="#f" />
</animateMotion>
</circle>
</g>
<g>
<circle r=".8" fill="rgba(96, 70, 254, 0.3)" fill-opacity="1" shape-rendering="crispEdges">
<animateMotion dur="6s" repeatCount="indefinite">
<mpath href="#h" />
</animateMotion>
</circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,132 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
background: 'var(--affine-white-30)',
alignItems: 'center',
borderRadius: '8px',
border: '1px solid var(--affine-black-10)',
fontSize: 'var(--affine-font-sm)',
width: '100%',
height: '52px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 12px',
position: 'relative',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
},
'&:before': {
content: "''",
position: 'absolute',
top: '-3px',
right: '-3px',
width: '8px',
height: '8px',
backgroundColor: 'var(--affine-primary-color)',
borderRadius: '50%',
zIndex: 1,
opacity: 1,
transition: '0.3s ease',
},
},
vars: {
'--svg-dot-animation': `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 122 116'%3E%3Cpath id='b' stroke='%23fff' stroke-linecap='round' stroke-width='0' d='M17.9256 115C17.434 111.774 13.1701 104.086 13.4282 95.6465C13.6862 87.207 18.6628 76.0721 17.9256 64.3628C17.1883 52.6535 8.7772 35.9512 9.00452 25.3907C9.23185 14.8302 16.2114 5.06512 17.9256 1'/%3E%3Cpath id='d' stroke='%23fff' stroke-linecap='round' stroke-width='0' d='M84.1628 115C85.2376 112.055 94.5618 98.8394 93.9975 91.1338C93.4332 83.4281 82.5505 73.2615 84.1628 62.5704C85.775 51.8793 96.4803 35.4248 95.9832 25.7826C95.4861 16.1404 87.9113 4.71163 84.1628 1'/%3E%3Cpath id='f' stroke='%23fff' stroke-linecap='round' stroke-width='0' d='M37.0913 115C37.9604 111.921 44.4347 99.4545 45.3816 92.9773C48.9305 68.7011 35.7877 73.9552 37.0913 62.7781C38.3949 51.6011 47.3889 36.9895 46.9869 26.9091C46.585 16.8286 40.1222 4.88034 37.0913 1'/%3E%3Cpath id='h' stroke='%23fff' stroke-linecap='round' stroke-width='0' d='M112.443 115C111.698 112.235 108.25 106.542 107.715 93.7582C107.241 82.4286 107.229 83.9543 112.443 66.1429C116.085 44.0408 100.661 42.5908 101.006 33.539C101.35 24.4871 109.843 4.48439 112.443 1'/%3E%3Cg%3E%3Ccircle r='1.5' fill='rgba(96, 70, 254, 0.3)'%3E%3CanimateMotion dur='10s' repeatCount='indefinite'%3E%3Cmpath href='%23b' /%3E%3C/animateMotion%3E%3C/circle%3E%3C/g%3E%3Cg%3E%3Ccircle r='1' fill='rgba(96, 70, 254, 0.3)' fill-opacity='1' shape-rendering='crispEdges'%3E%3CanimateMotion dur='8s' repeatCount='indefinite'%3E%3Cmpath href='%23d' /%3E%3C/animateMotion%3E%3C/circle%3E%3C/g%3E%3Cg%3E%3Ccircle r='.5' fill='rgba(96, 70, 254, 0.3)' fill-opacity='1' shape-rendering='crispEdges'%3E%3CanimateMotion dur='4s' repeatCount='indefinite'%3E%3Cmpath href='%23f' /%3E%3C/animateMotion%3E%3C/circle%3E%3C/g%3E%3Cg%3E%3Ccircle r='.8' fill='rgba(96, 70, 254, 0.3)' fill-opacity='1' shape-rendering='crispEdges'%3E%3CanimateMotion dur='6s' repeatCount='indefinite'%3E%3Cmpath href='%23h' /%3E%3C/animateMotion%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")`,
},
});
export const icon = style({
marginRight: '18px',
color: 'var(--affine-primary-color)',
fontSize: '24px',
});
export const particles = style({
background: `var(--svg-dot-animation), var(--svg-dot-animation)`,
backgroundRepeat: 'no-repeat, repeat',
backgroundPosition: 'center, center top 100%',
backgroundSize: '100%, 130%',
WebkitMaskImage:
'linear-gradient(to top, transparent, black, black, transparent)',
width: '100%',
height: '100%',
position: 'absolute',
left: 0,
});
export const particlesBefore = style({
content: '""',
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
background: `var(--svg-dot-animation), var(--svg-dot-animation), var(--svg-dot-animation)`,
backgroundRepeat: 'no-repeat, repeat, repeat',
backgroundPosition: 'center, center top 100%, center center',
backgroundSize: '100% 120%, 150%, 120%',
filter: 'blur(1px)',
willChange: 'filter',
});
export const installLabel = style({
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
width: '100%',
height: '100%',
fontSize: 'var(--affine-font-sm)',
whiteSpace: 'nowrap',
});
export const installLabelNormal = style([
installLabel,
{
selectors: {
[`${root}:hover &`]: {
display: 'none',
},
},
},
]);
export const installLabelHover = style([
installLabel,
{
display: 'none',
selectors: {
[`${root}:hover &`]: {
display: 'flex',
},
},
},
]);
export const halo = style({
overflow: 'hidden',
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
':before': {
content: '""',
display: 'block',
width: '60%',
height: '40%',
position: 'absolute',
top: '80%',
left: '50%',
background:
'linear-gradient(180deg, rgba(50, 26, 206, 0.1) 10%, rgba(50, 26, 206, 0.35) 30%, rgba(84, 56, 255, 1) 50%)',
filter: 'blur(10px) saturate(1.2)',
transform: 'translateX(-50%) translateY(calc(0 * 1%)) scale(0)',
transition: '0.3s ease',
willChange: 'filter',
},
selectors: {
'&:hover:before': {
transform: 'translateX(-50%) translateY(calc(-70 * 1%)) scale(1)',
},
},
});
@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { AppUpdaterButton } from '.';
export default {
title: 'Components/AppSidebar/AppUpdaterButton',
component: AppUpdaterButton,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<AppUpdaterButton />
</main>
);
};
@@ -0,0 +1,36 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ResetIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import * as styles from './index.css';
interface AddPageButtonProps {
className?: string;
style?: React.CSSProperties;
}
// Although it is called an input, it is actually a button.
export function AppUpdaterButton({ className, style }: AddPageButtonProps) {
const t = useAFFiNEI18N();
return (
<button
data-testid="new-page-button"
style={style}
className={clsx([styles.root, className])}
onClick={() => {
window.apis?.updater.updateClient();
}}
>
<div className={styles.particles} aria-hidden="true"></div>
<span className={styles.halo} aria-hidden="true"></span>
<div className={clsx([styles.installLabelNormal])}>
<span>{t['Update Available']()}</span>
</div>
<div className={clsx([styles.installLabelHover])}>
<ResetIcon className={styles.icon} />
<span>{t['Restart Install Client Update']()}</span>
</div>
</button>
);
}
@@ -0,0 +1,16 @@
import { style } from '@vanilla-extract/css';
export const root = style({
fontSize: 'var(--affine-font-xs)',
height: '16px',
userSelect: 'none',
selectors: {
'&:not(:first-of-type)': {
marginTop: '10px',
},
},
});
export const label = style({
color: 'var(--affine-black-30)',
});
@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { CategoryDivider } from '.';
export default {
title: 'Components/AppSidebar/CategoryDivider',
component: CategoryDivider,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<CategoryDivider label="Favorites" />
</main>
);
};
@@ -0,0 +1,17 @@
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import * as styles from './index.css';
interface CategoryDividerProps extends PropsWithChildren {
label: string;
}
export function CategoryDivider({ label, children }: CategoryDividerProps) {
return (
<div className={clsx([styles.root])}>
<div className={styles.label}>{label}</div>
{children}
</div>
);
}
@@ -6,7 +6,6 @@ export const fallbackStyle = style({
}); });
export const fallbackHeaderStyle = style({ export const fallbackHeaderStyle = style({
padding: '0 6px',
height: '58px', height: '58px',
width: '100%', width: '100%',
display: 'flex', display: 'flex',
@@ -5,27 +5,26 @@ import { createVar, style } from '@vanilla-extract/css';
export const floatingMaxWidth = 768; export const floatingMaxWidth = 768;
export const navWidthVar = createVar('nav-width'); export const navWidthVar = createVar('nav-width');
export const navStyle = style({ export const navWrapperStyle = style({
vars: {
[navWidthVar]: '256px',
},
position: 'relative', position: 'relative',
backgroundColor: 'var(--affine-background-secondary-color)',
width: navWidthVar, width: navWidthVar,
minWidth: navWidthVar, minWidth: navWidthVar,
height: '100%', height: '100%',
display: 'flex', backgroundColor: 'var(--affine-background-secondary-color)',
flexDirection: 'column',
transition: 'margin-left .3s, width .3s',
zIndex: parseInt(baseTheme.zIndexModal),
borderRight: '1px solid var(--affine-border-color)',
'@media': { '@media': {
[`(max-width: ${floatingMaxWidth}px)`]: { [`(max-width: ${floatingMaxWidth}px)`]: {
position: 'absolute', position: 'absolute',
width: `calc(10vw + ${navWidthVar})`, width: `calc(${navWidthVar})`,
zIndex: 2,
selectors: { selectors: {
'&[data-open="false"]': { '&[data-open="false"]': {
marginLeft: `calc((10vw + ${navWidthVar}) * -1)`, marginLeft: `calc((10vw + ${navWidthVar}) * -1)`,
}, },
'&[data-is-macos-electron="true"]': { '&[data-is-macos-electron="true"]': {
backgroundColor: 'var(--affine-background-secondary-color)', backgroundColor: 'var(--affine-background-primary-color)',
}, },
}, },
}, },
@@ -37,19 +36,30 @@ export const navStyle = style({
'&[data-is-macos-electron="true"]': { '&[data-is-macos-electron="true"]': {
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
'&[data-enable-animation="true"]': {
transition: 'margin-left .3s, width .3s',
},
}, },
vars: { });
[navWidthVar]: '256px',
}, export const navStyle = style({
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
zIndex: parseInt(baseTheme.zIndexModal),
borderRight: '1px solid transparent',
}); });
export const navHeaderStyle = style({ export const navHeaderStyle = style({
flex: '0 0 auto', flex: '0 0 auto',
height: '52px', height: '52px',
padding: '0px 16px 0px 10px', padding: '0px 16px',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
gap: '32px',
'@media': { '@media': {
[`(max-width: ${floatingMaxWidth}px)`]: { [`(max-width: ${floatingMaxWidth}px)`]: {
selectors: { selectors: {
@@ -69,15 +79,13 @@ export const navHeaderStyle = style({
export const navBodyStyle = style({ export const navBodyStyle = style({
flex: '1 1 auto', flex: '1 1 auto',
}); height: 'calc(100% - 52px)',
display: 'flex',
export const navFooterStyle = style({ flexDirection: 'column',
flex: '0 0 auto',
borderTop: '1px solid var(--affine-border-color)',
}); });
export const sidebarButtonStyle = style({ export const sidebarButtonStyle = style({
width: '32px', width: 'auto',
height: '32px', height: '32px',
color: 'var(--affine-icon-color)', color: 'var(--affine-icon-color)',
}); });
@@ -91,7 +99,7 @@ export const sidebarFloatMaskStyle = style({
left: 0, left: 0,
right: '100%', right: '100%',
bottom: 0, bottom: 0,
zIndex: parseInt(baseTheme.zIndexModal) - 1, zIndex: 1,
background: 'var(--affine-background-modal-color)', background: 'var(--affine-background-modal-color)',
'@media': { '@media': {
[`(max-width: ${floatingMaxWidth}px)`]: { [`(max-width: ${floatingMaxWidth}px)`]: {
@@ -105,66 +113,3 @@ export const sidebarFloatMaskStyle = style({
}, },
}, },
}); });
export const haloStyle = style({
overflow: 'hidden',
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
':before': {
content: '""',
display: 'block',
width: '60%',
height: '40%',
position: 'absolute',
top: '80%',
left: '50%',
background:
'linear-gradient(180deg, rgba(50, 26, 206, 0.1) 10%, rgba(50, 26, 206, 0.35) 30%, rgba(84, 56, 255, 1) 50%)',
filter: 'blur(10px) saturate(1.2)',
transform: 'translateX(-50%) translateY(calc(0 * 1%)) scale(0)',
transition: '0.3s ease',
willChange: 'filter',
},
selectors: {
'&:hover:before': {
transform: 'translateX(-50%) translateY(calc(-70 * 1%)) scale(1)',
},
},
});
export const updaterButtonStyle = style({});
export const particlesStyle = style({
background: `var(--svg-animation), var(--svg-animation)`,
backgroundRepeat: 'no-repeat, repeat',
backgroundPosition: 'center, center top 100%',
backgroundSize: '100%, 130%',
WebkitMaskImage:
'linear-gradient(to top, transparent, black, black, transparent)',
width: '100%',
height: '100%',
position: 'absolute',
});
export const particlesBefore = style({
content: '""',
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
background: `var(--svg-animation), var(--svg-animation), var(--svg-animation)`,
backgroundRepeat: 'no-repeat, repeat, repeat',
backgroundPosition: 'center, center top 100%, center center',
backgroundSize: '100% 120%, 150%, 120%',
filter: 'blur(1px)',
willChange: 'filter',
});
export const installLabelStyle = style({
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
paddingLeft: '8px',
});
@@ -1,3 +1,4 @@
import { atom } from 'jotai';
import { atomWithObservable } from 'jotai/utils'; import { atomWithObservable } from 'jotai/utils';
import { atomWithStorage } from 'jotai/utils'; import { atomWithStorage } from 'jotai/utils';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -7,6 +8,7 @@ export const appSidebarOpenAtom = atomWithStorage(
APP_SIDEBAR_OPEN, APP_SIDEBAR_OPEN,
undefined as boolean | undefined undefined as boolean | undefined
); );
export const appSidebarResizingAtom = atom(false);
export const appSidebarWidthAtom = atomWithStorage( export const appSidebarWidthAtom = atomWithStorage(
'app-sidebar-width', 'app-sidebar-width',
256 /* px */ 256 /* px */
@@ -1,17 +1,23 @@
import { IconButton } from '@affine/component'; import { IconButton } from '@affine/component';
import { SidebarIcon } from '@blocksuite/icons'; import {
DeleteTemporarilyIcon,
SettingsIcon,
SidebarIcon,
} from '@blocksuite/icons';
import type { Meta, StoryFn } from '@storybook/react'; import type { Meta, StoryFn } from '@storybook/react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import { useState } from 'react';
import { import { AppSidebar, AppSidebarFallback, appSidebarOpenAtom } from '.';
AppSidebar, import { AddPageButton } from './add-page-button';
AppSidebarFallback, import { CategoryDivider } from './category-divider';
appSidebarOpenAtom,
ResizeIndicator,
} from '.';
import { navHeaderStyle, sidebarButtonStyle } from './index.css'; import { navHeaderStyle, sidebarButtonStyle } from './index.css';
import { MenuLinkItem } from './menu-item';
import { QuickSearchInput } from './quick-search-input';
import {
SidebarContainer,
SidebarScrollableContainer,
} from './sidebar-containers';
export default { export default {
title: 'Components/AppSidebar', title: 'Components/AppSidebar',
@@ -23,7 +29,7 @@ const Container = ({ children }: PropsWithChildren) => (
style={{ style={{
position: 'relative', position: 'relative',
width: '100vw', width: '100vw',
height: '600px', height: 'calc(100vh - 40px)',
overflow: 'hidden', overflow: 'hidden',
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
@@ -51,17 +57,12 @@ const Main = () => {
</div> </div>
); );
}; };
const Footer = () => <div>Add Page</div>;
export const Default: StoryFn = () => { export const Default: StoryFn = () => {
const [ref, setRef] = useState<HTMLElement | null>(null);
return ( return (
<> <>
<Container> <Container>
<AppSidebar footer={<Footer />} ref={setRef}> <AppSidebar />
Test
</AppSidebar>
<ResizeIndicator targetElement={ref} />
<Main /> <Main />
</Container> </Container>
</> </>
@@ -76,3 +77,68 @@ export const Fallback = () => {
</Container> </Container>
); );
}; };
export const WithItems: StoryFn = () => {
return (
<Container>
<AppSidebar>
<SidebarContainer>
<QuickSearchInput />
<div style={{ height: '20px' }} />
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
</SidebarContainer>
<SidebarScrollableContainer>
<CategoryDivider label="Favorites" />
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Settings
</MenuLinkItem>
<CategoryDivider label="Others" />
<MenuLinkItem
icon={<DeleteTemporarilyIcon />}
href="/test"
onClick={() => alert('opened')}
>
Trash
</MenuLinkItem>
</SidebarScrollableContainer>
<SidebarContainer>
<AddPageButton />
</SidebarContainer>
</AppSidebar>
<Main />
</Container>
);
};
@@ -1,156 +1,102 @@
import { Button } from '@affine/component';
import { getEnvironment } from '@affine/env'; import { getEnvironment } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
ResetIcon,
SidebarIcon,
} from '@blocksuite/icons';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import type { PropsWithChildren, ReactElement } from 'react'; import type { PropsWithChildren, ReactElement } from 'react';
import type { ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react';
import { forwardRef, useCallback, useEffect } from 'react';
import { IconButton } from '../../ui/button/icon-button';
import { import {
floatingMaxWidth, floatingMaxWidth,
haloStyle,
installLabelStyle,
navBodyStyle, navBodyStyle,
navFooterStyle,
navHeaderStyle,
navStyle, navStyle,
navWidthVar, navWidthVar,
particlesStyle, navWrapperStyle,
sidebarButtonStyle,
sidebarFloatMaskStyle, sidebarFloatMaskStyle,
updaterButtonStyle,
} from './index.css'; } from './index.css';
import { import {
APP_SIDEBAR_OPEN, APP_SIDEBAR_OPEN,
appSidebarOpenAtom, appSidebarOpenAtom,
appSidebarResizingAtom,
appSidebarWidthAtom, appSidebarWidthAtom,
updateAvailableAtom, updateAvailableAtom,
} from './index.jotai'; } from './index.jotai';
import { ResizeIndicator } from './resize-indicator';
import { SidebarHeader } from './sidebar-header';
export { appSidebarOpenAtom }; export type AppSidebarProps = PropsWithChildren;
export type AppSidebarProps = PropsWithChildren<{ function useEnableAnimation() {
footer?: ReactNode | undefined; const [enable, setEnable] = useState(false);
}>; useEffect(() => {
setTimeout(() => {
setEnable(true);
}, 500);
}, []);
return enable;
}
export const AppSidebar = forwardRef<HTMLElement, AppSidebarProps>( export function AppSidebar(props: AppSidebarProps): ReactElement {
function AppSidebar(props, forwardedRef): ReactElement { const [open, setOpen] = useAtom(appSidebarOpenAtom);
const [open, setOpen] = useAtom(appSidebarOpenAtom); const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
const clientUpdateAvailable = useAtomValue(updateAvailableAtom); const initialRender = open === undefined;
const t = useAFFiNEI18N();
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
const initialRender = open === undefined;
const handleSidebarOpen = useCallback(() => { const isResizing = useAtomValue(appSidebarResizingAtom);
setOpen(open => !open); const navRef = useRef<HTMLDivElement>(null);
}, [setOpen]);
useEffect(() => { useEffect(() => {
if ( if (open === undefined && localStorage.getItem(APP_SIDEBAR_OPEN) === null) {
open === undefined && // give the initial value,
localStorage.getItem(APP_SIDEBAR_OPEN) === null // so that the sidebar can be closed on mobile by default
) { const { matches } = window.matchMedia(
// give the initial value, `(min-width: ${floatingMaxWidth}px)`
// so that the sidebar can be closed on mobile by default );
const { matches } = window.matchMedia( setOpen(matches);
`(min-width: ${floatingMaxWidth}px)`
);
setOpen(matches);
}
}, [open, setOpen]);
const environment = getEnvironment();
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
if (initialRender) {
// avoid the UI flash
return <div />;
} }
return ( }, [open, setOpen]);
<>
<nav
className={navStyle}
ref={forwardedRef}
style={assignInlineVars({
[navWidthVar]: `${appSidebarWidth}px`,
})}
data-testid="app-sidebar"
data-open={open}
data-is-macos-electron={isMacosDesktop}
>
<div
className={navHeaderStyle}
data-is-macos-electron={isMacosDesktop}
data-open={open}
>
{isMacosDesktop && (
<>
<IconButton
size="middle"
onClick={() => {
window.history.back();
}}
>
<ArrowLeftSmallIcon />
</IconButton>
<IconButton
size="middle"
onClick={() => {
window.history.forward();
}}
style={{ marginLeft: '32px' }}
>
<ArrowRightSmallIcon />
</IconButton>
</>
)}
<IconButton
data-testid="app-sidebar-arrow-button-collapse"
className={sidebarButtonStyle}
onClick={handleSidebarOpen}
>
<SidebarIcon width={24} height={24} />
</IconButton>
</div>
<div className={navBodyStyle}>{props.children}</div>
{clientUpdateAvailable && (
<Button
onClick={() => {
window.apis?.updater.updateClient();
}}
noBorder
className={updaterButtonStyle}
type={'light'}
>
<div className={particlesStyle} aria-hidden="true"></div>
<span className={haloStyle} aria-hidden="true"></span>
<div className={installLabelStyle}>
<ResetIcon />
<span>{t['Restart Install Client Update']()}</span>
</div>
</Button>
)}
<div className={navFooterStyle}>{props.footer}</div>
</nav>
<div
data-testid="app-sidebar-float-mask"
data-open={open}
className={sidebarFloatMaskStyle}
onClick={() => setOpen(false)}
/>
</>
);
}
);
// disable animation to avoid UI flash
const enableAnimation = useEnableAnimation();
const environment = getEnvironment();
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
if (initialRender) {
// avoid the UI flash
return <div />;
}
return (
<>
<div
style={assignInlineVars({
[navWidthVar]: `${appSidebarWidth}px`,
})}
className={navWrapperStyle}
data-open={open}
data-is-macos-electron={isMacosDesktop}
data-enable-animation={enableAnimation && !isResizing}
>
<nav className={navStyle} ref={navRef} data-testid="app-sidebar">
<SidebarHeader />
<div className={navBodyStyle} data-testid="sliderBar-inner">
{props.children}
</div>
</nav>
<ResizeIndicator targetElement={navRef.current} />
</div>
<div
data-testid="app-sidebar-float-mask"
data-open={open}
className={sidebarFloatMaskStyle}
onClick={() => setOpen(false)}
/>
</>
);
}
export * from './add-page-button';
export * from './app-updater-button';
export * from './category-divider';
export { AppSidebarFallback } from './fallback'; export { AppSidebarFallback } from './fallback';
export type { ResizeIndicatorProps } from './resize-indicator'; export * from './menu-item';
export { ResizeIndicator } from './resize-indicator'; export * from './quick-search-input';
export * from './sidebar-containers';
export { appSidebarOpenAtom, appSidebarResizingAtom, updateAvailableAtom };
@@ -0,0 +1,48 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
alignItems: 'center',
borderRadius: '4px',
width: '100%',
minHeight: '30px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 12px',
fontSize: 'var(--affine-font-sm)',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
},
'&[data-active="true"]': {
color: 'var(--affine-primary-color)',
background: 'var(--affine-hover-color)',
},
'&[data-disabled="true"]': {
cursor: 'default',
color: 'var(--affine-text-secondary-color)',
pointerEvents: 'none',
},
},
});
export const content = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const icon = style({
marginRight: '14px',
color: 'var(--affine-icon-color)',
fontSize: '18px',
});
export const spacer = style({
flex: 1,
});
export const linkItemRoot = style({
color: 'inherit',
display: 'contents',
});
@@ -0,0 +1,34 @@
import { SettingsIcon } from '@blocksuite/icons';
import type { Meta, StoryFn } from '@storybook/react';
import { MenuItem, MenuLinkItem } from '.';
export default {
title: 'Components/AppSidebar/MenuItem',
component: MenuItem,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<MenuItem icon={<SettingsIcon />} onClick={() => alert('opened')}>
Normal Item
</MenuItem>
<MenuLinkItem
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Normal Link Item
</MenuLinkItem>
<MenuLinkItem
active
icon={<SettingsIcon />}
href="/test"
onClick={() => alert('opened')}
>
Primary Item
</MenuLinkItem>
</main>
);
};
@@ -0,0 +1,47 @@
import clsx from 'clsx';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import React from 'react';
import * as styles from './index.css';
interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
icon?: React.ReactElement;
active?: boolean;
disabled?: boolean;
}
interface MenuLinkItemProps extends MenuItemProps, Pick<LinkProps, 'href'> {}
export function MenuItem({
onClick,
icon,
active,
children,
disabled,
...props
}: MenuItemProps) {
return (
<div
{...props}
className={clsx([styles.root, props.className])}
onClick={onClick}
data-active={active}
data-disabled={disabled}
>
{icon &&
React.cloneElement(icon, {
className: clsx([styles.icon, icon.props.className]),
})}
<div className={styles.content}>{children}</div>
</div>
);
}
export function MenuLinkItem({ href, ...props }: MenuLinkItemProps) {
return (
<Link href={href} className={styles.linkItemRoot}>
<MenuItem {...props}></MenuItem>
</Link>
);
}
@@ -0,0 +1,35 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
background: 'var(--affine-white-10)',
alignItems: 'center',
borderRadius: '8px',
border: '1px solid var(--affine-black-10)',
fontSize: 'var(--affine-font-sm)',
width: '100%',
height: '36px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 12px',
margin: '12px 0',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
},
},
});
export const icon = style({
marginRight: '14px',
color: 'var(--affine-icon-color)',
});
export const spacer = style({
flex: 1,
});
export const shortcutHint = style({
color: 'var(--affine-black-30)',
fontSize: 'var(--affine-font-base)',
});
@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { QuickSearchInput } from '.';
export default {
title: 'Components/AppSidebar/QuickSearchInput',
component: QuickSearchInput,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<QuickSearchInput onClick={() => alert('opened')} />
</main>
);
};
@@ -0,0 +1,32 @@
import { getEnvironment } from '@affine/env/config';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SearchIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import * as styles from './index.css';
interface QuickSearchInputProps extends React.HTMLAttributes<HTMLDivElement> {
onClick?: () => void;
}
// Although it is called an input, it is actually a button.
export function QuickSearchInput({ onClick, ...props }: QuickSearchInputProps) {
const t = useAFFiNEI18N();
const environment = getEnvironment();
const isMac = environment.isBrowser && environment.isMacOs;
return (
<div
{...props}
className={clsx([props.className, styles.root])}
onClick={onClick}
>
<SearchIcon className={styles.icon} />
{t['Quick search']()}
<div className={styles.spacer} />
<div className={styles.shortcutHint}>
{isMac ? ' ⌘ + K' : ' Ctrl + K'}
</div>
</div>
);
}
@@ -1,16 +1,14 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { navWidthVar } from '../index.css'; export const resizerContainer = style({
export const spacerStyle = style({
position: 'absolute', position: 'absolute',
left: navWidthVar, right: 0,
top: 0, top: 0,
bottom: 0, bottom: 0,
width: '7px', width: '10px',
height: '100%', height: '100%',
borderLeft: '1px solid var(--affine-border-color)', zIndex: 'calc(var(--affine-z-index-modal) + 1)',
zIndex: 'calc(var(--affine-z-index-modal) - 1)', transform: 'translateX(50%)',
backgroundColor: 'transparent', backgroundColor: 'transparent',
opacity: 0, opacity: 0,
cursor: 'col-resize', cursor: 'col-resize',
@@ -36,3 +34,14 @@ export const spacerStyle = style({
}, },
}, },
}); });
export const resizerInner = style({
position: 'absolute',
height: '100%',
width: '2px',
left: '3px',
backgroundColor: 'var(--affine-border-color)',
selectors: {
[`${resizerContainer}:hover &`]: {},
},
});
@@ -1,56 +1,38 @@
import type { Instance } from '@popperjs/core'; import { useAtom, useSetAtom } from 'jotai';
import { createPopper } from '@popperjs/core';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { useCallback, useLayoutEffect, useState } from 'react';
import { import {
useCallback, appSidebarOpenAtom,
useDeferredValue, appSidebarResizingAtom,
useEffect, appSidebarWidthAtom,
useRef, } from '../index.jotai';
useState, import * as styles from './index.css';
} from 'react';
import { appSidebarOpenAtom, appSidebarWidthAtom } from '../index.jotai'; type ResizeIndicatorProps = {
import { spacerStyle } from './index.css';
export type ResizeIndicatorProps = {
targetElement: HTMLElement | null; targetElement: HTMLElement | null;
}; };
export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => { export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => {
const ref = useRef<HTMLDivElement>(null);
const popperRef = useRef<Instance | null>(null);
const setWidth = useSetAtom(appSidebarWidthAtom); const setWidth = useSetAtom(appSidebarWidthAtom);
const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom); const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useAtom(appSidebarResizingAtom);
useEffect(() => {
if (ref.current) {
if (props.targetElement) {
const popper = createPopper(props.targetElement, ref.current, {
placement: 'right',
});
popperRef.current = popper;
return () => {
popper.destroy();
popperRef.current = null;
};
}
}
}, [props.targetElement]);
const sidebarWidth = useDeferredValue(useAtomValue(appSidebarWidthAtom)); const [anchorLeft, setAnchorLeft] = useState(0);
useEffect(() => {
if (popperRef.current) { useLayoutEffect(() => {
popperRef.current.update(); if (!props.targetElement) return;
} const { left } = props.targetElement.getBoundingClientRect();
}, [sidebarWidth]); setAnchorLeft(left);
}, [props.targetElement]);
const onResizeStart = useCallback(() => { const onResizeStart = useCallback(() => {
let resized = false; let resized = false;
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
const newWidth = Math.min(480, Math.max(e.clientX, 256)); if (!props.targetElement) return;
const newWidth = Math.min(480, Math.max(e.clientX - anchorLeft, 256));
setWidth(newWidth); setWidth(newWidth);
setIsResizing(true); setIsResizing(true);
resized = true; resized = true;
@@ -64,24 +46,28 @@ export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => {
if (!resized) { if (!resized) {
setSidebarOpen(o => !o); setSidebarOpen(o => !o);
} }
if (popperRef.current) {
popperRef.current.update();
}
setIsResizing(false); setIsResizing(false);
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
}, },
{ once: true } { once: true }
); );
}, [setSidebarOpen, setWidth]); }, [
anchorLeft,
props.targetElement,
setIsResizing,
setSidebarOpen,
setWidth,
]);
return ( return (
<div <div
ref={ref} className={styles.resizerContainer}
className={spacerStyle}
data-testid="app-sidebar-resizer" data-testid="app-sidebar-resizer"
data-resizing={isResizing} data-resizing={isResizing}
data-open={sidebarOpen} data-open={sidebarOpen}
onMouseDown={onResizeStart} onMouseDown={onResizeStart}
/> >
<div className={styles.resizerInner} />
</div>
); );
}; };
@@ -0,0 +1,75 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const baseContainer = style({
padding: '12px 16px',
display: 'flex',
flexFlow: 'column nowrap',
rowGap: '8px',
});
export const scrollableContainerRoot = style({
flex: '1 1 auto',
overflowY: 'hidden',
vars: {
'--scrollbar-width': '10px',
},
transition: 'all .3s .2s',
borderTop: '1px solid transparent',
selectors: {
'&[data-has-scroll-top="true"]': {
boxShadow: 'inset 0 8px 8px -8px var(--affine-black-10)',
},
},
});
export const scrollableViewport = style({
height: '100%',
});
globalStyle(`${scrollableViewport} > div`, {
maxWidth: '100%',
display: 'block !important',
});
export const scrollableContainer = style([
baseContainer,
{
height: '100%',
},
]);
export const scrollbar = style({
display: 'flex',
flexDirection: 'column',
userSelect: 'none',
touchAction: 'none',
padding: '0 2px',
width: 'var(--scrollbar-width)',
height: '100%',
opacity: 1,
transition: 'opacity .15s',
selectors: {
'&[data-state="hidden"]': {
opacity: 0,
},
},
});
export const scrollbarThumb = style({
position: 'relative',
background: 'var(--affine-black-30)',
borderRadius: '4px',
selectors: {
'&::before': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '100%',
height: '100%',
minWidth: '44px',
minHeight: '44px',
},
},
});
@@ -0,0 +1,61 @@
import * as ScrollArea from '@radix-ui/react-scroll-area';
import clsx from 'clsx';
import { type PropsWithChildren, useEffect, useRef, useState } from 'react';
import * as styles from './index.css';
export function SidebarContainer({ children }: PropsWithChildren) {
return <div className={clsx([styles.baseContainer])}>{children}</div>;
}
function useHasScrollTop() {
const ref = useRef<HTMLDivElement>(null);
const [hasScrollTop, setHasScrollTop] = useState(false);
useEffect(() => {
if (!ref.current) {
return;
}
const container = ref.current;
function updateScrollTop() {
if (container) {
const hasScrollTop = container.scrollTop > 0;
setHasScrollTop(hasScrollTop);
}
}
container.addEventListener('scroll', updateScrollTop);
updateScrollTop();
return () => {
container.removeEventListener('scroll', updateScrollTop);
};
}, []);
return [hasScrollTop, ref] as const;
}
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
const [hasScrollTop, ref] = useHasScrollTop();
return (
<ScrollArea.Root
data-has-scroll-top={hasScrollTop}
className={styles.scrollableContainerRoot}
>
<ScrollArea.Viewport
className={clsx([styles.scrollableViewport])}
ref={ref}
>
<div className={clsx([styles.scrollableContainer])}>{children}</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
forceMount
orientation="vertical"
className={styles.scrollbar}
>
<ScrollArea.Thumb className={styles.scrollbarThumb} />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);
}
@@ -0,0 +1,52 @@
import { IconButton } from '@affine/component';
import { getEnvironment } from '@affine/env/config';
import {
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
SidebarIcon,
} from '@blocksuite/icons';
import { useAtom } from 'jotai';
import { navHeaderStyle, sidebarButtonStyle } from '../index.css';
import { appSidebarOpenAtom } from '../index.jotai';
export const SidebarHeader = () => {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
const environment = getEnvironment();
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
return (
<div
className={navHeaderStyle}
data-is-macos-electron={isMacosDesktop}
data-open={open}
>
{isMacosDesktop && (
<>
<IconButton
size="middle"
onClick={() => {
window.history.back();
}}
>
<ArrowLeftSmallIcon />
</IconButton>
<IconButton
size="middle"
onClick={() => {
window.history.forward();
}}
>
<ArrowRightSmallIcon />
</IconButton>
</>
)}
<IconButton
data-testid="app-sidebar-arrow-button-collapse"
className={sidebarButtonStyle}
onClick={() => setOpen(open => !open)}
>
<SidebarIcon width={24} height={24} />
</IconButton>
</div>
);
};
@@ -51,7 +51,7 @@ const slideOut2 = keyframes({
export const changeLogWrapperSlideInStyle = style({ export const changeLogWrapperSlideInStyle = style({
width: 'calc(100% + 4px)', width: 'calc(100% + 4px)',
height: '0px', flexShrink: 0,
animation: `${slideIn} 1s ease-in-out forwards`, animation: `${slideIn} 1s ease-in-out forwards`,
display: 'flex', display: 'flex',
justifyContent: 'flex-start', justifyContent: 'flex-start',
@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css'; import { globalStyle, style } from '@vanilla-extract/css';
import { breakpoints } from '../../styles/mui-theme'; import { breakpoints } from '../../styles/mui-theme';
export const appStyle = style({ export const appStyle = style({
@@ -13,6 +13,14 @@ export const appStyle = style({
'&[data-is-resizing="true"]': { '&[data-is-resizing="true"]': {
cursor: 'col-resize', cursor: 'col-resize',
}, },
'&[data-noise-background="true"]:before': {
content: '""',
position: 'absolute',
inset: 0,
opacity: 'var(--affine-noise-opacity)',
backgroundSize: '25%',
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.25' numOctaves='10' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
},
}, },
vars: { vars: {
'--affine-editor-width': '686px', '--affine-editor-width': '686px',
@@ -26,11 +34,35 @@ export const appStyle = style({
}, },
}); });
globalStyle(`html[data-theme="light"] ${appStyle}`, {
vars: {
'--affine-noise-opacity': '0.25',
},
});
globalStyle(`html[data-theme="dark"] ${appStyle}`, {
vars: {
'--affine-noise-opacity': '0.1',
},
});
export const mainContainerStyle = style({ export const mainContainerStyle = style({
position: 'relative', position: 'relative',
flexGrow: 1, flexGrow: 1,
maxWidth: '100%', maxWidth: '100%',
zIndex: 0,
backgroundColor: 'var(--affine-background-primary-color)', backgroundColor: 'var(--affine-background-primary-color)',
selectors: {
'&[data-is-desktop="true"]': {
margin: '8px 8px 8px 8px',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'var(--affine-shadow-1)',
},
'&[data-is-desktop="true"][data-is-sidebar-open="true"]': {
marginLeft: '2px',
},
},
}); });
export const toolStyle = style({ export const toolStyle = style({
@@ -9,8 +9,13 @@ export type WorkspaceRootProps = PropsWithChildren<{
}>; }>;
export const AppContainer = (props: WorkspaceRootProps): ReactElement => { export const AppContainer = (props: WorkspaceRootProps): ReactElement => {
const noisyBackground = environment.isDesktop && environment.isMacOs;
return ( return (
<div className={appStyle} data-is-resizing={props.resizing}> <div
className={appStyle}
data-noise-background={noisyBackground}
data-is-resizing={props.resizing}
>
{props.children} {props.children}
</div> </div>
); );
@@ -18,12 +23,15 @@ export const AppContainer = (props: WorkspaceRootProps): ReactElement => {
export type MainContainerProps = PropsWithChildren<{ export type MainContainerProps = PropsWithChildren<{
className?: string; className?: string;
sidebarOpen?: boolean;
}>; }>;
export const MainContainer = (props: MainContainerProps): ReactElement => { export const MainContainer = (props: MainContainerProps): ReactElement => {
return ( return (
<div <div
className={clsx(mainContainerStyle, 'main-container', props.className)} className={clsx(mainContainerStyle, 'main-container', props.className)}
data-is-desktop={environment.isDesktop}
data-is-sidebar-open={props.sidebarOpen}
> >
{props.children} {props.children}
</div> </div>
+1
View File
@@ -12,6 +12,7 @@
}, },
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./config": "./src/config.ts",
"./constant": "./src/constant.ts", "./constant": "./src/constant.ts",
"./blocksuite": "./src/blocksuite.ts" "./blocksuite": "./src/blocksuite.ts"
}, },
+2
View File
@@ -18,6 +18,7 @@
"No item": "No item", "No item": "No item",
"Import": "Import", "Import": "Import",
"Trash": "Trash", "Trash": "Trash",
"others": "Others",
"New Page": "New Page", "New Page": "New Page",
"New Keyword Page": "New '{{query}}' page", "New Keyword Page": "New '{{query}}' page",
"Find 0 result": "Find 0 result", "Find 0 result": "Find 0 result",
@@ -231,6 +232,7 @@
"Synced with AFFiNE Cloud": "Synced with AFFiNE Cloud", "Synced with AFFiNE Cloud": "Synced with AFFiNE Cloud",
"Recent": "Recent", "Recent": "Recent",
"Successfully deleted": "Successfully deleted", "Successfully deleted": "Successfully deleted",
"Update Available": "Update available",
"Restart Install Client Update": "Restart to install update", "Restart Install Client Update": "Restart to install update",
"Add Workspace": "Add Workspace", "Add Workspace": "Add Workspace",
"Add Workspace Hint": "Select where you already have", "Add Workspace Hint": "Select where you already have",
@@ -18,7 +18,7 @@ test('New a page , then delete it in all pages, permanently delete it', async ({
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to restore'); await getBlockSuiteEditorTitle(page).fill('this is a new page to restore');
const newPageId = page.url().split('/').reverse()[0]; const newPageId = page.url().split('/').reverse()[0];
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to restore', name: 'this is a new page to restore',
}); });
@@ -36,7 +36,7 @@ test('New a page , then delete it in all pages, permanently delete it', async ({
await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('link', { name: 'Trash' }).click(); await page.getByTestId('trash-page').click();
// permanently delete it // permanently delete it
await page await page
.getByTestId('more-actions-' + newPageId) .getByTestId('more-actions-' + newPageId)
@@ -18,7 +18,7 @@ test.skip('New a page ,then open it and export html', async ({ page }) => {
await page await page
.getByPlaceholder('Title') .getByPlaceholder('Title')
.fill('this is a new page to export html content'); .fill('this is a new page to export html content');
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to export html content', name: 'this is a new page to export html content',
@@ -46,7 +46,7 @@ test.skip('New a page ,then open it and export markdown', async ({ page }) => {
await page await page
.getByPlaceholder('Title') .getByPlaceholder('Title')
.fill('this is a new page to export markdown content'); .fill('this is a new page to export markdown content');
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to export markdown content', name: 'this is a new page to export markdown content',
}); });
@@ -16,7 +16,7 @@ test('New a page and open it ,then favorite it', async ({ page }) => {
await newPage(page); await newPage(page);
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite'); await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite');
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to favorite', name: 'this is a new page to favorite',
}); });
@@ -55,7 +55,7 @@ test('Cancel favorite', async ({ page }) => {
await newPage(page); await newPage(page);
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite'); await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite');
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to favorite', name: 'this is a new page to favorite',
}); });
@@ -68,14 +68,13 @@ test('Cancel favorite', async ({ page }) => {
await favoriteBtn.click(); await favoriteBtn.click();
// expect it in favorite list // expect it in favorite list
await page.getByRole('link', { name: 'Favorites' }).click();
expect( expect(
page.getByRole('cell', { name: 'this is a new page to favorite' }) page.getByRole('cell', { name: 'this is a new page to favorite' })
).not.toBeUndefined(); ).not.toBeUndefined();
// cancel favorite // cancel favorite
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const box = await page const box = await page
.getByRole('cell', { name: 'this is a new page to favorite' }) .getByRole('cell', { name: 'this is a new page to favorite' })
@@ -86,7 +85,6 @@ test('Cancel favorite', async ({ page }) => {
await page.getByTestId('favorited-icon').click(); await page.getByTestId('favorited-icon').click();
// expect it not in favorite list // expect it not in favorite list
await page.getByRole('link', { name: 'Favorites' }).click();
expect( expect(
page.getByText( page.getByText(
'Tips: Click Add to Favorites/Trash and the page will appear here.' 'Tips: Click Add to Favorites/Trash and the page will appear here.'
@@ -8,7 +8,6 @@ import {
newPage, newPage,
waitMarkdownImported, waitMarkdownImported,
} from '../libs/page-logic'; } from '../libs/page-logic';
import { assertCurrentWorkspaceFlavour } from '../libs/workspace';
test('Show favorite items in sidebar', async ({ page }) => { test('Show favorite items in sidebar', async ({ page }) => {
await openHomePage(page); await openHomePage(page);
@@ -17,7 +16,7 @@ test('Show favorite items in sidebar', async ({ page }) => {
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite'); await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite');
const newPageId = page.url().split('/').reverse()[0]; const newPageId = page.url().split('/').reverse()[0];
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to favorite', name: 'this is a new page to favorite',
}); });
@@ -41,7 +40,7 @@ test('Show favorite items in favorite list', async ({ page }) => {
await newPage(page); await newPage(page);
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite'); await getBlockSuiteEditorTitle(page).fill('this is a new page to favorite');
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to favorite', name: 'this is a new page to favorite',
}); });
@@ -52,16 +51,11 @@ test('Show favorite items in favorite list', async ({ page }) => {
const favoriteBtn = page.getByTestId('editor-option-menu-favorite'); const favoriteBtn = page.getByTestId('editor-option-menu-favorite');
await favoriteBtn.click(); await favoriteBtn.click();
await page.getByRole('link', { name: 'Favorites' }).click(); await page.getByTestId('all-pages').click();
expect( expect(
page.getByRole('cell', { name: 'this is a new page to favorite' }) page.getByRole('cell', { name: 'this is a new page to favorite' })
).not.toBeUndefined(); ).not.toBeUndefined();
await page.getByRole('cell').getByRole('button').nth(0).click(); await page.getByRole('cell').getByRole('button').nth(0).click();
expect(
await page
.getByText('Click Add to Favorites and the page will appear here.')
.isVisible()
).toBe(true);
await assertCurrentWorkspaceFlavour('local', page);
}); });
+1 -1
View File
@@ -25,7 +25,7 @@ test('click btn bew page and find it in all pages', async ({ page }) => {
await newPage(page); await newPage(page);
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page'); await getBlockSuiteEditorTitle(page).fill('this is a new page');
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { name: 'this is a new page' }); const cell = page.getByRole('cell', { name: 'this is a new page' });
expect(cell).not.toBeUndefined(); expect(cell).not.toBeUndefined();
await assertCurrentWorkspaceFlavour('local', page); await assertCurrentWorkspaceFlavour('local', page);
@@ -18,7 +18,7 @@ test('click btn bew page and open in tab', async ({ page }) => {
const newPageUrl = page.url(); const newPageUrl = page.url();
const newPageId = page.url().split('/').reverse()[0]; const newPageId = page.url().split('/').reverse()[0];
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
await page await page
.getByTestId('more-actions-' + newPageId) .getByTestId('more-actions-' + newPageId)
@@ -18,7 +18,7 @@ test('New a page , then delete it in all pages, restore it', async ({
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to restore'); await getBlockSuiteEditorTitle(page).fill('this is a new page to restore');
const newPageId = page.url().split('/').reverse()[0]; const newPageId = page.url().split('/').reverse()[0];
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to restore', name: 'this is a new page to restore',
}); });
@@ -36,7 +36,7 @@ test('New a page , then delete it in all pages, restore it', async ({
await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('link', { name: 'Trash' }).click(); await page.getByTestId('trash-page').click();
await page.waitForTimeout(50); await page.waitForTimeout(50);
const trashPage = page.url(); const trashPage = page.url();
// restore it // restore it
@@ -48,7 +48,7 @@ test('New a page , then delete it in all pages, restore it', async ({
// stay in trash page // stay in trash page
expect(page.url()).toBe(trashPage); expect(page.url()).toBe(trashPage);
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const restoreCell = page.getByRole('cell', { const restoreCell = page.getByRole('cell', {
name: 'this is a new page to restore', name: 'this is a new page to restore',
}); });
@@ -16,7 +16,7 @@ test('New a page ,then open it and show delete modal', async ({ page }) => {
await newPage(page); await newPage(page);
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to delete'); await getBlockSuiteEditorTitle(page).fill('this is a new page to delete');
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to delete', name: 'this is a new page to delete',
}); });
@@ -40,7 +40,7 @@ test('New a page ,then go to all pages and show delete modal', async ({
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to delete'); await getBlockSuiteEditorTitle(page).fill('this is a new page to delete');
const newPageId = page.url().split('/').reverse()[0]; const newPageId = page.url().split('/').reverse()[0];
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to delete', name: 'this is a new page to delete',
}); });
@@ -18,7 +18,7 @@ test('New a page , then delete it in all pages, finally find it in trash', async
await getBlockSuiteEditorTitle(page).click(); await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to delete'); await getBlockSuiteEditorTitle(page).fill('this is a new page to delete');
const newPageId = page.url().split('/').reverse()[0]; const newPageId = page.url().split('/').reverse()[0];
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const cell = page.getByRole('cell', { const cell = page.getByRole('cell', {
name: 'this is a new page to delete', name: 'this is a new page to delete',
}); });
@@ -36,7 +36,7 @@ test('New a page , then delete it in all pages, finally find it in trash', async
await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('link', { name: 'Trash' }).click(); await page.getByTestId('trash-page').click();
expect( expect(
page.getByRole('cell', { name: 'this is a new page to delete' }) page.getByRole('cell', { name: 'this is a new page to delete' })
).not.toBeUndefined(); ).not.toBeUndefined();
+1 -1
View File
@@ -48,7 +48,7 @@ test('Click right-bottom corner change log icon', async ({ page }) => {
); );
await page.waitForTimeout(50); await page.waitForTimeout(50);
expect(await editorRightBottomChangeLog.isVisible()).toEqual(true); expect(await editorRightBottomChangeLog.isVisible()).toEqual(true);
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByTestId('all-pages').click();
const normalRightBottomChangeLog = page.locator( const normalRightBottomChangeLog = page.locator(
'[data-testid=right-bottom-change-log-icon]' '[data-testid=right-bottom-change-log-icon]'
); );