mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
refactor(core): new back&forward button base on workbench (#6012)
# feature: ## In Browser: - hidden back&forward button in sidebar. - back and forward is equal with `window.history.back()` `window.history.forward()` ## In Desktop: - Back and forward can be controlled through the sidebar, cmdk, and shortcut keys. - back and forward act on the currently **active** view. - buttons change disable&enable style based on current active view history # Refactor: Move app-sidebar and app-container from @affine/component to @affine/core
This commit is contained in:
@@ -4,7 +4,6 @@ import '@affine/component/theme/theme.css';
|
||||
import { AffineContext } from '@affine/component/context';
|
||||
import { GlobalLoading } from '@affine/component/global-loading';
|
||||
import { NotificationCenter } from '@affine/component/notification-center';
|
||||
import { WorkspaceFallback } from '@affine/component/workspace';
|
||||
import { createI18n, setUpLanguage } from '@affine/i18n';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import { getCurrentStore } from '@toeverything/infra/atom';
|
||||
@@ -13,6 +12,7 @@ import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { lazy, memo, Suspense } from 'react';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
|
||||
import { WorkspaceFallback } from './components/workspace';
|
||||
import { GlobalScopeProvider } from './modules/infra-web/global-scope';
|
||||
import { CloudSessionProvider } from './providers/session-provider';
|
||||
import { router } from './router';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// these atoms cannot be moved to @affine/jotai since they use atoms from @affine/component
|
||||
import { appSidebarOpenAtom } from '@affine/component/app-sidebar';
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
|
||||
import { appSidebarOpenAtom } from '../components/app-sidebar';
|
||||
|
||||
export type Guide = {
|
||||
// should show quick search tips
|
||||
quickSearchTips: boolean;
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||
import { useCallback } from 'react';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { router } from '../router';
|
||||
|
||||
export type History = {
|
||||
stack: string[];
|
||||
current: number;
|
||||
skip: boolean;
|
||||
};
|
||||
|
||||
export const MAX_HISTORY = 50;
|
||||
|
||||
const historyBaseAtom = atomWithStorage<History>(
|
||||
'router-history',
|
||||
{
|
||||
stack: [],
|
||||
current: 0,
|
||||
skip: false,
|
||||
},
|
||||
createJSONStorage(() => sessionStorage)
|
||||
);
|
||||
|
||||
historyBaseAtom.onMount = set => {
|
||||
const unsubscribe = router.subscribe(state => {
|
||||
set(prev => {
|
||||
const url = state.location.pathname;
|
||||
|
||||
// if stack top is the same as current, skip
|
||||
if (prev.stack[prev.current] === url) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (prev.skip) {
|
||||
return {
|
||||
stack: [...prev.stack],
|
||||
current: prev.current,
|
||||
skip: false,
|
||||
};
|
||||
} else {
|
||||
if (prev.current < prev.stack.length - 1) {
|
||||
const newStack = prev.stack.slice(0, prev.current);
|
||||
newStack.push(url);
|
||||
if (newStack.length > MAX_HISTORY) {
|
||||
newStack.shift();
|
||||
}
|
||||
return {
|
||||
stack: newStack,
|
||||
current: newStack.length - 1,
|
||||
skip: false,
|
||||
};
|
||||
} else {
|
||||
const newStack = [...prev.stack, url];
|
||||
if (newStack.length > MAX_HISTORY) {
|
||||
newStack.shift();
|
||||
}
|
||||
return {
|
||||
stack: newStack,
|
||||
current: newStack.length - 1,
|
||||
skip: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
};
|
||||
|
||||
export function useHistoryAtom() {
|
||||
const navigate = useNavigate();
|
||||
const [base, setBase] = useAtom(historyBaseAtom);
|
||||
return [
|
||||
base,
|
||||
useCallback(
|
||||
(forward: boolean) => {
|
||||
setBase(prev => {
|
||||
if (forward) {
|
||||
const target = Math.min(prev.stack.length - 1, prev.current + 1);
|
||||
const url = prev.stack[target];
|
||||
navigate(url);
|
||||
return {
|
||||
...prev,
|
||||
current: target,
|
||||
skip: true,
|
||||
};
|
||||
} else {
|
||||
const target = Math.max(0, prev.current - 1);
|
||||
const url = prev.stack[target];
|
||||
navigate(url);
|
||||
return {
|
||||
...prev,
|
||||
current: target,
|
||||
skip: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
[setBase, navigate]
|
||||
),
|
||||
] as const;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { appSidebarOpenAtom } from '@affine/component/app-sidebar';
|
||||
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { SidebarIcon } from '@blocksuite/icons';
|
||||
import { registerAffineCommand } from '@toeverything/infra/command';
|
||||
import type { createStore } from 'jotai';
|
||||
|
||||
import { appSidebarOpenAtom } from '../components/app-sidebar';
|
||||
|
||||
export function registerAffineLayoutCommands({
|
||||
t,
|
||||
store,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
||||
import {
|
||||
AppContainer as AppContainerWithoutSettings,
|
||||
type WorkspaceRootProps,
|
||||
} from '@affine/component/workspace';
|
||||
|
||||
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
||||
} from '../workspace';
|
||||
|
||||
export const AppContainer = (props: WorkspaceRootProps) => {
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ToolContainer } from '@affine/component/workspace';
|
||||
|
||||
import { HelpIsland } from '../../pure/help-island';
|
||||
import { ToolContainer } from '../../workspace';
|
||||
|
||||
export const HubIsland = () => {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'inline-flex',
|
||||
background: cssVar('white30'),
|
||||
alignItems: 'center',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${cssVar('black10')}`,
|
||||
fontSize: cssVar('fontSm'),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
height: '52px',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0 24px',
|
||||
transition: 'background 0.2s ease',
|
||||
selectors: {
|
||||
'&:active': {
|
||||
background: cssVar('white50'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const icon = style({
|
||||
marginRight: '18px',
|
||||
color: cssVar('iconColor'),
|
||||
fontSize: '24px',
|
||||
});
|
||||
export const spacer = style({
|
||||
flex: 1,
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import { AddPageButton } from './index';
|
||||
|
||||
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,33 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { PlusIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import type React from 'react';
|
||||
|
||||
import { Spotlight } from '../spolight';
|
||||
import * as styles from './index.css';
|
||||
|
||||
interface AddPageButtonProps {
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function AddPageButton({
|
||||
onClick,
|
||||
className,
|
||||
style,
|
||||
}: AddPageButtonProps) {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-testid="sidebar-new-page-button"
|
||||
style={style}
|
||||
className={clsx([styles.root, className])}
|
||||
onClick={onClick}
|
||||
>
|
||||
<PlusIcon className={styles.icon} /> {t['New Page']()}
|
||||
<Spotlight />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export {
|
||||
closeIcon,
|
||||
ellipsisTextOverflow,
|
||||
halo,
|
||||
icon,
|
||||
particles,
|
||||
root,
|
||||
} from '../app-updater-button/index.css';
|
||||
export const rootPadding = style({
|
||||
padding: '0 24px',
|
||||
});
|
||||
export const label = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
fontSize: cssVar('fontSm'),
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CloseIcon, DownloadIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
// Although it is called an input, it is actually a button.
|
||||
export function AppDownloadButton({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const [show, setShow] = useState(true);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setShow(false);
|
||||
}, []);
|
||||
|
||||
// TODO: unify this type of literal value.
|
||||
const handleClick = useCallback(() => {
|
||||
const url = `https://affine.pro/download?channel=stable`;
|
||||
open(url, '_blank');
|
||||
}, []);
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
style={style}
|
||||
className={clsx([styles.root, styles.rootPadding, className])}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className={clsx([styles.label])}>
|
||||
<DownloadIcon className={styles.icon} />
|
||||
<span className={styles.ellipsisTextOverflow}>Download App</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.closeIcon}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</div>
|
||||
<div className={styles.particles} aria-hidden="true"></div>
|
||||
<span className={styles.halo} aria-hidden="true"></span>
|
||||
</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,224 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'inline-flex',
|
||||
background: cssVar('white30'),
|
||||
alignItems: 'center',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${cssVar('black10')}`,
|
||||
fontSize: cssVar('fontSm'),
|
||||
width: '100%',
|
||||
height: '52px',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0 12px',
|
||||
position: 'relative',
|
||||
transition: 'all 0.3s ease',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVar('white60'),
|
||||
},
|
||||
'&[data-disabled="true"]': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:after': {
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
top: '-2px',
|
||||
right: '-2px',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
backgroundColor: cssVar('primaryColor'),
|
||||
borderRadius: '50%',
|
||||
zIndex: 1,
|
||||
transition: 'opacity 0.3s',
|
||||
},
|
||||
'&:hover:after': {
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
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(30, 150, 235, 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(30, 150, 235, 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(30, 150, 235, 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(30, 150, 235, 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: cssVar('iconColor'),
|
||||
fontSize: '24px',
|
||||
});
|
||||
export const closeIcon = style({
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
height: '14px',
|
||||
width: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: cssVar('shadow1'),
|
||||
color: cssVar('textSecondaryColor'),
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
transition: '0.1s',
|
||||
borderRadius: '50%',
|
||||
transform: 'scale(0.6)',
|
||||
zIndex: 1,
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
[`${root}:hover &`]: {
|
||||
opacity: 1,
|
||||
transform: 'scale(1)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const installLabel = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
fontSize: cssVar('fontSm'),
|
||||
whiteSpace: 'nowrap',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const installLabelNormal = style([
|
||||
installLabel,
|
||||
{
|
||||
selectors: {
|
||||
[`${root}:hover &, ${root}[data-updating=true] &`]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
export const installLabelHover = style([
|
||||
installLabel,
|
||||
{
|
||||
display: 'none',
|
||||
justifyContent: 'flex-start',
|
||||
selectors: {
|
||||
[`${root}:hover &, ${root}[data-updating=true] &`]: {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
export const updateAvailableWrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '8px 0',
|
||||
});
|
||||
export const versionLabel = style({
|
||||
padding: '0 6px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
fontSize: '10px',
|
||||
lineHeight: '18px',
|
||||
borderRadius: '4px',
|
||||
maxWidth: '100px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
export const whatsNewLabel = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
fontSize: cssVar('fontSm'),
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
export const ellipsisTextOverflow = style({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
export const progress = style({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
borderRadius: '12px',
|
||||
background: cssVar('black10'),
|
||||
});
|
||||
export const progressInner = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
borderRadius: '12px',
|
||||
background: cssVar('primaryColor'),
|
||||
transition: '0.1s',
|
||||
});
|
||||
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%',
|
||||
maskImage: 'linear-gradient(to top, transparent, black, black, transparent)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
display: 'none',
|
||||
selectors: {
|
||||
[`${root}:hover &, ${root}[data-updating=true] &`]: {
|
||||
display: 'block',
|
||||
},
|
||||
'&:before': {
|
||||
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',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const halo = style({
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
':before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
inset: 0,
|
||||
position: 'absolute',
|
||||
filter: 'blur(10px) saturate(1.2)',
|
||||
transition: '0.3s ease',
|
||||
willChange: 'filter, transform',
|
||||
transform: 'translateY(100%) scale(0.6)',
|
||||
background:
|
||||
'radial-gradient(ellipse 60% 80% at bottom, rgba(30, 150, 235, 0.35), transparent)',
|
||||
},
|
||||
':after': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
inset: 0,
|
||||
position: 'absolute',
|
||||
filter: 'blur(10px) saturate(1.2)',
|
||||
transition: '0.1s ease',
|
||||
willChange: 'filter, transform',
|
||||
transform: 'translateY(100%) scale(0.6)',
|
||||
background:
|
||||
'radial-gradient(ellipse 30% 45% at bottom, rgba(30, 150, 235, 0.6), transparent)',
|
||||
},
|
||||
selectors: {
|
||||
[`${root}:hover &:before, ${root}:hover &:after,
|
||||
${root}[data-updating=true] &:before, ${root}[data-updating=true] &:after`]:
|
||||
{
|
||||
transform: 'translateY(0) scale(1)',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { Tooltip } from '@affine/component';
|
||||
import { Unreachable } from '@affine/env/constant';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
export interface AddPageButtonProps {
|
||||
onQuitAndInstall: () => void;
|
||||
onDownloadUpdate: () => void;
|
||||
onDismissChangelog: () => void;
|
||||
onOpenChangelog: () => void;
|
||||
changelogUnread: boolean;
|
||||
updateReady: boolean;
|
||||
updateAvailable: {
|
||||
version: string;
|
||||
allowAutoUpdate: boolean;
|
||||
} | null;
|
||||
autoDownload: boolean;
|
||||
downloadProgress: number | null;
|
||||
appQuitting: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface ButtonContentProps {
|
||||
updateReady: boolean;
|
||||
updateAvailable: {
|
||||
version: string;
|
||||
allowAutoUpdate: boolean;
|
||||
} | null;
|
||||
autoDownload: boolean;
|
||||
downloadProgress: number | null;
|
||||
appQuitting: boolean;
|
||||
changelogUnread: boolean;
|
||||
onDismissChangelog: () => void;
|
||||
}
|
||||
|
||||
function DownloadUpdate({ updateAvailable }: ButtonContentProps) {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.installLabel}>
|
||||
<span className={styles.ellipsisTextOverflow}>
|
||||
{t['com.affine.appUpdater.downloadUpdate']()}
|
||||
</span>
|
||||
<span className={styles.versionLabel}>{updateAvailable?.version}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateReady({ updateAvailable, appQuitting }: ButtonContentProps) {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.updateAvailableWrapper}>
|
||||
<div className={styles.installLabelNormal}>
|
||||
<span className={styles.ellipsisTextOverflow}>
|
||||
{t['com.affine.appUpdater.updateAvailable']()}
|
||||
</span>
|
||||
<span className={styles.versionLabel}>{updateAvailable?.version}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.installLabelHover}>
|
||||
<ResetIcon className={styles.icon} />
|
||||
<span className={styles.ellipsisTextOverflow}>
|
||||
{t[appQuitting ? 'Loading' : 'com.affine.appUpdater.installUpdate']()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadingUpdate({
|
||||
updateAvailable,
|
||||
downloadProgress,
|
||||
}: ButtonContentProps) {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={clsx([styles.updateAvailableWrapper])}>
|
||||
<div className={clsx([styles.installLabelNormal])}>
|
||||
<span className={styles.ellipsisTextOverflow}>
|
||||
{t['com.affine.appUpdater.downloading']()}
|
||||
</span>
|
||||
<span className={styles.versionLabel}>{updateAvailable?.version}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.progress}>
|
||||
<div
|
||||
className={styles.progressInner}
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenDownloadPage({ updateAvailable }: ButtonContentProps) {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<>
|
||||
<div className={styles.installLabelNormal}>
|
||||
<span className={styles.ellipsisTextOverflow}>
|
||||
{t['com.affine.appUpdater.updateAvailable']()}
|
||||
</span>
|
||||
<span className={styles.versionLabel}>{updateAvailable?.version}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.installLabelHover}>
|
||||
<span className={styles.ellipsisTextOverflow}>
|
||||
{t['com.affine.appUpdater.openDownloadPage']()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WhatsNew({ onDismissChangelog }: ButtonContentProps) {
|
||||
const t = useAFFiNEI18N();
|
||||
const onClickClose: React.MouseEventHandler = useCallback(
|
||||
e => {
|
||||
onDismissChangelog();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[onDismissChangelog]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className={clsx([styles.whatsNewLabel])}>
|
||||
<NewIcon className={styles.icon} />
|
||||
<span className={styles.ellipsisTextOverflow}>
|
||||
{t['com.affine.appUpdater.whatsNew']()}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.closeIcon} onClick={onClickClose}>
|
||||
<CloseIcon />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getButtonContentRenderer = (props: ButtonContentProps) => {
|
||||
if (props.updateReady) {
|
||||
return UpdateReady;
|
||||
} else if (props.updateAvailable?.allowAutoUpdate) {
|
||||
if (props.autoDownload && props.updateAvailable.allowAutoUpdate) {
|
||||
return DownloadingUpdate;
|
||||
} else {
|
||||
return DownloadUpdate;
|
||||
}
|
||||
} else if (props.updateAvailable && !props.updateAvailable?.allowAutoUpdate) {
|
||||
return OpenDownloadPage;
|
||||
} else if (props.changelogUnread) {
|
||||
return WhatsNew;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function AppUpdaterButton({
|
||||
updateReady,
|
||||
changelogUnread,
|
||||
onDismissChangelog,
|
||||
onDownloadUpdate,
|
||||
onQuitAndInstall,
|
||||
onOpenChangelog,
|
||||
updateAvailable,
|
||||
autoDownload,
|
||||
downloadProgress,
|
||||
appQuitting,
|
||||
className,
|
||||
style,
|
||||
}: AddPageButtonProps) {
|
||||
const handleClick = useCallback(() => {
|
||||
if (updateReady) {
|
||||
onQuitAndInstall();
|
||||
} else if (updateAvailable) {
|
||||
if (updateAvailable.allowAutoUpdate) {
|
||||
if (autoDownload) {
|
||||
// wait for download to finish
|
||||
} else {
|
||||
onDownloadUpdate();
|
||||
}
|
||||
} else {
|
||||
window.open(
|
||||
`https://github.com/toeverything/AFFiNE/releases/tag/v${updateAvailable.version}`,
|
||||
'_blank'
|
||||
);
|
||||
}
|
||||
} else if (changelogUnread) {
|
||||
window.open(runtimeConfig.changelogUrl, '_blank');
|
||||
onOpenChangelog();
|
||||
} else {
|
||||
throw new Unreachable();
|
||||
}
|
||||
}, [
|
||||
updateReady,
|
||||
updateAvailable,
|
||||
changelogUnread,
|
||||
onQuitAndInstall,
|
||||
autoDownload,
|
||||
onDownloadUpdate,
|
||||
onOpenChangelog,
|
||||
]);
|
||||
|
||||
const contentProps = useMemo(
|
||||
() => ({
|
||||
updateReady,
|
||||
updateAvailable,
|
||||
changelogUnread,
|
||||
autoDownload,
|
||||
downloadProgress,
|
||||
appQuitting,
|
||||
onDismissChangelog,
|
||||
}),
|
||||
[
|
||||
updateReady,
|
||||
updateAvailable,
|
||||
changelogUnread,
|
||||
autoDownload,
|
||||
downloadProgress,
|
||||
appQuitting,
|
||||
onDismissChangelog,
|
||||
]
|
||||
);
|
||||
|
||||
const ContentComponent = getButtonContentRenderer(contentProps);
|
||||
|
||||
const wrapWithTooltip = (
|
||||
node: React.ReactElement,
|
||||
tooltip?: React.ReactElement | string
|
||||
) => {
|
||||
if (!tooltip) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} side="top">
|
||||
{node}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
if (appQuitting) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (updateAvailable?.allowAutoUpdate) {
|
||||
return !updateReady && autoDownload;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [
|
||||
appQuitting,
|
||||
autoDownload,
|
||||
updateAvailable?.allowAutoUpdate,
|
||||
updateReady,
|
||||
]);
|
||||
|
||||
if (!updateAvailable && !changelogUnread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return wrapWithTooltip(
|
||||
<button
|
||||
style={style}
|
||||
className={clsx([styles.root, className])}
|
||||
data-has-update={!!updateAvailable}
|
||||
data-updating={appQuitting}
|
||||
data-disabled={disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{ContentComponent ? <ContentComponent {...contentProps} /> : null}
|
||||
<div className={styles.particles} aria-hidden="true"></div>
|
||||
<span className={styles.halo} aria-hidden="true"></span>
|
||||
</button>,
|
||||
updateAvailable?.version
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
minHeight: '16px',
|
||||
width: 'calc(100% + 6px)',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '4px',
|
||||
padding: '0 8px',
|
||||
selectors: {
|
||||
'&:not(:first-of-type)': {
|
||||
marginTop: '16px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const label = style({
|
||||
color: cssVar('black30'),
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import { CategoryDivider } from './index';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const fallbackStyle = style({
|
||||
margin: '12px 16px',
|
||||
height: '100%',
|
||||
});
|
||||
export const fallbackHeaderStyle = style({
|
||||
height: '56px',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: '8px',
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const floatingMaxWidth = 768;
|
||||
export const navWrapperStyle = style({
|
||||
zIndex: 3,
|
||||
paddingBottom: '8px',
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
zIndex: -1,
|
||||
},
|
||||
},
|
||||
selectors: {
|
||||
'&[data-has-border=true]': {
|
||||
borderRight: `1px solid ${cssVar('borderColor')}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const navHeaderButton = style({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
export const navHeaderNavigationButtons = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
columnGap: '32px',
|
||||
});
|
||||
export const navStyle = style({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
export const navHeaderStyle = style({
|
||||
flex: '0 0 auto',
|
||||
height: '52px',
|
||||
padding: '0px 16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
selectors: {
|
||||
'&[data-is-macos-electron="true"]': {
|
||||
paddingLeft: '90px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const navBodyStyle = style({
|
||||
flex: '1 1 auto',
|
||||
height: 'calc(100% - 52px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
rowGap: '4px',
|
||||
});
|
||||
export const sidebarFloatMaskStyle = style({
|
||||
transition: 'opacity .15s',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: '100%',
|
||||
bottom: 0,
|
||||
background: cssVar('backgroundModalColor'),
|
||||
selectors: {
|
||||
'&[data-open="true"][data-is-floating="true"]': {
|
||||
opacity: 1,
|
||||
pointerEvents: 'auto',
|
||||
right: '0',
|
||||
zIndex: 3,
|
||||
},
|
||||
},
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
|
||||
export const APP_SIDEBAR_OPEN = 'app-sidebar-open';
|
||||
export const isMobile = window.innerWidth < 768;
|
||||
|
||||
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, !isMobile);
|
||||
export const appSidebarFloatingAtom = atom(isMobile);
|
||||
|
||||
export const appSidebarResizingAtom = atom(false);
|
||||
export const appSidebarWidthAtom = atomWithStorage(
|
||||
'app-sidebar-width',
|
||||
256 /* px */
|
||||
);
|
||||
147
packages/frontend/core/src/components/app-sidebar/index.tsx
Normal file
147
packages/frontend/core/src/components/app-sidebar/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { ResizePanel } from '@affine/component/resize-panel';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { debounce } from 'lodash-es';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { fallbackHeaderStyle, fallbackStyle } from './fallback.css';
|
||||
import {
|
||||
floatingMaxWidth,
|
||||
navBodyStyle,
|
||||
navHeaderStyle,
|
||||
navStyle,
|
||||
navWrapperStyle,
|
||||
sidebarFloatMaskStyle,
|
||||
} from './index.css';
|
||||
import {
|
||||
APP_SIDEBAR_OPEN,
|
||||
appSidebarFloatingAtom,
|
||||
appSidebarOpenAtom,
|
||||
appSidebarResizingAtom,
|
||||
appSidebarWidthAtom,
|
||||
} from './index.jotai';
|
||||
import { SidebarHeader } from './sidebar-header';
|
||||
|
||||
export type AppSidebarProps = PropsWithChildren<{
|
||||
hasBackground?: boolean;
|
||||
}>;
|
||||
|
||||
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 [width, setWidth] = useAtom(appSidebarWidthAtom);
|
||||
const [floating, setFloating] = useAtom(appSidebarFloatingAtom);
|
||||
const [resizing, setResizing] = useAtom(appSidebarResizingAtom);
|
||||
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
const isFloatingMaxWidth = window.matchMedia(
|
||||
`(max-width: ${floatingMaxWidth}px)`
|
||||
).matches;
|
||||
const isOverflowWidth = window.matchMedia(
|
||||
`(max-width: ${width / 0.4}px)`
|
||||
).matches;
|
||||
const isFloating = isFloatingMaxWidth || isOverflowWidth;
|
||||
if (
|
||||
open === undefined &&
|
||||
localStorage.getItem(APP_SIDEBAR_OPEN) === null
|
||||
) {
|
||||
// give the initial value,
|
||||
// so that the sidebar can be closed on mobile by default
|
||||
setOpen(!isFloating);
|
||||
}
|
||||
setFloating(isFloating);
|
||||
}
|
||||
|
||||
const dOnResize = debounce(onResize, 50);
|
||||
window.addEventListener('resize', dOnResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', dOnResize);
|
||||
};
|
||||
}, [open, setFloating, setOpen, width]);
|
||||
|
||||
const transparent = environment.isDesktop && !props.hasBackground;
|
||||
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
|
||||
const hasRightBorder = !environment.isDesktop || !transparent;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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-has-background={environment.isDesktop && props.hasBackground}
|
||||
>
|
||||
<nav className={navStyle} data-testid="app-sidebar">
|
||||
<SidebarHeader />
|
||||
<div className={navBodyStyle} data-testid="sliderBar-inner">
|
||||
{props.children}
|
||||
</div>
|
||||
</nav>
|
||||
</ResizePanel>
|
||||
<div
|
||||
data-testid="app-sidebar-float-mask"
|
||||
data-open={open}
|
||||
data-is-floating={floating}
|
||||
className={sidebarFloatMaskStyle}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const AppSidebarFallback = (): ReactElement | null => {
|
||||
const width = useAtomValue(appSidebarWidthAtom);
|
||||
return (
|
||||
<div
|
||||
style={{ width }}
|
||||
className={navWrapperStyle}
|
||||
data-has-border
|
||||
data-open="true"
|
||||
>
|
||||
<nav className={navStyle}>
|
||||
<div className={navHeaderStyle} data-open="true" />
|
||||
<div className={navBodyStyle}>
|
||||
<div className={fallbackStyle}>
|
||||
<div className={fallbackHeaderStyle}>
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
<Skeleton variant="rectangular" width={150} height={40} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export * from './add-page-button';
|
||||
export * from './app-download-button';
|
||||
export * from './app-updater-button';
|
||||
export * from './category-divider';
|
||||
export * from './index.css';
|
||||
export * from './menu-item';
|
||||
export * from './quick-search-input';
|
||||
export * from './sidebar-containers';
|
||||
export * from './sidebar-header';
|
||||
export { appSidebarFloatingAtom, appSidebarOpenAtom, appSidebarResizingAtom };
|
||||
@@ -0,0 +1,123 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const linkItemRoot = style({
|
||||
color: 'inherit',
|
||||
display: 'contents',
|
||||
});
|
||||
export const root = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'left',
|
||||
color: 'inherit',
|
||||
width: '100%',
|
||||
minHeight: '30px',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0 12px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
marginTop: '4px',
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVar('hoverColor'),
|
||||
},
|
||||
'&[data-active="true"]': {
|
||||
background: cssVar('hoverColor'),
|
||||
},
|
||||
'&[data-disabled="true"]': {
|
||||
cursor: 'default',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// this is not visible in dark mode
|
||||
// '&[data-active="true"]:hover': {
|
||||
// background:
|
||||
// // make this a variable?
|
||||
// 'linear-gradient(0deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.04)), rgba(0, 0, 0, 0.04)',
|
||||
// },
|
||||
'&[data-collapsible="true"]': {
|
||||
paddingLeft: '4px',
|
||||
paddingRight: '4px',
|
||||
},
|
||||
'&[data-type="collection-list-item"][data-collapsible="false"][data-active="true"],&[data-type="reference-page"][data-collapsible="false"][data-active="true"], &[data-type="reference-page"][data-collapsible="false"]:hover, &[data-type="collection-list-item"][data-collapsible="false"]:hover':
|
||||
{
|
||||
width: 'calc(100% + 8px)',
|
||||
transform: 'translateX(-8px)',
|
||||
paddingLeft: '20px',
|
||||
paddingRight: '12px',
|
||||
},
|
||||
[`${linkItemRoot}:first-of-type &`]: {
|
||||
marginTop: '0px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const content = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
});
|
||||
export const postfix = style({
|
||||
right: '4px',
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
selectors: {
|
||||
[`${root}:hover &`]: {
|
||||
justifySelf: 'flex-end',
|
||||
position: 'initial',
|
||||
opacity: 1,
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const icon = style({
|
||||
color: cssVar('iconColor'),
|
||||
fontSize: '20px',
|
||||
});
|
||||
export const collapsedIconContainer = style({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '2px',
|
||||
transition: 'transform 0.2s',
|
||||
color: 'inherit',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
'&[data-disabled="true"]': {
|
||||
opacity: 0.3,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:hover': {
|
||||
background: cssVar('hoverColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const iconsContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
width: '28px',
|
||||
flexShrink: 0,
|
||||
selectors: {
|
||||
'&[data-collapsible="true"]': {
|
||||
width: '44px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const collapsedIcon = style({
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const spacer = style({
|
||||
flex: 1,
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { SettingsIcon } from '@blocksuite/icons';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { MenuItem, MenuLinkItem } from './index';
|
||||
|
||||
export default {
|
||||
title: 'Components/AppSidebar/MenuItem',
|
||||
component: MenuItem,
|
||||
} satisfies Meta;
|
||||
|
||||
export const Default: StoryFn = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<main style={{ width: '240px' }}>
|
||||
<MenuItem icon={<SettingsIcon />} onClick={() => alert('opened')}>
|
||||
Normal Item
|
||||
</MenuItem>
|
||||
<MenuLinkItem
|
||||
icon={<SettingsIcon />}
|
||||
to="/test"
|
||||
onClick={() => alert('opened')}
|
||||
>
|
||||
Normal Link Item
|
||||
</MenuLinkItem>
|
||||
<MenuLinkItem
|
||||
active
|
||||
icon={<SettingsIcon />}
|
||||
to="/test"
|
||||
onClick={() => alert('opened')}
|
||||
>
|
||||
Primary Item
|
||||
</MenuLinkItem>
|
||||
<MenuItem
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
icon={<SettingsIcon />}
|
||||
onClick={() => alert('opened')}
|
||||
>
|
||||
Collapsible Item
|
||||
</MenuItem>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import type { To } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
export interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactElement;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
// true, false, undefined. undefined means no collapse
|
||||
collapsed?: boolean;
|
||||
// if onCollapsedChange is given, but collapsed is undefined, then we will render the collapse button as disabled
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
postfix?: React.ReactElement;
|
||||
}
|
||||
|
||||
export interface MenuLinkItemProps extends MenuItemProps {
|
||||
to: To;
|
||||
linkComponent?: React.ComponentType<{ to: To; className: string }>;
|
||||
}
|
||||
|
||||
const stopPropagation: React.MouseEventHandler = e => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
|
||||
(
|
||||
{
|
||||
onClick,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
disabled,
|
||||
collapsed,
|
||||
onCollapsedChange,
|
||||
postfix,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const collapsible = onCollapsedChange !== undefined;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
onClick={onClick}
|
||||
className={clsx([styles.root, props.className])}
|
||||
data-active={active}
|
||||
data-disabled={disabled}
|
||||
data-collapsible={collapsible}
|
||||
>
|
||||
{icon && (
|
||||
<div className={styles.iconsContainer} data-collapsible={collapsible}>
|
||||
{collapsible && (
|
||||
<div
|
||||
data-disabled={collapsed === undefined ? true : undefined}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault(); // for links
|
||||
onCollapsedChange?.(!collapsed);
|
||||
}}
|
||||
data-testid="fav-collapsed-button"
|
||||
className={styles.collapsedIconContainer}
|
||||
>
|
||||
<ArrowDownSmallIcon
|
||||
className={styles.collapsedIcon}
|
||||
data-collapsed={collapsed !== false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{React.cloneElement(icon, {
|
||||
className: clsx([styles.icon, icon.props.className]),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.content}>{children}</div>
|
||||
{postfix ? (
|
||||
<div className={styles.postfix} onClick={stopPropagation}>
|
||||
{postfix}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
MenuItem.displayName = 'MenuItem';
|
||||
|
||||
export const MenuLinkItem = React.forwardRef<HTMLDivElement, MenuLinkItemProps>(
|
||||
({ to, linkComponent: LinkComponent = Link, ...props }, ref) => {
|
||||
return (
|
||||
<LinkComponent to={to} className={styles.linkItemRoot}>
|
||||
{/* The <a> element rendered by Link does not generate display box due to `display: contents` style */}
|
||||
{/* Thus ref is passed to MenuItem instead of Link */}
|
||||
<MenuItem ref={ref} {...props}></MenuItem>
|
||||
</LinkComponent>
|
||||
);
|
||||
}
|
||||
);
|
||||
MenuLinkItem.displayName = 'MenuLinkItem';
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'inline-flex',
|
||||
background: cssVar('white10'),
|
||||
alignItems: 'center',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${cssVar('black10')}`,
|
||||
fontSize: cssVar('fontSm'),
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0 12px',
|
||||
margin: '20px 0',
|
||||
position: 'relative',
|
||||
});
|
||||
export const icon = style({
|
||||
marginRight: '8px',
|
||||
color: cssVar('iconColor'),
|
||||
fontSize: '20px',
|
||||
});
|
||||
export const spacer = style({
|
||||
flex: 1,
|
||||
});
|
||||
export const shortcutHint = style({
|
||||
color: cssVar('black30'),
|
||||
fontSize: cssVar('fontBase'),
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import { QuickSearchInput } from './index';
|
||||
|
||||
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,33 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { SearchIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Spotlight } from '../spolight';
|
||||
import * as styles from './index.css';
|
||||
|
||||
interface QuickSearchInputProps extends 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 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>
|
||||
<Spotlight />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const baseContainer = style({
|
||||
padding: '4px 16px',
|
||||
display: 'flex',
|
||||
flexFlow: 'column nowrap',
|
||||
});
|
||||
export const scrollableContainerRoot = style({
|
||||
flex: '1 1 auto',
|
||||
overflowY: 'hidden',
|
||||
vars: {
|
||||
'--scrollbar-width': '10px',
|
||||
},
|
||||
});
|
||||
export const scrollTopBorder = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
height: '1px',
|
||||
transition: 'opacity .3s .2s',
|
||||
opacity: 0,
|
||||
background: cssVar('black10'),
|
||||
selectors: {
|
||||
'&[data-has-scroll-top="true"]': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const scrollableViewport = style({
|
||||
height: '100%',
|
||||
marginTop: '4px',
|
||||
});
|
||||
globalStyle(`${scrollableViewport} > div`, {
|
||||
maxWidth: '100%',
|
||||
display: 'block !important',
|
||||
});
|
||||
export const scrollableContainer = style([
|
||||
baseContainer,
|
||||
{
|
||||
height: '100%',
|
||||
padding: '4px 8px',
|
||||
},
|
||||
]);
|
||||
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: cssVar('black30'),
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useHasScrollTop } from '@affine/component';
|
||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||
import clsx from 'clsx';
|
||||
import { type PropsWithChildren, useRef } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
export function SidebarContainer({ children }: PropsWithChildren) {
|
||||
return <div className={clsx([styles.baseContainer])}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const hasScrollTop = useHasScrollTop(ref);
|
||||
return (
|
||||
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
||||
<div
|
||||
data-has-scroll-top={hasScrollTop}
|
||||
className={styles.scrollTopBorder}
|
||||
/>
|
||||
<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,23 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { NavigationButtons } from '../../../modules/navigation';
|
||||
import { navHeaderStyle } from '../index.css';
|
||||
import { appSidebarOpenAtom } from '../index.jotai';
|
||||
import { SidebarSwitch } from './sidebar-switch';
|
||||
|
||||
export const SidebarHeader = () => {
|
||||
const open = useAtomValue(appSidebarOpenAtom);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={navHeaderStyle}
|
||||
data-open={open}
|
||||
data-is-macos-electron={environment.isDesktop && environment.isMacOs}
|
||||
>
|
||||
<SidebarSwitch show={open} />
|
||||
<NavigationButtons />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export * from './sidebar-switch';
|
||||
@@ -0,0 +1,18 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const sidebarSwitch = style({
|
||||
opacity: 0,
|
||||
display: 'none !important',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
transition: 'all .3s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-show=true]': {
|
||||
opacity: 1,
|
||||
display: 'inline-flex !important',
|
||||
width: '32px',
|
||||
flexShrink: 0,
|
||||
fontSize: '24px',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IconButton, Tooltip } from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { SidebarIcon } from '@blocksuite/icons';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { appSidebarOpenAtom } from '../index.jotai';
|
||||
import * as styles from './sidebar-switch.css';
|
||||
|
||||
export const SidebarSwitch = ({
|
||||
show = true,
|
||||
className,
|
||||
}: {
|
||||
show?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [open, setOpen] = useAtom(appSidebarOpenAtom);
|
||||
const t = useAFFiNEI18N();
|
||||
const tooltipContent = open
|
||||
? t['com.affine.sidebarSwitch.collapse']()
|
||||
: t['com.affine.sidebarSwitch.expand']();
|
||||
const collapseKeyboardShortcuts =
|
||||
environment.isBrowser && environment.isMacOs ? ' ⌘+/' : ' Ctrl+/';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={tooltipContent + ' ' + collapseKeyboardShortcuts}
|
||||
side={open ? 'bottom' : 'right'}
|
||||
>
|
||||
<IconButton
|
||||
className={clsx(styles.sidebarSwitch, className)}
|
||||
data-show={show}
|
||||
size="large"
|
||||
data-testid={`app-sidebar-arrow-button-${open ? 'collapse' : 'expand'}`}
|
||||
style={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
onClick={() => setOpen(open => !open)}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
export const spotlightX = createVar();
|
||||
export const spotlightY = createVar();
|
||||
export const spotlightOpacity = createVar();
|
||||
export const spotlightSize = createVar();
|
||||
export const spotlight = style({
|
||||
vars: {
|
||||
[spotlightX]: '0px',
|
||||
[spotlightY]: '0px',
|
||||
[spotlightOpacity]: '0',
|
||||
[spotlightSize]: '64px',
|
||||
},
|
||||
position: 'absolute',
|
||||
background: `radial-gradient(${spotlightSize} circle at ${spotlightX} ${spotlightY}, var(--affine-text-primary-color), transparent)`,
|
||||
inset: '0px',
|
||||
pointerEvents: 'none',
|
||||
willChange: 'background, opacity',
|
||||
opacity: spotlightOpacity,
|
||||
zIndex: 1,
|
||||
transition: 'all 0.2s',
|
||||
borderRadius: 'inherit',
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
import { Spotlight } from './index';
|
||||
|
||||
export default {
|
||||
title: 'Components/AppSidebar/Spotlight',
|
||||
component: Spotlight,
|
||||
} satisfies Meta;
|
||||
|
||||
const Container = ({ children }: PropsWithChildren) => (
|
||||
<main
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '320px',
|
||||
height: '320px',
|
||||
border: '1px solid #ccc',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
|
||||
export const Default: StoryFn = () => {
|
||||
return (
|
||||
<Container>
|
||||
<Spotlight />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import { useTheme } from 'next-themes';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
function useMouseOffset() {
|
||||
const [offset, setOffset] = React.useState<{ x: number; y: number }>();
|
||||
const [outside, setOutside] = React.useState(true);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && ref.current.parentElement) {
|
||||
const el = ref.current.parentElement;
|
||||
|
||||
// debounce?
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const bound = el.getBoundingClientRect();
|
||||
setOffset({ x: e.clientX - bound.x, y: e.clientY - bound.y });
|
||||
setOutside(false);
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
setOutside(true);
|
||||
};
|
||||
el.addEventListener('mousemove', onMouseMove);
|
||||
el.addEventListener('mouseleave', onMouseLeave);
|
||||
return () => {
|
||||
el.removeEventListener('mousemove', onMouseMove);
|
||||
el.removeEventListener('mouseleave', onMouseLeave);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, []);
|
||||
|
||||
return [offset, outside, ref] as const;
|
||||
}
|
||||
|
||||
export function Spotlight() {
|
||||
const [offset, outside, ref] = useMouseOffset();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const offsetVars = assignInlineVars({
|
||||
[styles.spotlightX]: (offset?.x ?? 0) + 'px',
|
||||
[styles.spotlightY]: (offset?.y ?? 0) + 'px',
|
||||
[styles.spotlightOpacity]: outside ? '0' : isDark ? '.1' : '0.07',
|
||||
});
|
||||
|
||||
return <div style={offsetVars} ref={ref} className={styles.spotlight} />;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import { useHasScrollTop } from '@affine/component/app-sidebar';
|
||||
import { Scrollable, useHasScrollTop } from '@affine/component';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type ForwardedRef,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import {
|
||||
appSidebarFloatingAtom,
|
||||
appSidebarOpenAtom,
|
||||
} from '@affine/component/app-sidebar';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { appSidebarFloatingAtom, appSidebarOpenAtom } from '../../app-sidebar';
|
||||
import * as style from './style.css';
|
||||
|
||||
interface HeaderPros {
|
||||
|
||||
@@ -47,13 +47,25 @@ export const WorkspaceModeFilterTab = ({
|
||||
|
||||
return (
|
||||
<RadioButtonGroup value={value} onValueChange={handleValueChange}>
|
||||
<RadioButton spanStyle={styles.filterTab} value="docs">
|
||||
<RadioButton
|
||||
spanStyle={styles.filterTab}
|
||||
value="docs"
|
||||
data-testid="workspace-docs-button"
|
||||
>
|
||||
{t['com.affine.docs.header']()}
|
||||
</RadioButton>
|
||||
<RadioButton spanStyle={styles.filterTab} value="collections">
|
||||
<RadioButton
|
||||
spanStyle={styles.filterTab}
|
||||
value="collections"
|
||||
data-testid="workspace-collections-button"
|
||||
>
|
||||
{t['com.affine.collections.header']()}
|
||||
</RadioButton>
|
||||
<RadioButton spanStyle={styles.filterTab} value="tags">
|
||||
<RadioButton
|
||||
spanStyle={styles.filterTab}
|
||||
value="tags"
|
||||
data-testid="workspace-tags-button"
|
||||
>
|
||||
{t['Tags']()}
|
||||
</RadioButton>
|
||||
</RadioButtonGroup>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AnimatedCollectionsIcon, toast } from '@affine/component';
|
||||
import { MenuLinkItem as SidebarMenuLinkItem } from '@affine/component/app-sidebar';
|
||||
import { RenameModal } from '@affine/component/rename-modal';
|
||||
import { Button, IconButton } from '@affine/component/ui/button';
|
||||
import {
|
||||
@@ -23,6 +22,7 @@ import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag';
|
||||
import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta';
|
||||
import { Workbench } from '../../../../modules/workbench';
|
||||
import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link';
|
||||
import { MenuLinkItem as SidebarMenuLinkItem } from '../../../app-sidebar';
|
||||
import type { CollectionsListProps } from '../index';
|
||||
import { Page } from './page';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MenuItem as CollectionItem } from '@affine/component/app-sidebar';
|
||||
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
@@ -11,6 +10,7 @@ import { useParams } from 'react-router-dom';
|
||||
|
||||
import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag';
|
||||
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
|
||||
import { MenuItem as CollectionItem } from '../../../app-sidebar';
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import { PostfixItem } from '../components/postfix-item';
|
||||
import { ReferencePage } from '../components/reference-page';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MenuLinkItem } from '@affine/component/app-sidebar';
|
||||
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
@@ -8,6 +7,7 @@ import { PageRecordList, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { MenuLinkItem } from '../../../app-sidebar';
|
||||
import * as styles from '../favorite/styles.css';
|
||||
import { PostfixItem } from './postfix-item';
|
||||
export interface ReferencePageProps {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MenuLinkItem } from '@affine/component/app-sidebar';
|
||||
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
@@ -11,6 +10,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { getDragItemId } from '../../../../hooks/affine/use-sidebar-drag';
|
||||
import { MenuLinkItem } from '../../../app-sidebar';
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import { PostfixItem } from '../components/postfix-item';
|
||||
import {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MenuItem } from '@affine/component/app-sidebar';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ImportIcon } from '@blocksuite/icons';
|
||||
|
||||
import type { BlockSuiteWorkspace } from '../../shared';
|
||||
import { MenuItem } from '../app-sidebar';
|
||||
import { usePageHelper } from '../blocksuite/block-suite-page-list/utils';
|
||||
|
||||
const ImportPage = ({
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import { AnimatedDeleteIcon } from '@affine/component';
|
||||
import {
|
||||
AddPageButton,
|
||||
AppDownloadButton,
|
||||
AppSidebar,
|
||||
appSidebarOpenAtom,
|
||||
CategoryDivider,
|
||||
MenuItem,
|
||||
MenuLinkItem,
|
||||
QuickSearchInput,
|
||||
SidebarContainer,
|
||||
SidebarScrollableContainer,
|
||||
} from '@affine/component/app-sidebar';
|
||||
import { Menu } from '@affine/component/ui/menu';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
@@ -23,19 +11,27 @@ import { useLiveData, useService, type Workspace } from '@toeverything/infra';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { HTMLAttributes, ReactElement } from 'react';
|
||||
import { forwardRef, Suspense, useCallback, useEffect, useMemo } from 'react';
|
||||
import { forwardRef, Suspense, useCallback, useEffect } from 'react';
|
||||
|
||||
import { openWorkspaceListModalAtom } from '../../atoms';
|
||||
import { useHistoryAtom } from '../../atoms/history';
|
||||
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
||||
import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info';
|
||||
import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts';
|
||||
import { getDropItemId } from '../../hooks/affine/use-sidebar-drag';
|
||||
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
|
||||
import { useRegisterBrowserHistoryCommands } from '../../hooks/use-browser-history-commands';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { Workbench } from '../../modules/workbench';
|
||||
import { WorkspaceSubPath } from '../../shared';
|
||||
import {
|
||||
AddPageButton,
|
||||
AppDownloadButton,
|
||||
AppSidebar,
|
||||
appSidebarOpenAtom,
|
||||
CategoryDivider,
|
||||
MenuItem,
|
||||
MenuLinkItem,
|
||||
QuickSearchInput,
|
||||
SidebarContainer,
|
||||
SidebarScrollableContainer,
|
||||
} from '../app-sidebar';
|
||||
import {
|
||||
createEmptyCollection,
|
||||
MoveToTrash,
|
||||
@@ -109,7 +105,6 @@ export const RootAppSidebar = ({
|
||||
const [openUserWorkspaceList, setOpenUserWorkspaceList] = useAtom(
|
||||
openWorkspaceListModalAtom
|
||||
);
|
||||
const generalShortcutsInfo = useGeneralShortcuts();
|
||||
const currentPath = useLiveData(useService(Workbench).location).pathname;
|
||||
|
||||
const onClickNewPage = useAsyncCallback(async () => {
|
||||
@@ -133,9 +128,6 @@ export const RootAppSidebar = ({
|
||||
);
|
||||
|
||||
const navigateHelper = useNavigateHelper();
|
||||
const backToAll = useCallback(() => {
|
||||
navigateHelper.jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
|
||||
}, [currentWorkspace.id, navigateHelper]);
|
||||
// Listen to the "New Page" action from the menu
|
||||
useEffect(() => {
|
||||
if (environment.isDesktop) {
|
||||
@@ -153,19 +145,6 @@ export const RootAppSidebar = ({
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
|
||||
const [history, setHistory] = useHistoryAtom();
|
||||
const router = useMemo(() => {
|
||||
return {
|
||||
forward: () => {
|
||||
setHistory(true);
|
||||
},
|
||||
back: () => {
|
||||
setHistory(false);
|
||||
},
|
||||
history,
|
||||
};
|
||||
}, [history, setHistory]);
|
||||
|
||||
const dropItemId = getDropItemId('trash');
|
||||
const trashDroppable = useDroppable({
|
||||
id: dropItemId,
|
||||
@@ -173,7 +152,6 @@ export const RootAppSidebar = ({
|
||||
const closeUserWorkspaceList = useCallback(() => {
|
||||
setOpenUserWorkspaceList(false);
|
||||
}, [setOpenUserWorkspaceList]);
|
||||
useRegisterBrowserHistoryCommands(router.back, router.forward);
|
||||
const userInfo = useDeleteCollectionInfo();
|
||||
|
||||
const collection = useService(CollectionService);
|
||||
@@ -199,7 +177,6 @@ export const RootAppSidebar = ({
|
||||
|
||||
return (
|
||||
<AppSidebar
|
||||
router={router}
|
||||
hasBackground={
|
||||
!(
|
||||
appSettings.enableBlurBackground &&
|
||||
@@ -207,7 +184,6 @@ export const RootAppSidebar = ({
|
||||
environment.isMacOs
|
||||
)
|
||||
}
|
||||
generalShortcutsInfo={generalShortcutsInfo}
|
||||
>
|
||||
<MoveToTrash.ConfirmModal
|
||||
open={trashConfirmOpen}
|
||||
@@ -249,7 +225,6 @@ export const RootAppSidebar = ({
|
||||
icon={<FolderIcon />}
|
||||
active={allPageActive}
|
||||
path={paths.all(currentWorkspaceId)}
|
||||
onClick={backToAll}
|
||||
>
|
||||
<span data-testid="all-pages">
|
||||
{t['com.affine.workspaceSubPath.all']()}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MenuItem } from '@affine/component/app-sidebar';
|
||||
import {
|
||||
useJournalInfoHelper,
|
||||
useJournalRouteHelper,
|
||||
@@ -9,6 +8,8 @@ import { TodayIcon, TomorrowIcon, YesterdayIcon } from '@blocksuite/icons';
|
||||
import { Doc, useServiceOptional } from '@toeverything/infra';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { MenuItem } from '../app-sidebar';
|
||||
|
||||
interface AppSidebarJournalButtonProps {
|
||||
workspace: BlockSuiteWorkspace;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppUpdaterButton } from '@affine/component/app-sidebar/app-updater-button';
|
||||
import { useAppUpdater } from '@affine/core/hooks/use-app-updater';
|
||||
|
||||
import { AppUpdaterButton } from '../app-sidebar';
|
||||
|
||||
export const UpdaterButton = () => {
|
||||
const appUpdater = useAppUpdater();
|
||||
|
||||
|
||||
103
packages/frontend/core/src/components/workspace/index.css.ts
Normal file
103
packages/frontend/core/src/components/workspace/index.css.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { lightCssVariables } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const appStyle = style({
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexGrow: '1',
|
||||
flexDirection: 'row',
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
selectors: {
|
||||
'&[data-is-resizing="true"]': {
|
||||
cursor: 'col-resize',
|
||||
},
|
||||
'&.blur-background': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&.noisy-background::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: `var(--affine-noise-opacity, 0)`,
|
||||
backgroundRepeat: 'repeat',
|
||||
backgroundSize: '3%',
|
||||
// todo: figure out how to use vanilla-extract webpack plugin to inject img url
|
||||
backgroundImage: `var(--noise-background)`,
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`html[data-theme="light"] ${appStyle}`, {
|
||||
vars: {
|
||||
'--affine-noise-opacity': '0.35',
|
||||
},
|
||||
});
|
||||
globalStyle(`html[data-theme="dark"] ${appStyle}`, {
|
||||
vars: {
|
||||
'--affine-noise-opacity': '1',
|
||||
},
|
||||
'@media': {
|
||||
print: {
|
||||
vars: lightCssVariables,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const mainContainerStyle = style({
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
// it will create stacking context to limit layer of child elements and be lower than after auto zIndex
|
||||
width: 0,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
maxWidth: '100%',
|
||||
selectors: {
|
||||
'&[data-show-padding="true"]': {
|
||||
margin: '8px',
|
||||
overflow: 'hidden',
|
||||
// todo: is this performance intensive?
|
||||
filter: 'drop-shadow(0px 0px 4px rgba(66,65,73,.14))',
|
||||
'@media': {
|
||||
print: {
|
||||
overflow: 'visible',
|
||||
margin: '0px',
|
||||
borderRadius: '0px',
|
||||
},
|
||||
},
|
||||
},
|
||||
'&[data-show-padding="true"]:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
height: '8px',
|
||||
width: '100%',
|
||||
top: '-8px',
|
||||
left: 0,
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
},
|
||||
'&[data-transparent=true]': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const toolStyle = style({
|
||||
position: 'absolute',
|
||||
right: '30px',
|
||||
bottom: '30px',
|
||||
zIndex: cssVar('zIndexPopover'),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
'@media': {
|
||||
'screen and (max-width: 960px)': {
|
||||
right: 'calc((100vw - 640px) * 3 / 19 + 14px)',
|
||||
},
|
||||
'screen and (max-width: 640px)': {
|
||||
right: '5px',
|
||||
bottom: '5px',
|
||||
},
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
75
packages/frontend/core/src/components/workspace/index.tsx
Normal file
75
packages/frontend/core/src/components/workspace/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { clsx } from 'clsx';
|
||||
import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { AppSidebarFallback } from '../app-sidebar';
|
||||
import { appStyle, mainContainerStyle, toolStyle } from './index.css';
|
||||
|
||||
export type WorkspaceRootProps = PropsWithChildren<{
|
||||
resizing?: boolean;
|
||||
useNoisyBackground?: boolean;
|
||||
useBlurBackground?: boolean;
|
||||
}>;
|
||||
|
||||
export const AppContainer = ({
|
||||
resizing,
|
||||
useNoisyBackground,
|
||||
useBlurBackground,
|
||||
children,
|
||||
}: WorkspaceRootProps) => {
|
||||
const noisyBackground = useNoisyBackground && environment.isDesktop;
|
||||
return (
|
||||
<div
|
||||
className={clsx(appStyle, {
|
||||
'noisy-background': noisyBackground,
|
||||
'blur-background': environment.isDesktop && useBlurBackground,
|
||||
})}
|
||||
data-noise-background={noisyBackground}
|
||||
data-is-resizing={resizing}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface MainContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
padding?: boolean;
|
||||
transparent?: boolean;
|
||||
}
|
||||
|
||||
export const MainContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<MainContainerProps>
|
||||
>(function MainContainer(
|
||||
{ className, padding, children, transparent, ...props },
|
||||
ref
|
||||
): ReactElement {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(mainContainerStyle, className)}
|
||||
data-is-macos={environment.isDesktop && environment.isMacOs}
|
||||
data-show-padding={!!padding}
|
||||
data-transparent={transparent}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MainContainer.displayName = 'MainContainer';
|
||||
|
||||
export const ToolContainer = (props: PropsWithChildren): ReactElement => {
|
||||
return <div className={toolStyle}>{props.children}</div>;
|
||||
};
|
||||
|
||||
export const WorkspaceFallback = (): ReactElement => {
|
||||
return (
|
||||
<AppContainer>
|
||||
<AppSidebarFallback />
|
||||
<MainContainer />
|
||||
</AppContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { appSidebarOpenAtom } from '@affine/component/app-sidebar';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { appSidebarOpenAtom } from '../../components/app-sidebar';
|
||||
|
||||
export function useSwitchSidebarStatus() {
|
||||
const [isOpened, setOpened] = useAtom(appSidebarOpenAtom);
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
AppSidebarFallback,
|
||||
appSidebarResizingAtom,
|
||||
} from '@affine/component/app-sidebar';
|
||||
import { MainContainer, WorkspaceFallback } from '@affine/component/workspace';
|
||||
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
@@ -26,12 +21,17 @@ import { Map as YMap } from 'yjs';
|
||||
import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms';
|
||||
import { AppContainer } from '../components/affine/app-container';
|
||||
import { SyncAwareness } from '../components/affine/awareness';
|
||||
import {
|
||||
AppSidebarFallback,
|
||||
appSidebarResizingAtom,
|
||||
} from '../components/app-sidebar';
|
||||
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
|
||||
import {
|
||||
type DraggableTitleCellData,
|
||||
PageListDragOverlay,
|
||||
} from '../components/page-list';
|
||||
import { RootAppSidebar } from '../components/root-app-sidebar';
|
||||
import { MainContainer, WorkspaceFallback } from '../components/workspace';
|
||||
import { WorkspaceUpgrade } from '../components/workspace-upgrade';
|
||||
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
|
||||
import { useSidebarDrag } from '../hooks/affine/use-sidebar-drag';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import type { Location } from 'history';
|
||||
import { Observable, switchMap } from 'rxjs';
|
||||
|
||||
import type { Workbench } from '../../workbench';
|
||||
|
||||
export class Navigator {
|
||||
constructor(private readonly workbench: Workbench) {}
|
||||
|
||||
private readonly history = this.workbench.activeView.map(
|
||||
view => view.history
|
||||
);
|
||||
|
||||
private readonly location = LiveData.from(
|
||||
this.history.pipe(
|
||||
switchMap(
|
||||
history =>
|
||||
new Observable<{ index: number; entries: Location[] }>(subscriber => {
|
||||
subscriber.next({ index: history.index, entries: history.entries });
|
||||
return history.listen(() => {
|
||||
subscriber.next({
|
||||
index: history.index,
|
||||
entries: history.entries,
|
||||
});
|
||||
});
|
||||
})
|
||||
)
|
||||
),
|
||||
{ index: 0, entries: [] }
|
||||
);
|
||||
|
||||
readonly backable = this.location.map(
|
||||
({ index, entries }) => index > 0 && entries.length > 1
|
||||
);
|
||||
|
||||
readonly forwardable = this.location.map(
|
||||
({ index, entries }) => index < entries.length - 1
|
||||
);
|
||||
|
||||
back() {
|
||||
if (!environment.isDesktop) {
|
||||
window.history.back();
|
||||
} else {
|
||||
this.history.value.back();
|
||||
}
|
||||
}
|
||||
|
||||
forward() {
|
||||
if (!environment.isDesktop) {
|
||||
window.history.forward();
|
||||
} else {
|
||||
this.history.value.forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/frontend/core/src/modules/navigation/index.ts
Normal file
2
packages/frontend/core/src/modules/navigation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Navigator } from './entities/navigator';
|
||||
export { NavigationButtons } from './view/navigation-buttons';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const container = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
columnGap: '32px',
|
||||
});
|
||||
|
||||
export const button = style({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { IconButton, Tooltip } from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
|
||||
import { useService } from '@toeverything/infra/di';
|
||||
import { useLiveData } from '@toeverything/infra/livedata';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useGeneralShortcuts } from '../../../hooks/affine/use-shortcuts';
|
||||
import { Navigator } from '../entities/navigator';
|
||||
import * as styles from './navigation-buttons.css';
|
||||
import { useRegisterNavigationCommands } from './use-register-navigation-commands';
|
||||
|
||||
export const NavigationButtons = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const shortcuts = useGeneralShortcuts().shortcuts;
|
||||
|
||||
useRegisterNavigationCommands();
|
||||
|
||||
const shortcutsObject = useMemo(() => {
|
||||
const goBack = t['com.affine.keyboardShortcuts.goBack']();
|
||||
const goBackShortcut = shortcuts?.[goBack];
|
||||
|
||||
const goForward = t['com.affine.keyboardShortcuts.goForward']();
|
||||
const goForwardShortcut = shortcuts?.[goForward];
|
||||
return {
|
||||
goBack,
|
||||
goBackShortcut,
|
||||
goForward,
|
||||
goForwardShortcut,
|
||||
};
|
||||
}, [shortcuts, t]);
|
||||
|
||||
const navigator = useService(Navigator);
|
||||
|
||||
const backable = useLiveData(navigator.backable);
|
||||
const forwardable = useLiveData(navigator.forwardable);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigator.back();
|
||||
}, [navigator]);
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
navigator.forward();
|
||||
}, [navigator]);
|
||||
|
||||
useEffect(() => {
|
||||
const cb = (event: MouseEvent) => {
|
||||
if (event.button === 3 || event.button === 4) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.button === 3) {
|
||||
navigator.back();
|
||||
} else {
|
||||
navigator.forward();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('mouseup', cb);
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', cb);
|
||||
};
|
||||
}, [navigator]);
|
||||
|
||||
if (!environment.isDesktop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Tooltip
|
||||
content={`${shortcutsObject.goBack} ${shortcutsObject.goBackShortcut}`}
|
||||
side="bottom"
|
||||
>
|
||||
<IconButton
|
||||
className={styles.button}
|
||||
data-testid="app-navigation-button-back"
|
||||
disabled={!backable}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeftSmallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={`${shortcutsObject.goForward} ${shortcutsObject.goForwardShortcut}`}
|
||||
side="bottom"
|
||||
>
|
||||
<IconButton
|
||||
className={styles.button}
|
||||
data-testid="app-navigation-button-forward"
|
||||
disabled={!forwardable}
|
||||
onClick={handleForward}
|
||||
>
|
||||
<ArrowRightSmallIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,12 +2,13 @@ import {
|
||||
PreconditionStrategy,
|
||||
registerAffineCommand,
|
||||
} from '@toeverything/infra/command';
|
||||
import { useService } from '@toeverything/infra/di';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useRegisterBrowserHistoryCommands(
|
||||
back: () => unknown,
|
||||
forward: () => unknown
|
||||
) {
|
||||
import { Navigator } from '../entities/navigator';
|
||||
|
||||
export function useRegisterNavigationCommands() {
|
||||
const navigator = useService(Navigator);
|
||||
useEffect(() => {
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
@@ -22,7 +23,7 @@ export function useRegisterBrowserHistoryCommands(
|
||||
binding: '$mod+[',
|
||||
},
|
||||
run() {
|
||||
back();
|
||||
navigator.back();
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -37,7 +38,7 @@ export function useRegisterBrowserHistoryCommands(
|
||||
binding: '$mod+]',
|
||||
},
|
||||
run() {
|
||||
forward();
|
||||
navigator.forward();
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -45,5 +46,5 @@ export function useRegisterBrowserHistoryCommands(
|
||||
return () => {
|
||||
unsubs.forEach(unsub => unsub());
|
||||
};
|
||||
}, [back, forward]);
|
||||
}, [navigator]);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
LocalStorageGlobalCache,
|
||||
LocalStorageGlobalState,
|
||||
} from './infra-web/storage';
|
||||
import { Navigator } from './navigation';
|
||||
import { RightSidebar } from './right-sidebar/entities/right-sidebar';
|
||||
import { Workbench } from './workbench';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ export function configureBusinessServices(services: ServiceCollection) {
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.add(Workbench)
|
||||
.add(Navigator, [Workbench])
|
||||
.add(RightSidebar)
|
||||
.add(WorkspacePropertiesAdapter, [Workspace])
|
||||
.add(CollectionService, [Workspace])
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import type { Location, To } from 'history';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { createIsland } from '../../../utils/island';
|
||||
import { createNavigableHistory } from '../../../utils/navigable-history';
|
||||
|
||||
export class View {
|
||||
constructor(defaultPath: To = { pathname: '/all' }) {
|
||||
this.history = createNavigableHistory({
|
||||
initialEntries: [defaultPath],
|
||||
initialIndex: 0,
|
||||
});
|
||||
}
|
||||
|
||||
id = nanoid();
|
||||
|
||||
history = createMemoryHistory();
|
||||
history = createNavigableHistory({
|
||||
initialEntries: ['/all'],
|
||||
initialIndex: 0,
|
||||
});
|
||||
|
||||
location = LiveData.from<Location>(
|
||||
new Observable(subscriber => {
|
||||
@@ -20,6 +30,17 @@ export class View {
|
||||
}),
|
||||
this.history.location
|
||||
);
|
||||
|
||||
entries = LiveData.from<Location[]>(
|
||||
new Observable(subscriber => {
|
||||
subscriber.next(this.history.entries);
|
||||
return this.history.listen(() => {
|
||||
subscriber.next(this.history.entries);
|
||||
});
|
||||
}),
|
||||
this.history.entries
|
||||
);
|
||||
|
||||
size = new LiveData(100);
|
||||
|
||||
header = createIsland();
|
||||
|
||||
@@ -27,8 +27,8 @@ export class Workbench {
|
||||
this.activeViewIndex.next(index);
|
||||
}
|
||||
|
||||
createView(at: WorkbenchPosition = 'beside') {
|
||||
const view = new View();
|
||||
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
|
||||
const view = new View(defaultLocation);
|
||||
const newViews = [...this.views.value];
|
||||
newViews.splice(this.indexAt(at), 0, view);
|
||||
this.views.next(newViews);
|
||||
@@ -44,16 +44,17 @@ export class Workbench {
|
||||
) {
|
||||
let view = this.viewAt(at);
|
||||
if (!view) {
|
||||
const newIndex = this.createView(at);
|
||||
const newIndex = this.createView(at, to);
|
||||
view = this.viewAt(newIndex);
|
||||
if (!view) {
|
||||
throw new Unreachable();
|
||||
}
|
||||
}
|
||||
if (replaceHistory) {
|
||||
view.history.replace(to);
|
||||
} else {
|
||||
view.history.push(to);
|
||||
if (replaceHistory) {
|
||||
view.history.replace(to);
|
||||
} else {
|
||||
view.history.push(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ export function useBindWorkbenchToDesktopRouter(
|
||||
if (newLocation === null) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
workbench.location.value.pathname === newLocation.pathname &&
|
||||
workbench.location.value.search === newLocation.search &&
|
||||
workbench.location.value.hash === newLocation.hash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
workbench.open(newLocation);
|
||||
}, [basename, browserLocation, workbench]);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import {
|
||||
appSidebarOpenAtom,
|
||||
SidebarSwitch,
|
||||
} from '@affine/component/app-sidebar';
|
||||
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
|
||||
import { RightSidebarIcon } from '@blocksuite/icons';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
@@ -10,6 +6,10 @@ import { useService } from '@toeverything/infra/di';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Suspense, useCallback } from 'react';
|
||||
|
||||
import {
|
||||
appSidebarOpenAtom,
|
||||
SidebarSwitch,
|
||||
} from '../../../components/app-sidebar';
|
||||
import { RightSidebar } from '../../right-sidebar';
|
||||
import * as styles from './route-container.css';
|
||||
import { useView } from './use-view';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Menu } from '@affine/component/ui/menu';
|
||||
import { WorkspaceFallback } from '@affine/component/workspace';
|
||||
import { WorkspaceManager } from '@toeverything/infra';
|
||||
import { WorkspaceListService } from '@toeverything/infra';
|
||||
import { useService } from '@toeverything/infra';
|
||||
@@ -9,6 +8,7 @@ import { type LoaderFunction, redirect } from 'react-router-dom';
|
||||
|
||||
import { createFirstAppData } from '../bootstrap/first-app-data';
|
||||
import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list';
|
||||
import { WorkspaceFallback } from '../components/workspace';
|
||||
import { appConfigStorage } from '../hooks/use-app-config-storage';
|
||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import { WorkspaceSubPath } from '../shared';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import { MainContainer } from '@affine/component/workspace';
|
||||
import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status';
|
||||
import { usePageDocumentTitle } from '@affine/core/hooks/use-global-state';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
import { AppContainer } from '../../components/affine/app-container';
|
||||
import { PageDetailEditor } from '../../components/page-detail-editor';
|
||||
import { SharePageNotFoundError } from '../../components/share-page-not-found-error';
|
||||
import { MainContainer } from '../../components/workspace';
|
||||
import { CurrentWorkspaceService } from '../../modules/workspace';
|
||||
import * as styles from './share-detail-page.css';
|
||||
import { ShareFooter } from './share-footer';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const body = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { CollectionService } from '../../../modules/collection';
|
||||
import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench';
|
||||
import { EmptyCollectionList } from '../page-list-empty';
|
||||
import { AllCollectionHeader } from './header';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const AllCollection = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
@@ -65,25 +66,27 @@ export const AllCollection = () => {
|
||||
/>
|
||||
</ViewHeaderIsland>
|
||||
<ViewBodyIsland>
|
||||
{collectionMetas.length > 0 ? (
|
||||
<VirtualizedCollectionList
|
||||
collections={collections}
|
||||
collectionMetas={collectionMetas}
|
||||
setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
|
||||
node={node}
|
||||
config={config}
|
||||
handleCreateCollection={handleCreateCollection}
|
||||
/>
|
||||
) : (
|
||||
<EmptyCollectionList
|
||||
heading={
|
||||
<CollectionListHeader
|
||||
node={node}
|
||||
onCreate={handleCreateCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
{collectionMetas.length > 0 ? (
|
||||
<VirtualizedCollectionList
|
||||
collections={collections}
|
||||
collectionMetas={collectionMetas}
|
||||
setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
|
||||
node={node}
|
||||
config={config}
|
||||
handleCreateCollection={handleCreateCollection}
|
||||
/>
|
||||
) : (
|
||||
<EmptyCollectionList
|
||||
heading={
|
||||
<CollectionListHeader
|
||||
node={node}
|
||||
onCreate={handleCreateCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ViewBodyIsland>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { InlineEditHandle } from '@affine/component';
|
||||
import { appSidebarFloatingAtom } from '@affine/component/app-sidebar';
|
||||
import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite';
|
||||
import { JournalWeekDatePicker } from '@affine/core/components/blocksuite/block-suite-header/journal/date-picker';
|
||||
import { JournalTodayButton } from '@affine/core/components/blocksuite/block-suite-header/journal/today-button';
|
||||
@@ -12,6 +11,7 @@ import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { SharePageButton } from '../../../components/affine/share-page-modal';
|
||||
import { appSidebarFloatingAtom } from '../../../components/app-sidebar';
|
||||
import { BlocksuiteHeaderTitle } from '../../../components/blocksuite/block-suite-header/title/index';
|
||||
import { HeaderDivider } from '../../../components/pure/header';
|
||||
import * as styles from './detail-page-header.css';
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -269,7 +270,7 @@ export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => {
|
||||
|
||||
const [page, setPage] = useState<Doc | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!pageRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WorkspaceFallback } from '@affine/component/workspace';
|
||||
import { useWorkspace } from '@affine/core/hooks/use-workspace';
|
||||
import {
|
||||
Workspace,
|
||||
@@ -22,6 +21,7 @@ import { useParams } from 'react-router-dom';
|
||||
|
||||
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
|
||||
import { HubIsland } from '../../components/affine/hub-island';
|
||||
import { WorkspaceFallback } from '../../components/workspace';
|
||||
import { WorkspaceLayout } from '../../layouts/workspace-layout';
|
||||
import { RightSidebarContainer } from '../../modules/right-sidebar';
|
||||
import { WorkbenchRoot } from '../../modules/workbench';
|
||||
|
||||
221
packages/frontend/core/src/utils/navigable-history.ts
Normal file
221
packages/frontend/core/src/utils/navigable-history.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Blocker, Listener, Location, To } from 'history';
|
||||
import {
|
||||
Action,
|
||||
createPath,
|
||||
type MemoryHistory,
|
||||
type MemoryHistoryOptions,
|
||||
parsePath,
|
||||
} from 'history';
|
||||
|
||||
export interface NavigableHistory extends MemoryHistory {
|
||||
entries: Location[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `createMemoryHistory` from `history` package, but with additional `entries` property.
|
||||
*
|
||||
* Original `MemoryHistory` does not have `entries` property, so we can't get `backable` and `forwardable` state which
|
||||
* is needed for implementing back and forward buttons.
|
||||
*
|
||||
* @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#creatememoryhistory
|
||||
*/
|
||||
export function createNavigableHistory(
|
||||
options: MemoryHistoryOptions = {}
|
||||
): NavigableHistory {
|
||||
const { initialEntries = ['/'], initialIndex } = options;
|
||||
const entries: Location[] = initialEntries.map(entry => {
|
||||
const location = Object.freeze<Location>({
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: createKey(),
|
||||
...(typeof entry === 'string' ? parsePath(entry) : entry),
|
||||
});
|
||||
|
||||
warning(
|
||||
location.pathname.charAt(0) === '/',
|
||||
`Relative pathnames are not supported in createMemoryHistory({ initialEntries }) (invalid entry: ${JSON.stringify(
|
||||
entry
|
||||
)})`
|
||||
);
|
||||
|
||||
return location;
|
||||
});
|
||||
let index = clamp(
|
||||
initialIndex == null ? entries.length - 1 : initialIndex,
|
||||
0,
|
||||
entries.length - 1
|
||||
);
|
||||
|
||||
let action = Action.Pop;
|
||||
let location = entries[index];
|
||||
const listeners = createEvents<Listener>();
|
||||
const blockers = createEvents<Blocker>();
|
||||
|
||||
function createHref(to: To) {
|
||||
return typeof to === 'string' ? to : createPath(to);
|
||||
}
|
||||
|
||||
function getNextLocation(to: To, state: any = null): Location {
|
||||
return Object.freeze<Location>({
|
||||
pathname: location.pathname,
|
||||
search: '',
|
||||
hash: '',
|
||||
...(typeof to === 'string' ? parsePath(to) : to),
|
||||
state,
|
||||
key: createKey(),
|
||||
});
|
||||
}
|
||||
|
||||
function allowTx(action: Action, location: Location, retry: () => void) {
|
||||
return (
|
||||
!blockers.length || (blockers.call({ action, location, retry }), false)
|
||||
);
|
||||
}
|
||||
|
||||
function applyTx(nextAction: Action, nextLocation: Location) {
|
||||
action = nextAction;
|
||||
location = nextLocation;
|
||||
listeners.call({ action, location });
|
||||
}
|
||||
|
||||
function push(to: To, state?: any) {
|
||||
const nextAction = Action.Push;
|
||||
const nextLocation = getNextLocation(to, state);
|
||||
function retry() {
|
||||
push(to, state);
|
||||
}
|
||||
|
||||
warning(
|
||||
location.pathname.charAt(0) === '/',
|
||||
`Relative pathnames are not supported in memory history.push(${JSON.stringify(
|
||||
to
|
||||
)})`
|
||||
);
|
||||
|
||||
if (allowTx(nextAction, nextLocation, retry)) {
|
||||
index += 1;
|
||||
entries.splice(index, entries.length, nextLocation);
|
||||
applyTx(nextAction, nextLocation);
|
||||
}
|
||||
}
|
||||
|
||||
function replace(to: To, state?: any) {
|
||||
const nextAction = Action.Replace;
|
||||
const nextLocation = getNextLocation(to, state);
|
||||
function retry() {
|
||||
replace(to, state);
|
||||
}
|
||||
|
||||
warning(
|
||||
location.pathname.charAt(0) === '/',
|
||||
`Relative pathnames are not supported in memory history.replace(${JSON.stringify(
|
||||
to
|
||||
)})`
|
||||
);
|
||||
|
||||
if (allowTx(nextAction, nextLocation, retry)) {
|
||||
entries[index] = nextLocation;
|
||||
applyTx(nextAction, nextLocation);
|
||||
}
|
||||
}
|
||||
|
||||
function go(delta: number) {
|
||||
const nextIndex = clamp(index + delta, 0, entries.length - 1);
|
||||
const nextAction = Action.Pop;
|
||||
const nextLocation = entries[nextIndex];
|
||||
function retry() {
|
||||
go(delta);
|
||||
}
|
||||
|
||||
if (allowTx(nextAction, nextLocation, retry)) {
|
||||
index = nextIndex;
|
||||
applyTx(nextAction, nextLocation);
|
||||
}
|
||||
}
|
||||
|
||||
const history: NavigableHistory = {
|
||||
get index() {
|
||||
return index;
|
||||
},
|
||||
get action() {
|
||||
return action;
|
||||
},
|
||||
get location() {
|
||||
return location;
|
||||
},
|
||||
get entries() {
|
||||
return entries;
|
||||
},
|
||||
createHref,
|
||||
push,
|
||||
replace,
|
||||
go,
|
||||
back() {
|
||||
go(-1);
|
||||
},
|
||||
forward() {
|
||||
go(1);
|
||||
},
|
||||
listen(listener) {
|
||||
return listeners.push(listener);
|
||||
},
|
||||
block(blocker) {
|
||||
return blockers.push(blocker);
|
||||
},
|
||||
};
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
function createKey() {
|
||||
return Math.random().toString(36).substr(2, 8);
|
||||
}
|
||||
|
||||
function warning(cond: any, message: string) {
|
||||
if (!cond) {
|
||||
// eslint-disable-next-line no-console
|
||||
if (typeof console !== 'undefined') console.warn(message);
|
||||
|
||||
try {
|
||||
// Welcome to debugging history!
|
||||
//
|
||||
// This error is thrown as a convenience so you can more easily
|
||||
// find the source for a warning that appears in the console by
|
||||
// enabling "pause on exceptions" in your JavaScript debugger.
|
||||
throw new Error(message);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(n: number, lowerBound: number, upperBound: number) {
|
||||
return Math.min(Math.max(n, lowerBound), upperBound);
|
||||
}
|
||||
|
||||
type Events<F> = {
|
||||
length: number;
|
||||
push: (fn: F) => () => void;
|
||||
call: (arg: any) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
function createEvents<F extends Function>(): Events<F> {
|
||||
let handlers: F[] = [];
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return handlers.length;
|
||||
},
|
||||
push(fn: F) {
|
||||
handlers.push(fn);
|
||||
return function () {
|
||||
handlers = handlers.filter(handler => handler !== fn);
|
||||
};
|
||||
},
|
||||
call(arg) {
|
||||
handlers.forEach(fn => fn && fn(arg));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user