refactor(core): side bar resizing (#5280)

Rewrite sidebar panel using a customized react-resizable-panels version that supports sidebar pixel sizing (not using flex percentages).

Now the left & right sidebar using the same `ResizePanel` impl.

fix https://github.com/toeverything/AFFiNE/issues/5271
fix TOV-163
fix TOV-146
fix TOV-168
fix TOV-109
fix TOV-165
This commit is contained in:
Peng Xiao
2023-12-13 07:52:01 +00:00
parent 2a9a6855f4
commit ce64685176
16 changed files with 406 additions and 386 deletions

View File

@@ -1,21 +1,12 @@
import { baseTheme } from '@toeverything/theme';
import type { ComplexStyleRule } from '@vanilla-extract/css';
import { createVar, style } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
export const floatingMaxWidth = 768;
export const navWidthVar = createVar('nav-width');
export const navWrapperStyle = style({
vars: {
[navWidthVar]: '256px',
},
position: 'relative',
width: navWidthVar,
minWidth: navWidthVar,
height: '100%',
zIndex: 3,
paddingBottom: '8px',
backgroundColor: 'transparent',
backgroundColor: 'var(--affine-background-primary-color)',
'@media': {
print: {
display: 'none',
@@ -23,23 +14,7 @@ export const navWrapperStyle = style({
},
},
selectors: {
'&[data-is-floating="true"]': {
position: 'absolute',
width: `calc(${navWidthVar})`,
zIndex: 4,
backgroundColor: 'var(--affine-background-primary-color)',
},
'&[data-open="false"]': {
marginLeft: `calc(${navWidthVar} * -1)`,
},
'&[data-enable-animation="true"]': {
transition: 'margin-left .3s .05s, width .3s .05s',
},
'&[data-is-floating="false"].has-background': {
backgroundColor: 'var(--affine-white-60)',
borderRight: '1px solid var(--affine-border-color)',
},
'&.has-border': {
'&[data-has-border=true]': {
borderRight: '1px solid var(--affine-border-color)',
},
},
@@ -63,7 +38,6 @@ export const navStyle = style({
height: '100%',
display: 'flex',
flexDirection: 'column',
zIndex: parseInt(baseTheme.zIndexModal),
});
export const navHeaderStyle = style({

View File

@@ -1,18 +1,16 @@
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useAtom, useAtomValue } from 'jotai';
import { debounce } from 'lodash-es';
import type { PropsWithChildren, ReactElement } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useEffect } from 'react';
import { Skeleton } from '../../ui/skeleton';
import { ResizePanel } from '../resize-panel';
import { fallbackHeaderStyle, fallbackStyle } from './fallback.css';
import {
floatingMaxWidth,
navBodyStyle,
navHeaderStyle,
navStyle,
navWidthVar,
navWrapperStyle,
sidebarFloatMaskStyle,
} from './index.css';
@@ -23,7 +21,6 @@ import {
appSidebarResizingAtom,
appSidebarWidthAtom,
} from './index.jotai';
import { ResizeIndicator } from './resize-indicator';
import type { SidebarHeaderProps } from './sidebar-header';
import { SidebarHeader } from './sidebar-header';
@@ -33,30 +30,19 @@ export type AppSidebarProps = PropsWithChildren<
}
>;
function useEnableAnimation() {
const [enable, setEnable] = useState(false);
useEffect(() => {
window.setTimeout(() => {
setEnable(true);
}, 500);
}, []);
return enable;
}
export type History = {
stack: string[];
current: number;
};
const MAX_WIDTH = 480;
const MIN_WIDTH = 256;
export function AppSidebar(props: AppSidebarProps): ReactElement {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
const [appSidebarFloating, setAppSidebarFloating] = useAtom(
appSidebarFloatingAtom
);
const isResizing = useAtomValue(appSidebarResizingAtom);
const navRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useAtom(appSidebarWidthAtom);
const [floating, setFloating] = useAtom(appSidebarFloatingAtom);
const [resizing, setResizing] = useAtom(appSidebarResizingAtom);
useEffect(() => {
function onResize() {
@@ -64,7 +50,7 @@ export function AppSidebar(props: AppSidebarProps): ReactElement {
`(max-width: ${floatingMaxWidth}px)`
).matches;
const isOverflowWidth = window.matchMedia(
`(max-width: ${appSidebarWidth / 0.4}px)`
`(max-width: ${width / 0.4}px)`
).matches;
const isFloating = isFloatingMaxWidth || isOverflowWidth;
if (
@@ -75,7 +61,7 @@ export function AppSidebar(props: AppSidebarProps): ReactElement {
// so that the sidebar can be closed on mobile by default
setOpen(!isFloating);
}
setAppSidebarFloating(isFloating && !!open);
setFloating(isFloating && !!open);
}
const dOnResize = debounce(onResize, 50);
@@ -83,33 +69,34 @@ export function AppSidebar(props: AppSidebarProps): ReactElement {
return () => {
window.removeEventListener('resize', dOnResize);
};
}, [appSidebarWidth, open, setAppSidebarFloating, setOpen]);
// disable animation to avoid UI flash
const enableAnimation = useEnableAnimation();
}, [open, setFloating, setOpen, width]);
const transparent = environment.isDesktop && !props.hasBackground;
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
const hasRightBorder = !environment.isDesktop || !transparent;
return (
<>
<div
style={assignInlineVars({
[navWidthVar]: `${appSidebarWidth}px`,
})}
className={clsx(navWrapperStyle, {
'has-background': environment.isDesktop && props.hasBackground,
'has-border':
!environment.isDesktop ||
(environment.isDesktop && props.hasBackground),
})}
data-open={open}
<ResizePanel
floating={floating}
open={open}
resizing={resizing}
maxWidth={MAX_WIDTH}
minWidth={MIN_WIDTH}
width={width}
resizeHandlePos="right"
onOpen={setOpen}
onResizing={setResizing}
onWidthChange={setWidth}
className={navWrapperStyle}
resizeHandleVerticalPadding={transparent ? 16 : 0}
data-transparent={transparent}
data-has-border={hasRightBorder}
data-testid="app-sidebar-wrapper"
data-is-macos-electron={isMacosDesktop}
data-is-floating={appSidebarFloating}
data-has-background={props.hasBackground}
data-enable-animation={enableAnimation && !isResizing}
data-has-background={environment.isDesktop && props.hasBackground}
>
<nav className={navStyle} ref={navRef} data-testid="app-sidebar">
<nav className={navStyle} data-testid="app-sidebar">
<SidebarHeader
router={props.router}
generalShortcutsInfo={props.generalShortcutsInfo}
@@ -118,12 +105,11 @@ export function AppSidebar(props: AppSidebarProps): ReactElement {
{props.children}
</div>
</nav>
<ResizeIndicator targetElement={navRef.current} />
</div>
</ResizePanel>
<div
data-testid="app-sidebar-float-mask"
data-open={open}
data-is-floating={appSidebarFloating}
data-is-floating={floating}
className={sidebarFloatMaskStyle}
onClick={() => setOpen(false)}
/>
@@ -132,15 +118,12 @@ export function AppSidebar(props: AppSidebarProps): ReactElement {
}
export const AppSidebarFallback = (): ReactElement | null => {
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
const width = useAtomValue(appSidebarWidthAtom);
return (
<div
style={assignInlineVars({
[navWidthVar]: `${appSidebarWidth}px`,
})}
className={clsx(navWrapperStyle, {
'has-border': true,
})}
style={{ width }}
className={navWrapperStyle}
data-has-border
data-open="true"
>
<nav className={navStyle}>

View File

@@ -1,45 +0,0 @@
import { style } from '@vanilla-extract/css';
export const resizerContainer = style({
position: 'absolute',
right: 0,
top: '16px',
bottom: '16px',
width: '16px',
zIndex: 'calc(var(--affine-z-index-modal) + 1)',
transform: 'translateX(50%)',
backgroundColor: 'transparent',
opacity: 0,
cursor: 'col-resize',
'@media': {
'(max-width: 600px)': {
// do not allow resizing on mobile
display: 'none',
},
},
transition: 'opacity 0.15s ease 0.1s',
selectors: {
'&:hover': {
opacity: 1,
},
'&[data-resizing="true"]': {
opacity: 1,
transition: 'width .3s, min-width .3s, max-width .3s',
},
'&[data-open="false"]': {
display: 'none',
},
'&[data-open="open"]': {
display: 'block',
},
},
});
export const resizerInner = style({
position: 'absolute',
height: '100%',
width: '4px',
left: '6px',
borderRadius: '4px',
backgroundColor: 'var(--affine-primary-color)',
});

View File

@@ -1,62 +0,0 @@
import { assertExists } from '@blocksuite/global/utils';
import { useAtom, useSetAtom } from 'jotai';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
import {
appSidebarOpenAtom,
appSidebarResizingAtom,
appSidebarWidthAtom,
} from '../index.jotai';
import * as styles from './index.css';
type ResizeIndicatorProps = {
targetElement: HTMLElement | null;
};
export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => {
const setWidth = useSetAtom(appSidebarWidthAtom);
const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom);
const [isResizing, setIsResizing] = useAtom(appSidebarResizingAtom);
const onResizeStart = useCallback(() => {
let resized = false;
assertExists(props.targetElement);
const { left: anchorLeft } = props.targetElement.getBoundingClientRect();
function onMouseMove(e: MouseEvent) {
e.preventDefault();
if (!props.targetElement) return;
const newWidth = Math.min(480, Math.max(e.clientX - anchorLeft, 256));
setWidth(newWidth);
setIsResizing(true);
resized = true;
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener(
'mouseup',
() => {
// if not resized, toggle sidebar
if (!resized) {
setSidebarOpen(o => !o);
}
setIsResizing(false);
document.removeEventListener('mousemove', onMouseMove);
},
{ once: true }
);
}, [props.targetElement, setIsResizing, setSidebarOpen, setWidth]);
return (
<div
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 @@
export * from './resize-panel';

View File

@@ -0,0 +1,100 @@
import { createVar, style } from '@vanilla-extract/css';
export const panelWidthVar = createVar('panel-width');
export const resizeHandleOffsetVar = createVar('resize-handle-offset');
export const resizeHandleVerticalPadding = createVar(
'resize-handle-vertical-padding'
);
export const root = style({
vars: {
[panelWidthVar]: '256px',
[resizeHandleOffsetVar]: '0',
},
position: 'relative',
width: panelWidthVar,
minWidth: 0,
height: '100%',
selectors: {
'&[data-is-floating="true"]': {
position: 'absolute',
width: `calc(${panelWidthVar})`,
zIndex: 4,
},
'&[data-open="true"]': {
maxWidth: '50%',
},
'&[data-open="false"][data-handle-position="right"]': {
marginLeft: `calc(${panelWidthVar} * -1)`,
},
'&[data-open="false"][data-handle-position="left"]': {
marginRight: `calc(${panelWidthVar} * -1)`,
},
'&[data-enable-animation="true"]': {
transition: 'margin-left .3s .05s, margin-right .3s .05s, width .3s .05s',
},
'&[data-is-floating="false"][data-transparent=true]': {
backgroundColor: 'transparent',
},
},
});
export const panelContent = style({
position: 'relative',
height: '100%',
overflow: 'auto',
});
export const resizeHandleContainer = style({
position: 'absolute',
right: resizeHandleOffsetVar,
top: resizeHandleVerticalPadding,
bottom: resizeHandleVerticalPadding,
width: 16,
zIndex: '1',
transform: 'translateX(50%)',
backgroundColor: 'transparent',
opacity: 0,
display: 'flex',
justifyContent: 'center',
cursor: 'ew-resize',
'@media': {
'(max-width: 600px)': {
// do not allow resizing on small screen
display: 'none',
},
},
transition: 'opacity 0.15s ease 0.1s',
selectors: {
'&[data-resizing="true"], &:hover': {
opacity: 1,
},
'&[data-open="false"]': {
display: 'none',
},
'&[data-open="open"]': {
display: 'block',
},
'&[data-handle-position="left"]': {
left: resizeHandleOffsetVar,
right: 'auto',
transform: 'translateX(-50%)',
},
},
});
export const resizerInner = style({
position: 'absolute',
height: '100%',
width: '2px',
borderRadius: '2px',
backgroundColor: 'var(--affine-primary-color)',
transition: 'all 0.2s ease-in-out',
transform: 'translateX(0.5px)',
selectors: {
[`${resizeHandleContainer}[data-resizing="true"] &`]: {
width: '4px',
borderRadius: '4px',
},
},
});

View File

@@ -0,0 +1,181 @@
import { assertExists } from '@blocksuite/global/utils';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import * as styles from './resize-panel.css';
export interface ResizeHandleProps
extends React.HtmlHTMLAttributes<HTMLDivElement> {
resizing: boolean;
open: boolean;
minWidth: number;
maxWidth: number;
resizeHandlePos: 'left' | 'right';
resizeHandleOffset?: number;
resizeHandleVerticalPadding?: number;
onOpen: (open: boolean) => void;
onResizing: (resizing: boolean) => void;
onWidthChange: (width: number) => void;
}
export interface ResizePanelProps
extends React.HtmlHTMLAttributes<HTMLDivElement> {
resizing: boolean;
open: boolean;
floating?: boolean;
minWidth: number;
maxWidth: number;
resizeHandlePos: 'left' | 'right';
resizeHandleOffset?: number;
resizeHandleVerticalPadding?: number;
enableAnimation?: boolean;
width: number;
onOpen: (open: boolean) => void;
onResizing: (resizing: boolean) => void;
onWidthChange: (width: number) => void;
}
const ResizeHandle = ({
className,
resizing,
minWidth,
maxWidth,
resizeHandlePos,
resizeHandleOffset,
resizeHandleVerticalPadding,
open,
onOpen,
onResizing,
onWidthChange,
...rest
}: ResizeHandleProps) => {
const ref = useRef<HTMLDivElement>(null);
const onResizeStart = useCallback(() => {
let resized = false;
const panelContainer = ref.current?.parentElement;
assertExists(
panelContainer,
'parent element not found for resize indicator'
);
const { left: anchorLeft, right: anchorRight } =
panelContainer.getBoundingClientRect();
function onMouseMove(e: MouseEvent) {
e.preventDefault();
if (!panelContainer) return;
const newWidth = Math.min(
maxWidth,
Math.max(
resizeHandlePos === 'right'
? e.clientX - anchorLeft
: anchorRight - e.clientX,
minWidth
)
);
onWidthChange(newWidth);
onResizing(true);
resized = true;
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener(
'mouseup',
() => {
// if not resized, toggle sidebar
if (!resized) {
onOpen(false);
}
onResizing(false);
document.removeEventListener('mousemove', onMouseMove);
},
{ once: true }
);
}, [maxWidth, resizeHandlePos, minWidth, onWidthChange, onResizing, onOpen]);
return (
<div
{...rest}
data-testid="resize-handle"
ref={ref}
style={assignInlineVars({
[styles.resizeHandleOffsetVar]: `${resizeHandleOffset ?? 0}px`,
[styles.resizeHandleVerticalPadding]: `${
resizeHandleVerticalPadding ?? 0
}px`,
})}
className={clsx(styles.resizeHandleContainer, className)}
data-handle-position={resizeHandlePos}
data-resizing={resizing}
data-open={open}
onMouseDown={onResizeStart}
>
<div className={styles.resizerInner} />
</div>
);
};
function useEnableAnimation() {
const [enable, setEnable] = useState(false);
useEffect(() => {
window.setTimeout(() => {
setEnable(true);
}, 500);
}, []);
return enable;
}
export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
function ResizePanel(
{
children,
className,
resizing,
minWidth,
maxWidth,
width,
floating,
enableAnimation: _enableAnimation = true,
open,
onOpen,
onResizing,
onWidthChange,
resizeHandlePos,
resizeHandleOffset,
resizeHandleVerticalPadding,
...rest
},
ref
) {
const enableAnimation = useEnableAnimation() && _enableAnimation;
return (
<div
{...rest}
ref={ref}
style={assignInlineVars({
[styles.panelWidthVar]: `${width}px`,
})}
className={clsx(className, styles.root)}
data-open={open}
data-is-floating={floating}
data-handle-position={resizeHandlePos}
data-enable-animation={enableAnimation && !resizing}
>
{children}
<ResizeHandle
resizeHandlePos={resizeHandlePos}
resizeHandleOffset={resizeHandleOffset}
resizeHandleVerticalPadding={resizeHandleVerticalPadding}
maxWidth={maxWidth}
minWidth={minWidth}
onOpen={onOpen}
onResizing={onResizing}
onWidthChange={onWidthChange}
open={open}
resizing={resizing}
/>
</div>
);
}
);

View File

@@ -60,6 +60,8 @@ export const mainContainerStyle = style({
margin: '8px',
borderRadius: '5px',
overflow: 'hidden',
// todo: is this performance intensive?
filter: 'drop-shadow(0px 0px 4px rgba(66,65,73,.14))',
'@media': {
print: {
overflow: 'visible',
@@ -86,39 +88,6 @@ export const mainContainerStyle = style({
},
} as ComplexStyleRule);
// These styles override the default styles of the react-resizable-panels
// as the default styles make the overflow part hidden when printing to PDF.
// See https://github.com/toeverything/AFFiNE/pull/3893
globalStyle(`${mainContainerStyle} > div[data-panel-group]`, {
'@media': {
print: {
overflow: 'visible !important',
},
},
});
// These styles override the default styles of the react-resizable-panels
// as the default styles make the overflow part hidden when printing to PDF.
// See https://github.com/toeverything/AFFiNE/pull/3893
globalStyle(`${mainContainerStyle} > div[data-panel-group] > div[data-panel]`, {
'@media': {
print: {
overflow: 'visible !important',
},
},
});
// Hack margin so that it works normally when sidebar is closed
globalStyle(
`[data-testid=app-sidebar-wrapper][data-open=true][data-is-floating=false][data-has-background=false]
~ ${mainContainerStyle}[data-show-padding="true"]`,
{
// transition added here to prevent the transition from being applied on page load
transition: 'margin-left .3s ease-in-out',
marginLeft: '0',
}
);
export const toolStyle = style({
position: 'absolute',
right: '30px',