feat: refator header (#3685)

Co-authored-by: JimmFly <yangjinfei001@gmail.com>
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
Qi
2023-08-12 04:27:24 +08:00
committed by GitHub
parent 91619b87db
commit 401fb48b86
35 changed files with 772 additions and 1386 deletions
@@ -0,0 +1,150 @@
import { Wrapper } from '@affine/component';
import {
appSidebarFloatingAtom,
appSidebarOpenAtom,
SidebarSwitch,
} from '@affine/component/app-sidebar';
import { isDesktop } from '@affine/env/constant';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import throttle from 'lodash.throttle';
import type { MutableRefObject, ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import * as style from './style.css';
import { TopTip } from './top-tip';
import { WindowsAppControls } from './windows-app-controls';
interface HeaderPros {
left?: ReactNode;
right?: ReactNode;
center?: ReactNode;
}
const useIsTinyScreen = ({
mainContainer,
leftDoms,
centerDom,
rightDoms,
}: {
mainContainer: HTMLElement;
leftDoms: MutableRefObject<HTMLElement | null>[];
centerDom: MutableRefObject<HTMLElement | null>;
rightDoms: MutableRefObject<HTMLElement | null>[];
}) => {
const [isTinyScreen, setIsTinyScreen] = useState(false);
useEffect(() => {
const handleResize = throttle(() => {
if (!centerDom.current) {
return;
}
const leftTotalWidth = leftDoms.reduce((accWidth, dom) => {
return accWidth + (dom.current?.clientWidth || 0);
}, 0);
const rightTotalWidth = rightDoms.reduce((accWidth, dom) => {
return accWidth + (dom.current?.clientWidth || 0);
}, 0);
const containerRect = mainContainer.getBoundingClientRect();
const centerRect = centerDom.current.getBoundingClientRect();
const offset = isTinyScreen ? 50 : 0;
if (
leftTotalWidth + containerRect.left >= centerRect.left - offset ||
containerRect.right - centerRect.right <= rightTotalWidth + offset
) {
setIsTinyScreen(true);
} else {
setIsTinyScreen(false);
}
}, 100);
handleResize();
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
resizeObserver.observe(mainContainer);
}, [centerDom, isTinyScreen, leftDoms, mainContainer, rightDoms]);
return isTinyScreen;
};
// The Header component is used to solve the following problems
// 1. Manage layout issues independently of page or business logic
// 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed
export const Header = ({ left, center, right }: HeaderPros) => {
const sidebarSwitchRef = useRef<HTMLDivElement | null>(null);
const leftSlotRef = useRef<HTMLDivElement | null>(null);
const centerSlotRef = useRef<HTMLDivElement | null>(null);
const rightSlotRef = useRef<HTMLDivElement | null>(null);
const windowControlsRef = useRef<HTMLDivElement | null>(null);
const isTinyScreen = useIsTinyScreen({
mainContainer: document.querySelector('.main-container') || document.body,
leftDoms: [sidebarSwitchRef, leftSlotRef],
centerDom: centerSlotRef,
rightDoms: [rightSlotRef, windowControlsRef],
});
const isWindowsDesktop = globalThis.platform === 'win32' && isDesktop;
const open = useAtomValue(appSidebarOpenAtom);
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
return (
<>
<TopTip />
<div
className={style.header}
// data-has-warning={showWarning}
data-open={open}
data-sidebar-floating={appSidebarFloating}
data-testid="header"
>
<div
className={clsx(style.headerSideContainer, {
block: isTinyScreen,
})}
>
<div className={clsx(style.headerItem, 'top-item')}>
<div ref={sidebarSwitchRef}>
{!open && (
<Wrapper marginRight={20}>
<SidebarSwitch />
</Wrapper>
)}
</div>
</div>
<div className={clsx(style.headerItem, 'left')}>
<div ref={leftSlotRef}>{left}</div>
</div>
</div>
<div
className={clsx({
[style.headerCenter]: center,
'is-window': isWindowsDesktop,
'has-min-width': !isTinyScreen,
})}
ref={centerSlotRef}
>
{center}
</div>
<div
className={clsx(style.headerSideContainer, 'right', {
block: isTinyScreen,
})}
>
<div className={clsx(style.headerItem, 'top-item')}>
<div ref={windowControlsRef}>
{isWindowsDesktop ? <WindowsAppControls /> : null}
</div>
</div>
<div className={clsx(style.headerItem, 'right')}>
<div ref={rightSlotRef}>{right}</div>
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,111 @@
import type { ComplexStyleRule } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
justifyContent: 'space-between',
position: 'relative',
padding: '0 16px',
minHeight: '52px',
borderBottom: '1px solid var(--affine-border-color)',
selectors: {
'&[data-sidebar-floating="false"]': {
WebkitAppRegion: 'drag',
},
},
'@media': {
print: {
display: 'none',
},
},
':has([data-popper-placement])': {
WebkitAppRegion: 'no-drag',
},
} as ComplexStyleRule);
export const headerItem = style({
minHeight: '32px',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
selectors: {
'&.top-item': {
height: '52px',
},
'&.left': {
justifyContent: 'left',
},
'&.right': {
justifyContent: 'right',
},
},
});
export const headerCenter = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '52px',
flexShrink: 0,
maxWidth: '60%',
position: 'absolute',
transform: 'translateX(-50%)',
left: '50%',
zIndex: 1,
selectors: {
'&.is-window': {
maxWidth: '50%',
},
'&.is-window.has-min-width': {
minWidth: '400px',
},
'&.shadow': {
position: 'static',
visibility: 'hidden',
},
},
});
export const headerSideContainer = style({
display: 'flex',
flexShrink: 0,
alignItems: 'center',
selectors: {
'&.right': {
flexDirection: 'row-reverse',
},
'&.block': {
display: 'block',
},
},
});
export const windowAppControlsWrapper = style({
display: 'flex',
marginLeft: '20px',
});
export const windowAppControl = style({
WebkitAppRegion: 'no-drag',
cursor: 'pointer',
display: 'inline-flex',
width: '52px',
height: '52px',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '0',
selectors: {
'&[data-type="close"]': {
width: '56px',
paddingRight: '5px',
marginRight: '-12px',
},
'&[data-type="close"]:hover': {
background: 'var(--affine-windows-close-button)',
color: 'var(--affine-pure-white)',
},
'&:hover': {
background: 'var(--affine-hover-color)',
},
},
} as ComplexStyleRule);
@@ -0,0 +1,87 @@
import { BrowserWarning } from '@affine/component/affine-banner';
import { DownloadTips } from '@affine/component/affine-banner';
import { isDesktop } from '@affine/env/constant';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom } from 'jotai';
import { useEffect, useState } from 'react';
import { guideDownloadClientTipAtom } from '../../../atoms/guide';
const minimumChromeVersion = 102;
const shouldShowWarning = () => {
if (isDesktop) {
// even though desktop has compatibility issues,
// we don't want to show the warning
return false;
}
if (!environment.isBrowser) {
// disable in SSR
return false;
}
if (environment.isChrome) {
return environment.chromeVersion < minimumChromeVersion;
} else {
return !environment.isMobile;
}
};
const OSWarningMessage = () => {
const t = useAFFiNEI18N();
const [notChrome, setNotChrome] = useState(false);
const [notGoodVersion, setNotGoodVersion] = useState(false);
useEffect(() => {
setNotChrome(environment.isBrowser && !environment.isChrome);
setNotGoodVersion(
environment.isBrowser &&
environment.isChrome &&
environment.chromeVersion < minimumChromeVersion
);
}, []);
if (notChrome) {
return (
<span>
<Trans i18nKey="recommendBrowser">
We recommend the <strong>Chrome</strong> browser for optimal
experience.
</Trans>
</span>
);
} else if (notGoodVersion) {
return <span>{t['upgradeBrowser']()}</span>;
}
return null;
};
export const TopTip = () => {
const [showWarning, setShowWarning] = useState(false);
const [showDownloadTip, setShowDownloadTip] = useAtom(
guideDownloadClientTipAtom
);
useEffect(() => {
setShowWarning(shouldShowWarning());
}, []);
if (showDownloadTip && isDesktop) {
return (
<DownloadTips
onClose={() => {
setShowDownloadTip(false);
localStorage.setItem('affine-is-dt-hide', '1');
}}
/>
);
}
return (
<BrowserWarning
show={showWarning}
message={<OSWarningMessage />}
onClose={() => {
setShowWarning(false);
}}
/>
);
};
@@ -0,0 +1,51 @@
import { CloseIcon, MinusIcon, RoundedRectangleIcon } from '@blocksuite/icons';
import { useCallback } from 'react';
import * as style from './style.css';
export const WindowsAppControls = () => {
const handleMinimizeApp = useCallback(() => {
window.apis?.ui.handleMinimizeApp().catch(err => {
console.error(err);
});
}, []);
const handleMaximizeApp = useCallback(() => {
window.apis?.ui.handleMaximizeApp().catch(err => {
console.error(err);
});
}, []);
const handleCloseApp = useCallback(() => {
window.apis?.ui.handleCloseApp().catch(err => {
console.error(err);
});
}, []);
return (
<div
data-platform-target="win32"
className={style.windowAppControlsWrapper}
>
<button
data-type="minimize"
className={style.windowAppControl}
onClick={handleMinimizeApp}
>
<MinusIcon />
</button>
<button
data-type="maximize"
className={style.windowAppControl}
onClick={handleMaximizeApp}
>
<RoundedRectangleIcon />
</button>
<button
data-type="close"
className={style.windowAppControl}
onClick={handleCloseApp}
>
<CloseIcon />
</button>
</div>
);
};
@@ -0,0 +1,44 @@
import {
addCleanup,
pluginHeaderItemAtom,
} from '@toeverything/infra/__internal__/plugin';
import { useAtomValue } from 'jotai';
import { startTransition, useCallback, useRef } from 'react';
import * as styles from './styles.css';
export const PluginHeader = () => {
const headerItem = useAtomValue(pluginHeaderItemAtom);
const pluginsRef = useRef<string[]>([]);
return (
<div
className={styles.pluginHeaderItems}
ref={useCallback(
(root: HTMLDivElement | null) => {
if (root) {
Object.entries(headerItem).forEach(([pluginName, create]) => {
if (pluginsRef.current.includes(pluginName)) {
return;
}
pluginsRef.current.push(pluginName);
const div = document.createElement('div');
div.setAttribute('plugin-id', pluginName);
startTransition(() => {
const cleanup = create(div);
root.appendChild(div);
addCleanup(pluginName, () => {
pluginsRef.current = pluginsRef.current.filter(
name => name !== pluginName
);
root.removeChild(div);
cleanup();
});
});
});
}
},
[headerItem]
)}
/>
);
};
@@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
export const pluginHeaderItems = style({
display: 'flex',
gap: '12px',
alignItems: 'center',
height: '100%',
});
@@ -0,0 +1,78 @@
import { Confirm } from '@affine/component';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { Button } from '@toeverything/components/button';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { currentPageIdAtom } from '@toeverything/infra/atom';
import { useAtomValue } from 'jotai';
import { useCallback, useState } from 'react';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { buttonContainer, group } from './styles.css';
export const TrashButtonGroup = () => {
// fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
const pageId = useAtomValue(currentPageIdAtom);
assertExists(workspace);
assertExists(pageId);
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const t = useAFFiNEI18N();
const { jumpToSubPath } = useNavigateHelper();
const { restoreFromTrash } = useBlockSuiteMetaHelper(blockSuiteWorkspace);
const [open, setOpen] = useState(false);
return (
<div className={group}>
<div className={buttonContainer}>
<Button
type="primary"
onClick={() => {
restoreFromTrash(pageId);
}}
size="large"
>
{t['Restore it']()}
</Button>
</div>
<div className={buttonContainer}>
<Button
type="error"
onClick={() => {
setOpen(true);
}}
size="large"
>
{t['Delete permanently']()}
</Button>
</div>
<Confirm
title={t['TrashButtonGroupTitle']()}
content={t['TrashButtonGroupDescription']()}
confirmText={t['Delete']()}
confirmType="error"
open={open}
onConfirm={useCallback(() => {
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
blockSuiteWorkspace.removePage(pageId);
}, [blockSuiteWorkspace, jumpToSubPath, pageId, workspace.id])}
onCancel={() => {
setOpen(false);
}}
onClose={() => {
setOpen(false);
}}
/>
</div>
);
};
export default TrashButtonGroup;
@@ -0,0 +1,15 @@
import { style } from '@vanilla-extract/css';
export const group = style({
width: '100%',
position: 'absolute',
bottom: '100px',
left: '0',
display: 'flex',
gap: '24px',
justifyContent: 'center',
});
export const buttonContainer = style({
boxShadow: 'var(--affine-float-button-shadow-2)',
borderRadius: '8px',
});
@@ -0,0 +1,30 @@
import { RadioButton, RadioButtonGroup } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom } from 'jotai';
import { allPageModeSelectAtom } from '../../../atoms';
export const WorkspaceModeFilterTab = () => {
const t = useAFFiNEI18N();
const [value, setMode] = useAtom(allPageModeSelectAtom);
const handleValueChange = (value: string) => {
if (value !== 'all' && value !== 'page' && value !== 'edgeless') {
throw new Error('Invalid value for page mode option');
}
setMode(value);
};
return (
<RadioButtonGroup
width={300}
defaultValue={value}
onValueChange={handleValueChange}
>
<RadioButton value="all" style={{ textTransform: 'capitalize' }}>
{t['all']()}
</RadioButton>
<RadioButton value="page">{t['Page']()}</RadioButton>
<RadioButton value="edgeless">{t['Edgeless']()}</RadioButton>
</RadioButtonGroup>
);
};
@@ -1,44 +0,0 @@
import { RadioButton, RadioButtonGroup } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom } from 'jotai';
import type { ReactNode } from 'react';
import type React from 'react';
import { allPageModeSelectAtom } from '../../../atoms';
import type { HeaderProps } from '../../blocksuite/workspace-header/header';
import { Header } from '../../blocksuite/workspace-header/header';
import * as styles from '../../blocksuite/workspace-header/styles.css';
export interface WorkspaceTitleProps
extends React.PropsWithChildren<HeaderProps> {
icon?: ReactNode;
}
export const WorkspaceModeFilterTab = ({ ...props }: WorkspaceTitleProps) => {
const t = useAFFiNEI18N();
const [value, setMode] = useAtom(allPageModeSelectAtom);
const handleValueChange = (value: string) => {
if (value !== 'all' && value !== 'page' && value !== 'edgeless') {
throw new Error('Invalid value for page mode option');
}
setMode(value);
};
return (
<Header {...props}>
<div className={styles.allPageListTitleWrapper}>
<RadioButtonGroup
width={300}
defaultValue={value}
onValueChange={handleValueChange}
>
<RadioButton value="all" style={{ textTransform: 'capitalize' }}>
{t['all']()}
</RadioButton>
<RadioButton value="page">{t['Page']()}</RadioButton>
<RadioButton value="edgeless">{t['Edgeless']()}</RadioButton>
</RadioButtonGroup>
</div>
</Header>
);
};