refactor(infra): directory structure (#4615)

This commit is contained in:
Joooye_34
2023-10-18 23:30:08 +08:00
committed by GitHub
parent 814d552be8
commit bed9310519
1150 changed files with 539 additions and 584 deletions
@@ -0,0 +1,32 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
background: 'var(--affine-white-30)',
alignItems: 'center',
borderRadius: '8px',
border: '1px solid var(--affine-black-10)',
fontSize: 'var(--affine-font-sm)',
width: '100%',
position: 'relative',
height: '52px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 24px',
transition: 'background 0.2s ease',
selectors: {
'&:active': {
background: 'var(--affine-white-40)',
},
},
});
export const icon = style({
marginRight: '18px',
color: 'var(--affine-icon-color)',
fontSize: '24px',
});
export const spacer = style({
flex: 1,
});
@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { AddPageButton } from '.';
export default {
title: 'Components/AppSidebar/AddPageButton',
component: AddPageButton,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<AddPageButton onClick={() => alert('opened')} />
</main>
);
};
@@ -0,0 +1,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="new-page-button"
style={style}
className={clsx([styles.root, className])}
onClick={onClick}
>
<PlusIcon className={styles.icon} /> {t['New Page']()}
<Spotlight />
</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,235 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
background: 'var(--affine-white-30)',
alignItems: 'center',
borderRadius: '8px',
border: '1px solid var(--affine-black-10)',
fontSize: 'var(--affine-font-sm)',
width: '100%',
height: '52px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 12px',
position: 'relative',
transition: 'all 0.3s ease',
selectors: {
'&:hover': {
background: 'var(--affine-white-60)',
},
'&[data-disabled="true"]': {
pointerEvents: 'none',
},
'&:after': {
content: "''",
position: 'absolute',
top: '-2px',
right: '-2px',
width: '8px',
height: '8px',
backgroundColor: 'var(--affine-primary-color)',
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: 'var(--affine-icon-color)',
fontSize: '24px',
});
export const closeIcon = style({
position: 'absolute',
top: '4px',
right: '4px',
height: '14px',
width: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--affine-shadow-1)',
color: 'var(--affine-text-secondary-color)',
backgroundColor: 'var(--affine-background-primary-color)',
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: 'var(--affine-font-sm)',
whiteSpace: 'nowrap',
});
export const installLabelNormal = style([
installLabel,
{
justifyContent: 'space-between',
selectors: {
[`${root}:hover &, ${root}[data-updating=true] &`]: {
display: 'none',
},
},
},
]);
export const installLabelHover = style([
installLabel,
{
display: 'none',
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: 'var(--affine-text-secondary-color)',
background: 'var(--affine-background-primary-color)',
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: 'var(--affine-font-sm)',
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: 'var(--affine-black-10)',
});
export const progressInner = style({
position: 'absolute',
top: 0,
left: 0,
height: '100%',
borderRadius: '12px',
background: 'var(--affine-primary-color)',
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,69 @@
import { isBrowser } from '@affine/env/constant';
import type { UpdateMeta } from '@toeverything/infra/type';
import { atomWithObservable, atomWithStorage } from 'jotai/utils';
import { Observable } from 'rxjs';
// todo: move to utils?
function rpcToObservable<
T,
H extends () => Promise<T>,
E extends (callback: (t: T) => void) => () => void,
>(
initialValue: T | null,
{
event,
handler,
onSubscribe,
}: {
event?: E;
handler?: H;
onSubscribe?: () => void;
}
): Observable<T | null> {
return new Observable<T | null>(subscriber => {
subscriber.next(initialValue);
onSubscribe?.();
if (!isBrowser || !environment.isDesktop || !event) {
subscriber.complete();
return;
}
handler?.()
.then(t => {
subscriber.next(t);
})
.catch(err => {
subscriber.error(err);
});
return event(t => {
subscriber.next(t);
});
});
}
export const updateReadyAtom = atomWithObservable(() => {
return rpcToObservable(null as UpdateMeta | null, {
event: window.events?.updater.onUpdateReady,
});
});
export const updateAvailableAtom = atomWithObservable(() => {
return rpcToObservable(null as UpdateMeta | null, {
event: window.events?.updater.onUpdateAvailable,
onSubscribe: () => {
window.apis?.updater.checkForUpdatesAndNotify().catch(err => {
console.error(err);
});
},
});
});
export const downloadProgressAtom = atomWithObservable(() => {
return rpcToObservable(null as number | null, {
event: window.events?.updater.onDownloadProgress,
});
});
export const changelogCheckedAtom = atomWithStorage<Record<string, boolean>>(
'affine:client-changelog-checked',
{}
);
@@ -0,0 +1,263 @@
import { isBrowser, Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons';
import { Tooltip } from '@toeverything/components/tooltip';
import clsx from 'clsx';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { startTransition, useCallback, useState } from 'react';
import * as styles from './index.css';
import {
changelogCheckedAtom,
downloadProgressAtom,
updateAvailableAtom,
updateReadyAtom,
} from './index.jotai';
export interface AddPageButtonPureProps {
onClickUpdate: () => void;
onDismissCurrentChangelog: () => void;
currentChangelogUnread: boolean;
updateReady: boolean;
updateAvailable: {
version: string;
allowAutoUpdate: boolean;
} | null;
downloadProgress: number | null;
appQuitting: boolean;
className?: string;
style?: React.CSSProperties;
}
const currentVersionAtom = atom(async () => {
if (!isBrowser) {
return null;
}
const currentVersion = await window.apis?.updater.currentVersion();
return currentVersion;
});
const currentChangelogUnreadAtom = atom(async get => {
if (!isBrowser) {
return false;
}
const mapping = get(changelogCheckedAtom);
const currentVersion = await get(currentVersionAtom);
if (currentVersion) {
return !mapping[currentVersion];
}
return false;
});
export function AppUpdaterButtonPure({
updateReady,
onClickUpdate,
onDismissCurrentChangelog,
currentChangelogUnread,
updateAvailable,
downloadProgress,
appQuitting,
className,
style,
}: AddPageButtonPureProps) {
const t = useAFFiNEI18N();
if (!updateAvailable && !currentChangelogUnread) {
return null;
}
const updateAvailableNode = updateAvailable
? updateAvailable.allowAutoUpdate
? renderUpdateAvailableAllowAutoUpdate()
: renderUpdateAvailableNotAllowAutoUpdate()
: null;
const whatsNew =
!updateAvailable && currentChangelogUnread ? renderWhatsNew() : null;
const wrapWithTooltip = (
node: React.ReactElement,
tooltip?: React.ReactElement | string
) => {
if (!tooltip) {
return node;
}
return (
<Tooltip content={tooltip} side="top">
{node}
</Tooltip>
);
};
return wrapWithTooltip(
<button
style={style}
className={clsx([styles.root, className])}
data-has-update={!!updateAvailable}
data-updating={appQuitting}
data-disabled={
(updateAvailable?.allowAutoUpdate && !updateReady) || appQuitting
}
onClick={onClickUpdate}
>
{updateAvailableNode}
{whatsNew}
<div className={styles.particles} aria-hidden="true"></div>
<span className={styles.halo} aria-hidden="true"></span>
</button>,
updateAvailable?.version
);
function renderUpdateAvailableAllowAutoUpdate() {
return (
<div className={clsx([styles.updateAvailableWrapper])}>
<div className={clsx([styles.installLabelNormal])}>
<span className={styles.ellipsisTextOverflow}>
{!updateReady
? t['com.affine.appUpdater.downloading']()
: t['com.affine.appUpdater.updateAvailable']()}
</span>
<span className={styles.versionLabel}>
{updateAvailable?.version}
</span>
</div>
{updateReady ? (
<div className={clsx([styles.installLabelHover])}>
<ResetIcon className={styles.icon} />
<span className={styles.ellipsisTextOverflow}>
{t[
appQuitting ? 'Loading' : 'com.affine.appUpdater.installUpdate'
]()}
</span>
</div>
) : (
<div className={styles.progress}>
<div
className={styles.progressInner}
style={{ width: `${downloadProgress}%` }}
/>
</div>
)}
</div>
);
}
function renderUpdateAvailableNotAllowAutoUpdate() {
return (
<>
<div className={clsx([styles.installLabelNormal])}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.updateAvailable']()}
</span>
<span className={styles.versionLabel}>
{updateAvailable?.version}
</span>
</div>
<div className={clsx([styles.installLabelHover])}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.openDownloadPage']()}
</span>
</div>
</>
);
}
function renderWhatsNew() {
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={e => {
onDismissCurrentChangelog();
e.stopPropagation();
}}
>
<CloseIcon />
</div>
</>
);
}
}
// Although it is called an input, it is actually a button.
export function AppUpdaterButton({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
const currentChangelogUnread = useAtomValue(currentChangelogUnreadAtom);
const updateReady = useAtomValue(updateReadyAtom);
const updateAvailable = useAtomValue(updateAvailableAtom);
const currentVersion = useAtomValue(currentVersionAtom);
const downloadProgress = useAtomValue(downloadProgressAtom);
const setChangelogCheckAtom = useSetAtom(changelogCheckedAtom);
const [appQuitting, setAppQuitting] = useState(false);
const onDismissCurrentChangelog = useCallback(() => {
if (!currentVersion) {
return;
}
startTransition(() =>
setChangelogCheckAtom(mapping => {
return {
...mapping,
[currentVersion]: true,
};
})
);
}, [currentVersion, setChangelogCheckAtom]);
const onClickUpdate = useCallback(() => {
if (updateReady) {
setAppQuitting(true);
window.apis?.updater.quitAndInstall().catch(err => {
// TODO: add error toast here
console.error(err);
});
} else if (updateAvailable) {
if (updateAvailable.allowAutoUpdate) {
// wait for download to finish
} else {
window.open(
`https://github.com/toeverything/AFFiNE/releases/tag/v${currentVersion}`,
'_blank'
);
}
} else if (currentChangelogUnread) {
window.open(runtimeConfig.changelogUrl, '_blank');
onDismissCurrentChangelog();
} else {
throw new Unreachable();
}
}, [
currentChangelogUnread,
currentVersion,
onDismissCurrentChangelog,
updateAvailable,
updateReady,
]);
return (
<AppUpdaterButtonPure
appQuitting={appQuitting}
updateReady={!!updateReady}
onClickUpdate={onClickUpdate}
onDismissCurrentChangelog={onDismissCurrentChangelog}
currentChangelogUnread={currentChangelogUnread}
updateAvailable={updateAvailable}
downloadProgress={downloadProgress}
className={className}
style={style}
/>
);
}
export * from './index.jotai';
@@ -0,0 +1,22 @@
import { style } from '@vanilla-extract/css';
export const root = style({
fontSize: 'var(--affine-font-xs)',
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: 'var(--affine-black-30)',
});
@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { CategoryDivider } from '.';
export default {
title: 'Components/AppSidebar/CategoryDivider',
component: CategoryDivider,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<CategoryDivider label="Favorites" />
</main>
);
};
@@ -0,0 +1,17 @@
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import * as styles from './index.css';
interface CategoryDividerProps extends PropsWithChildren {
label: string;
}
export function CategoryDivider({ label, children }: CategoryDividerProps) {
return (
<div className={clsx([styles.root])}>
<div className={styles.label}>{label}</div>
{children}
</div>
);
}
@@ -0,0 +1,15 @@
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,115 @@
import { baseTheme } from '@toeverything/theme';
import type { ComplexStyleRule } from '@vanilla-extract/css';
import { createVar, style } from '@vanilla-extract/css';
export const floatingMaxWidth = 768;
export const navWidthVar = createVar('nav-width');
export const navWrapperStyle = style({
vars: {
[navWidthVar]: '256px',
},
position: 'relative',
width: navWidthVar,
minWidth: navWidthVar,
height: '100%',
zIndex: 3,
paddingBottom: '8px',
backgroundColor: 'transparent',
'@media': {
print: {
display: 'none',
zIndex: -1,
},
},
selectors: {
'&[data-is-floating="true"]': {
position: 'absolute',
width: `calc(${navWidthVar})`,
zIndex: 4,
backgroundColor: 'var(--affine-background-primary-color)',
},
'&[data-open="false"]': {
marginLeft: `calc(${navWidthVar} * -1)`,
},
'&[data-enable-animation="true"]': {
transition: 'margin-left .3s .05s, width .3s .05s',
},
'&[data-is-floating="false"].has-background': {
backgroundColor: 'var(--affine-white-60)',
borderRight: '1px solid var(--affine-border-color)',
},
'&.has-border': {
borderRight: '1px solid var(--affine-border-color)',
},
},
});
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',
zIndex: parseInt(baseTheme.zIndexModal),
});
export const navHeaderStyle = style({
flex: '0 0 auto',
height: '52px',
padding: '0px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
WebkitAppRegion: 'drag',
selectors: {
'&[data-is-macos-electron="true"]': {
paddingLeft: '90px',
},
},
} as ComplexStyleRule);
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: 'var(--affine-background-modal-color)',
selectors: {
'&[data-open="true"][data-is-floating="true"]': {
opacity: 1,
pointerEvents: 'auto',
right: '0',
zIndex: 3,
},
},
'@media': {
print: {
display: 'none',
},
},
});
@@ -0,0 +1,12 @@
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const APP_SIDEBAR_OPEN = 'app-sidebar-open';
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, true);
export const appSidebarFloatingAtom = atom(false);
export const appSidebarResizingAtom = atom(false);
export const appSidebarWidthAtom = atomWithStorage(
'app-sidebar-width',
256 /* px */
);
@@ -0,0 +1,169 @@
import { Skeleton } from '@mui/material';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useAtom, useAtomValue } from 'jotai';
import debounce from 'lodash/debounce';
import type { PropsWithChildren, ReactElement } from 'react';
import { useEffect, useRef, useState } from 'react';
import { fallbackHeaderStyle, fallbackStyle } from './fallback.css';
import {
floatingMaxWidth,
navBodyStyle,
navHeaderStyle,
navStyle,
navWidthVar,
navWrapperStyle,
sidebarFloatMaskStyle,
} from './index.css';
import {
APP_SIDEBAR_OPEN,
appSidebarFloatingAtom,
appSidebarOpenAtom,
appSidebarResizingAtom,
appSidebarWidthAtom,
} from './index.jotai';
import { ResizeIndicator } from './resize-indicator';
import type { SidebarHeaderProps } from './sidebar-header';
import { SidebarHeader } from './sidebar-header';
export type AppSidebarProps = PropsWithChildren<
SidebarHeaderProps & {
hasBackground?: boolean;
}
>;
function useEnableAnimation() {
const [enable, setEnable] = useState(false);
useEffect(() => {
window.setTimeout(() => {
setEnable(true);
}, 500);
}, []);
return enable;
}
export type History = {
stack: string[];
current: number;
};
export function AppSidebar(props: AppSidebarProps): ReactElement {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
const [appSidebarFloating, setAppSidebarFloating] = useAtom(
appSidebarFloatingAtom
);
const isResizing = useAtomValue(appSidebarResizingAtom);
const navRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onResize() {
const isFloatingMaxWidth = window.matchMedia(
`(max-width: ${floatingMaxWidth}px)`
).matches;
const isOverflowWidth = window.matchMedia(
`(max-width: ${appSidebarWidth / 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);
}
setAppSidebarFloating(isFloating && !!open);
}
const dOnResize = debounce(onResize, 50);
window.addEventListener('resize', dOnResize);
return () => {
window.removeEventListener('resize', dOnResize);
};
}, [appSidebarWidth, open, setAppSidebarFloating, setOpen]);
// disable animation to avoid UI flash
const enableAnimation = useEnableAnimation();
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
return (
<>
<div
style={assignInlineVars({
[navWidthVar]: `${appSidebarWidth}px`,
})}
className={clsx(navWrapperStyle, {
'has-background': environment.isDesktop && props.hasBackground,
'has-border':
!environment.isDesktop ||
(environment.isDesktop && props.hasBackground),
})}
data-open={open}
data-testid="app-sidebar-wrapper"
data-is-macos-electron={isMacosDesktop}
data-is-floating={appSidebarFloating}
data-has-background={props.hasBackground}
data-enable-animation={enableAnimation && !isResizing}
>
<nav className={navStyle} ref={navRef} data-testid="app-sidebar">
<SidebarHeader
router={props.router}
generalShortcutsInfo={props.generalShortcutsInfo}
/>
<div className={navBodyStyle} data-testid="sliderBar-inner">
{props.children}
</div>
</nav>
<ResizeIndicator targetElement={navRef.current} />
</div>
<div
data-testid="app-sidebar-float-mask"
data-open={open}
data-is-floating={appSidebarFloating}
className={sidebarFloatMaskStyle}
onClick={() => setOpen(false)}
/>
</>
);
}
export const AppSidebarFallback = (): ReactElement | null => {
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
return (
<div
style={assignInlineVars({
[navWidthVar]: `${appSidebarWidth}px`,
})}
className={clsx(navWrapperStyle, {
'has-border': true,
})}
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-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,127 @@
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: 'var(--affine-font-sm)',
marginTop: '4px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
},
'&[data-active="true"]': {
background: 'var(--affine-hover-color)',
},
'&[data-disabled="true"]': {
cursor: 'default',
color: 'var(--affine-text-secondary-color)',
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="favorite-list-item"][data-collapsible="false"][data-active="true"], &[data-type="favorite-list-item"][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({
justifySelf: 'flex-end',
opacity: 0,
pointerEvents: 'none',
selectors: {
[`${root}:hover &`]: {
opacity: 1,
pointerEvents: 'all',
},
},
});
export const icon = style({
color: 'var(--affine-icon-color)',
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: 'var(--affine-hover-color)',
},
},
});
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 '.';
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,102 @@
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import React from 'react';
import type { LinkProps } 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,
Pick<LinkProps, 'to'> {}
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, ...props }, ref) => {
return (
<Link 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>
</Link>
);
}
);
MenuLinkItem.displayName = 'MenuLinkItem';
@@ -0,0 +1,32 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
background: 'var(--affine-white-10)',
alignItems: 'center',
borderRadius: '8px',
border: '1px solid var(--affine-black-10)',
fontSize: 'var(--affine-font-sm)',
width: '100%',
height: '36px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 12px',
margin: '20px 0',
position: 'relative',
});
export const icon = style({
marginRight: '8px',
color: 'var(--affine-icon-color)',
fontSize: '20px',
});
export const spacer = style({
flex: 1,
});
export const shortcutHint = style({
color: 'var(--affine-black-30)',
fontSize: 'var(--affine-font-base)',
});
@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { QuickSearchInput } from '.';
export default {
title: 'Components/AppSidebar/QuickSearchInput',
component: QuickSearchInput,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<QuickSearchInput onClick={() => alert('opened')} />
</main>
);
};
@@ -0,0 +1,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,45 @@
import { style } from '@vanilla-extract/css';
export const resizerContainer = style({
position: 'absolute',
right: 0,
top: '16px',
bottom: '16px',
width: '16px',
zIndex: 'calc(var(--affine-z-index-modal) + 1)',
transform: 'translateX(50%)',
backgroundColor: 'transparent',
opacity: 0,
cursor: 'col-resize',
'@media': {
'(max-width: 600px)': {
// do not allow resizing on mobile
display: 'none',
},
},
transition: 'opacity 0.15s ease 0.1s',
selectors: {
'&:hover': {
opacity: 1,
},
'&[data-resizing="true"]': {
opacity: 1,
transition: 'width .3s, min-width .3s, max-width .3s',
},
'&[data-open="false"]': {
display: 'none',
},
'&[data-open="open"]': {
display: 'block',
},
},
});
export const resizerInner = style({
position: 'absolute',
height: '100%',
width: '4px',
left: '6px',
borderRadius: '4px',
backgroundColor: 'var(--affine-primary-color)',
});
@@ -0,0 +1,62 @@
import { assertExists } from '@blocksuite/global/utils';
import { useAtom, useSetAtom } from 'jotai';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
import {
appSidebarOpenAtom,
appSidebarResizingAtom,
appSidebarWidthAtom,
} from '../index.jotai';
import * as styles from './index.css';
type ResizeIndicatorProps = {
targetElement: HTMLElement | null;
};
export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => {
const setWidth = useSetAtom(appSidebarWidthAtom);
const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom);
const [isResizing, setIsResizing] = useAtom(appSidebarResizingAtom);
const onResizeStart = useCallback(() => {
let resized = false;
assertExists(props.targetElement);
const { left: anchorLeft } = props.targetElement.getBoundingClientRect();
function onMouseMove(e: MouseEvent) {
e.preventDefault();
if (!props.targetElement) return;
const newWidth = Math.min(480, Math.max(e.clientX - anchorLeft, 256));
setWidth(newWidth);
setIsResizing(true);
resized = true;
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener(
'mouseup',
() => {
// if not resized, toggle sidebar
if (!resized) {
setSidebarOpen(o => !o);
}
setIsResizing(false);
document.removeEventListener('mousemove', onMouseMove);
},
{ once: true }
);
}, [props.targetElement, setIsResizing, setSidebarOpen, setWidth]);
return (
<div
className={styles.resizerContainer}
data-testid="app-sidebar-resizer"
data-resizing={isResizing}
data-open={sidebarOpen}
onMouseDown={onResizeStart}
>
<div className={styles.resizerInner} />
</div>
);
};
@@ -0,0 +1,86 @@
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: 'var(--affine-black-10)',
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: 'var(--affine-black-30)',
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,35 @@
import * as ScrollArea from '@radix-ui/react-scroll-area';
import clsx from 'clsx';
import { type PropsWithChildren } from 'react';
import * as styles from './index.css';
import { useHasScrollTop } from './use-has-scroll-top';
export function SidebarContainer({ children }: PropsWithChildren) {
return <div className={clsx([styles.baseContainer])}>{children}</div>;
}
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
const [hasScrollTop, ref] = useHasScrollTop();
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,29 @@
import { useEffect, useRef, useState } from 'react';
export function useHasScrollTop() {
const ref = useRef<HTMLDivElement>(null);
const [hasScrollTop, setHasScrollTop] = useState(false);
useEffect(() => {
if (!ref.current) {
return;
}
const container = ref.current;
function updateScrollTop() {
if (container) {
const hasScrollTop = container.scrollTop > 0;
setHasScrollTop(hasScrollTop);
}
}
container.addEventListener('scroll', updateScrollTop);
updateScrollTop();
return () => {
container.removeEventListener('scroll', updateScrollTop);
};
}, []);
return [hasScrollTop, ref] as const;
}
@@ -0,0 +1,99 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import type { History } from '..';
import {
navHeaderButton,
navHeaderNavigationButtons,
navHeaderStyle,
} from '../index.css';
import { appSidebarOpenAtom } from '../index.jotai';
import { SidebarSwitch } from './sidebar-switch';
export type SidebarHeaderProps = {
router?: {
back: () => unknown;
forward: () => unknown;
history: History;
};
generalShortcutsInfo?: {
shortcuts: {
[title: string]: string[];
};
};
};
export const SidebarHeader = (props: SidebarHeaderProps) => {
const open = useAtomValue(appSidebarOpenAtom);
const t = useAFFiNEI18N();
const shortcuts = props.generalShortcutsInfo?.shortcuts;
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]);
return (
<div
className={navHeaderStyle}
data-open={open}
data-is-macos-electron={environment.isDesktop && environment.isMacOs}
>
<SidebarSwitch show={open} />
<div className={navHeaderNavigationButtons}>
<Tooltip
content={`${shortcutsObject.goBack} ${shortcutsObject.goBackShortcut}`}
side="bottom"
>
<IconButton
className={navHeaderButton}
data-testid="app-sidebar-arrow-button-back"
disabled={props.router?.history.current === 0}
onClick={() => {
props.router?.back();
}}
>
<ArrowLeftSmallIcon />
</IconButton>
</Tooltip>
<Tooltip
content={`${shortcutsObject.goForward} ${shortcutsObject.goForwardShortcut}`}
side="bottom"
>
<IconButton
className={navHeaderButton}
data-testid="app-sidebar-arrow-button-forward"
disabled={
props.router
? (props.router.history.stack.length > 0 &&
props.router.history.current ===
props.router.history.stack.length - 1) ||
props.router.history.stack.length === 0
: true
}
onClick={() => {
props.router?.forward();
}}
>
<ArrowRightSmallIcon />
</IconButton>
</Tooltip>
</div>
</div>
);
};
export * from './sidebar-switch';
@@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css';
export const sidebarSwitch = style({
opacity: 0,
width: 0,
overflow: 'hidden',
pointerEvents: 'none',
transition: 'all .3s ease-in-out',
selectors: {
'&[data-show=true]': {
opacity: 1,
width: '32px',
flexShrink: 0,
fontSize: '24px',
pointerEvents: 'auto',
},
},
});
@@ -0,0 +1,38 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SidebarIcon } from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { useAtom } from 'jotai';
import { appSidebarOpenAtom } from '../index.jotai';
import * as styles from './sidebar-switch.css';
export const SidebarSwitch = ({ show }: { show: boolean }) => {
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={styles.sidebarSwitch}
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,24 @@
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 '.';
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} />;
}