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

This commit is contained in:
Peng Xiao
2023-05-12 11:13:51 +08:00
committed by GitHub
parent 0fbed5d9d6
commit 10b4558947
54 changed files with 1166 additions and 642 deletions

View File

@@ -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,
});

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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)',
},
},
});

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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)',
});

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -6,7 +6,6 @@ export const fallbackStyle = style({
});
export const fallbackHeaderStyle = style({
padding: '0 6px',
height: '58px',
width: '100%',
display: 'flex',

View File

@@ -5,27 +5,26 @@ import { createVar, style } from '@vanilla-extract/css';
export const floatingMaxWidth = 768;
export const navWidthVar = createVar('nav-width');
export const navStyle = style({
export const navWrapperStyle = style({
vars: {
[navWidthVar]: '256px',
},
position: 'relative',
backgroundColor: 'var(--affine-background-secondary-color)',
width: navWidthVar,
minWidth: navWidthVar,
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'margin-left .3s, width .3s',
zIndex: parseInt(baseTheme.zIndexModal),
borderRight: '1px solid var(--affine-border-color)',
backgroundColor: 'var(--affine-background-secondary-color)',
'@media': {
[`(max-width: ${floatingMaxWidth}px)`]: {
position: 'absolute',
width: `calc(10vw + ${navWidthVar})`,
width: `calc(${navWidthVar})`,
zIndex: 2,
selectors: {
'&[data-open="false"]': {
marginLeft: `calc((10vw + ${navWidthVar}) * -1)`,
},
'&[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"]': {
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({
flex: '0 0 auto',
height: '52px',
padding: '0px 16px 0px 10px',
padding: '0px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '32px',
'@media': {
[`(max-width: ${floatingMaxWidth}px)`]: {
selectors: {
@@ -69,15 +79,13 @@ export const navHeaderStyle = style({
export const navBodyStyle = style({
flex: '1 1 auto',
});
export const navFooterStyle = style({
flex: '0 0 auto',
borderTop: '1px solid var(--affine-border-color)',
height: 'calc(100% - 52px)',
display: 'flex',
flexDirection: 'column',
});
export const sidebarButtonStyle = style({
width: '32px',
width: 'auto',
height: '32px',
color: 'var(--affine-icon-color)',
});
@@ -91,7 +99,7 @@ export const sidebarFloatMaskStyle = style({
left: 0,
right: '100%',
bottom: 0,
zIndex: parseInt(baseTheme.zIndexModal) - 1,
zIndex: 1,
background: 'var(--affine-background-modal-color)',
'@media': {
[`(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',
});

View File

@@ -1,3 +1,4 @@
import { atom } from 'jotai';
import { atomWithObservable } from 'jotai/utils';
import { atomWithStorage } from 'jotai/utils';
import { Observable } from 'rxjs';
@@ -7,6 +8,7 @@ export const appSidebarOpenAtom = atomWithStorage(
APP_SIDEBAR_OPEN,
undefined as boolean | undefined
);
export const appSidebarResizingAtom = atom(false);
export const appSidebarWidthAtom = atomWithStorage(
'app-sidebar-width',
256 /* px */

View File

@@ -1,17 +1,23 @@
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 { useAtom } from 'jotai';
import type { PropsWithChildren } from 'react';
import { useState } from 'react';
import {
AppSidebar,
AppSidebarFallback,
appSidebarOpenAtom,
ResizeIndicator,
} from '.';
import { AppSidebar, AppSidebarFallback, appSidebarOpenAtom } from '.';
import { AddPageButton } from './add-page-button';
import { CategoryDivider } from './category-divider';
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 {
title: 'Components/AppSidebar',
@@ -23,7 +29,7 @@ const Container = ({ children }: PropsWithChildren) => (
style={{
position: 'relative',
width: '100vw',
height: '600px',
height: 'calc(100vh - 40px)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'row',
@@ -51,17 +57,12 @@ const Main = () => {
</div>
);
};
const Footer = () => <div>Add Page</div>;
export const Default: StoryFn = () => {
const [ref, setRef] = useState<HTMLElement | null>(null);
return (
<>
<Container>
<AppSidebar footer={<Footer />} ref={setRef}>
Test
</AppSidebar>
<ResizeIndicator targetElement={ref} />
<AppSidebar />
<Main />
</Container>
</>
@@ -76,3 +77,68 @@ export const Fallback = () => {
</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>
);
};

View File

@@ -1,156 +1,102 @@
import { Button } from '@affine/component';
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 { useAtom, useAtomValue } from 'jotai';
import type { PropsWithChildren, ReactElement } from 'react';
import type { ReactNode } from 'react';
import { forwardRef, useCallback, useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import { IconButton } from '../../ui/button/icon-button';
import {
floatingMaxWidth,
haloStyle,
installLabelStyle,
navBodyStyle,
navFooterStyle,
navHeaderStyle,
navStyle,
navWidthVar,
particlesStyle,
sidebarButtonStyle,
navWrapperStyle,
sidebarFloatMaskStyle,
updaterButtonStyle,
} from './index.css';
import {
APP_SIDEBAR_OPEN,
appSidebarOpenAtom,
appSidebarResizingAtom,
appSidebarWidthAtom,
updateAvailableAtom,
} from './index.jotai';
import { ResizeIndicator } from './resize-indicator';
import { SidebarHeader } from './sidebar-header';
export { appSidebarOpenAtom };
export type AppSidebarProps = PropsWithChildren;
export type AppSidebarProps = PropsWithChildren<{
footer?: ReactNode | undefined;
}>;
function useEnableAnimation() {
const [enable, setEnable] = useState(false);
useEffect(() => {
setTimeout(() => {
setEnable(true);
}, 500);
}, []);
return enable;
}
export const AppSidebar = forwardRef<HTMLElement, AppSidebarProps>(
function AppSidebar(props, forwardedRef): ReactElement {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
const clientUpdateAvailable = useAtomValue(updateAvailableAtom);
const t = useAFFiNEI18N();
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
const initialRender = open === undefined;
export function AppSidebar(props: AppSidebarProps): ReactElement {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
const initialRender = open === undefined;
const handleSidebarOpen = useCallback(() => {
setOpen(open => !open);
}, [setOpen]);
const isResizing = useAtomValue(appSidebarResizingAtom);
const navRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (
open === undefined &&
localStorage.getItem(APP_SIDEBAR_OPEN) === null
) {
// give the initial value,
// so that the sidebar can be closed on mobile by default
const { matches } = window.matchMedia(
`(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 />;
useEffect(() => {
if (open === undefined && localStorage.getItem(APP_SIDEBAR_OPEN) === null) {
// give the initial value,
// so that the sidebar can be closed on mobile by default
const { matches } = window.matchMedia(
`(min-width: ${floatingMaxWidth}px)`
);
setOpen(matches);
}
return (
<>
<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)}
/>
</>
);
}
);
}, [open, setOpen]);
// 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 type { ResizeIndicatorProps } from './resize-indicator';
export { ResizeIndicator } from './resize-indicator';
export * from './menu-item';
export * from './quick-search-input';
export * from './sidebar-containers';
export { appSidebarOpenAtom, appSidebarResizingAtom, updateAvailableAtom };

View File

@@ -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',
});

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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)',
});

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -1,16 +1,14 @@
import { style } from '@vanilla-extract/css';
import { navWidthVar } from '../index.css';
export const spacerStyle = style({
export const resizerContainer = style({
position: 'absolute',
left: navWidthVar,
right: 0,
top: 0,
bottom: 0,
width: '7px',
width: '10px',
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',
opacity: 0,
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 &`]: {},
},
});

View File

@@ -1,56 +1,38 @@
import type { Instance } from '@popperjs/core';
import { createPopper } from '@popperjs/core';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import type { ReactElement } from 'react';
import { useCallback, useLayoutEffect, useState } from 'react';
import {
useCallback,
useDeferredValue,
useEffect,
useRef,
useState,
} from 'react';
appSidebarOpenAtom,
appSidebarResizingAtom,
appSidebarWidthAtom,
} from '../index.jotai';
import * as styles from './index.css';
import { appSidebarOpenAtom, appSidebarWidthAtom } from '../index.jotai';
import { spacerStyle } from './index.css';
export type ResizeIndicatorProps = {
type ResizeIndicatorProps = {
targetElement: HTMLElement | null;
};
export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => {
const ref = useRef<HTMLDivElement>(null);
const popperRef = useRef<Instance | null>(null);
const setWidth = useSetAtom(appSidebarWidthAtom);
const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom);
const [isResizing, setIsResizing] = useState(false);
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 [isResizing, setIsResizing] = useAtom(appSidebarResizingAtom);
const sidebarWidth = useDeferredValue(useAtomValue(appSidebarWidthAtom));
useEffect(() => {
if (popperRef.current) {
popperRef.current.update();
}
}, [sidebarWidth]);
const [anchorLeft, setAnchorLeft] = useState(0);
useLayoutEffect(() => {
if (!props.targetElement) return;
const { left } = props.targetElement.getBoundingClientRect();
setAnchorLeft(left);
}, [props.targetElement]);
const onResizeStart = useCallback(() => {
let resized = false;
function onMouseMove(e: MouseEvent) {
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);
setIsResizing(true);
resized = true;
@@ -64,24 +46,28 @@ export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => {
if (!resized) {
setSidebarOpen(o => !o);
}
if (popperRef.current) {
popperRef.current.update();
}
setIsResizing(false);
document.removeEventListener('mousemove', onMouseMove);
},
{ once: true }
);
}, [setSidebarOpen, setWidth]);
}, [
anchorLeft,
props.targetElement,
setIsResizing,
setSidebarOpen,
setWidth,
]);
return (
<div
ref={ref}
className={spacerStyle}
className={styles.resizerContainer}
data-testid="app-sidebar-resizer"
data-resizing={isResizing}
data-open={sidebarOpen}
onMouseDown={onResizeStart}
/>
>
<div className={styles.resizerInner} />
</div>
);
};

View File

@@ -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',
},
},
});

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -51,7 +51,7 @@ const slideOut2 = keyframes({
export const changeLogWrapperSlideInStyle = style({
width: 'calc(100% + 4px)',
height: '0px',
flexShrink: 0,
animation: `${slideIn} 1s ease-in-out forwards`,
display: 'flex',
justifyContent: 'flex-start',

View File

@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { breakpoints } from '../../styles/mui-theme';
export const appStyle = style({
@@ -13,6 +13,14 @@ export const appStyle = style({
'&[data-is-resizing="true"]': {
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: {
'--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({
position: 'relative',
flexGrow: 1,
maxWidth: '100%',
zIndex: 0,
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({

View File

@@ -9,8 +9,13 @@ export type WorkspaceRootProps = PropsWithChildren<{
}>;
export const AppContainer = (props: WorkspaceRootProps): ReactElement => {
const noisyBackground = environment.isDesktop && environment.isMacOs;
return (
<div className={appStyle} data-is-resizing={props.resizing}>
<div
className={appStyle}
data-noise-background={noisyBackground}
data-is-resizing={props.resizing}
>
{props.children}
</div>
);
@@ -18,12 +23,15 @@ export const AppContainer = (props: WorkspaceRootProps): ReactElement => {
export type MainContainerProps = PropsWithChildren<{
className?: string;
sidebarOpen?: boolean;
}>;
export const MainContainer = (props: MainContainerProps): ReactElement => {
return (
<div
className={clsx(mainContainerStyle, 'main-container', props.className)}
data-is-desktop={environment.isDesktop}
data-is-sidebar-open={props.sidebarOpen}
>
{props.children}
</div>

View File

@@ -12,6 +12,7 @@
},
"exports": {
".": "./src/index.ts",
"./config": "./src/config.ts",
"./constant": "./src/constant.ts",
"./blocksuite": "./src/blocksuite.ts"
},

View File

@@ -18,6 +18,7 @@
"No item": "No item",
"Import": "Import",
"Trash": "Trash",
"others": "Others",
"New Page": "New Page",
"New Keyword Page": "New '{{query}}' page",
"Find 0 result": "Find 0 result",
@@ -231,6 +232,7 @@
"Synced with AFFiNE Cloud": "Synced with AFFiNE Cloud",
"Recent": "Recent",
"Successfully deleted": "Successfully deleted",
"Update Available": "Update available",
"Restart Install Client Update": "Restart to install update",
"Add Workspace": "Add Workspace",
"Add Workspace Hint": "Select where you already have",