mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat: new sidebar (app shell) styles (#2303)
This commit is contained in:
@@ -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({
|
||||
padding: '0 6px',
|
||||
height: '58px',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 { 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 &`]: {},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
width: 'calc(100% + 4px)',
|
||||
height: '0px',
|
||||
flexShrink: 0,
|
||||
animation: `${slideIn} 1s ease-in-out forwards`,
|
||||
display: 'flex',
|
||||
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';
|
||||
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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user