mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 10:36:22 +08:00
feat: refator header (#3685)
Co-authored-by: JimmFly <yangjinfei001@gmail.com> Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user