feat(core): split right sidebar (#5971)

https://github.com/toeverything/AFFiNE/assets/13579374/c846c069-aa32-445d-b59b-b773a9b05ced

Now each view has a general container, the yellow area is the general container part, and the green part is the routing specific part.

![CleanShot 2024-03-01 at 11.47.35@2x.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/9a9f6ad6-2207-42e5-ae66-f7426bc9f3fc.png)
This commit is contained in:
EYHN
2024-03-04 06:42:12 +00:00
parent e2a31ea1fc
commit c599715963
88 changed files with 1393 additions and 1238 deletions

View File

@@ -0,0 +1,13 @@
import type { AffineEditorContainer } from '@blocksuite/presets';
export type SidebarTabName = 'outline' | 'frame' | 'copilot' | 'journal';
export interface SidebarTabProps {
editor: AffineEditorContainer | null;
}
export interface SidebarTab {
name: SidebarTabName;
icon: React.ReactNode;
Component: React.ComponentType<SidebarTabProps>;
}

View File

@@ -0,0 +1,14 @@
import type { SidebarTab } from './sidebar-tab';
import { copilotTab } from './tabs/copilot';
import { framePanelTab } from './tabs/frame';
import { journalTab } from './tabs/journal';
import { outlineTab } from './tabs/outline';
// the list of all possible tabs in affine.
// order matters (determines the order of the tabs)
export const sidebarTabs: SidebarTab[] = [
journalTab,
outlineTab,
framePanelTab,
copilotTab,
];

View File

@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,43 @@
import { assertExists } from '@blocksuite/global/utils';
import { AiIcon } from '@blocksuite/icons';
import { CopilotPanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react';
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
import * as styles from './outline.css';
// A wrapper for CopilotPanel
const EditorCopilotPanel = ({ editor }: SidebarTabProps) => {
const copilotPanelRef = useRef<CopilotPanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(
copilotPanelRef.current,
'copilot panel should be initialized'
);
container.append(copilotPanelRef.current);
}
}, []);
if (!editor) {
return;
}
if (!copilotPanelRef.current) {
copilotPanelRef.current = new CopilotPanel();
}
if (editor !== copilotPanelRef.current?.editor) {
(copilotPanelRef.current as CopilotPanel).editor = editor;
// (copilotPanelRef.current as CopilotPanel).fitPadding = [20, 20, 20, 20];
}
return <div className={styles.root} ref={onRefChange} />;
};
export const copilotTab: SidebarTab = {
name: 'copilot',
icon: <AiIcon />,
Component: EditorCopilotPanel,
};

View File

@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,40 @@
import { assertExists } from '@blocksuite/global/utils';
import { FrameIcon } from '@blocksuite/icons';
import { FramePanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react';
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
import * as styles from './frame.css';
// A wrapper for FramePanel
const EditorFramePanel = ({ editor }: SidebarTabProps) => {
const framePanelRef = useRef<FramePanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(framePanelRef.current, 'frame panel should be initialized');
container.append(framePanelRef.current);
}
}, []);
if (!editor) {
return;
}
if (!framePanelRef.current) {
framePanelRef.current = new FramePanel();
}
if (editor !== framePanelRef.current?.editor) {
(framePanelRef.current as FramePanel).editor = editor;
(framePanelRef.current as FramePanel).fitPadding = [20, 20, 20, 20];
}
return <div className={styles.root} ref={onRefChange} />;
};
export const framePanelTab: SidebarTab = {
name: 'frame',
icon: <FrameIcon />,
Component: EditorFramePanel,
};

View File

@@ -0,0 +1,227 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
const interactive = style({
position: 'relative',
cursor: 'pointer',
selectors: {
'&:hover': {
backgroundColor: cssVar('hoverColor'),
},
'&::before': {
content: '""',
position: 'absolute',
inset: 0,
opacity: 0,
borderRadius: 'inherit',
boxShadow: `0 0 0 3px ${cssVar('primaryColor')}`,
pointerEvents: 'none',
},
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
boxShadow: `0 0 0 0px ${cssVar('primaryColor')}`,
pointerEvents: 'none',
},
'&:focus-visible::before': {
opacity: 0.2,
},
'&:focus-visible::after': {
boxShadow: `0 0 0 1px ${cssVar('primaryColor')}`,
},
},
});
export const calendar = style({
padding: '16px',
});
export const journalPanel = style({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
overflow: 'hidden',
});
export const dailyCount = style({
height: 0,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: 8,
});
export const dailyCountHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
gap: 8,
});
export const dailyCountNav = style([
interactive,
{
height: 28,
width: 0,
flex: 1,
fontWeight: 500,
fontSize: 14,
padding: '4px 8px',
whiteSpace: 'nowrap',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVar('textSecondaryColor'),
transition: 'all .3s',
selectors: {
'&[aria-selected="true"]': {
backgroundColor: cssVar('backgroundTertiaryColor'),
color: cssVar('textPrimaryColor'),
},
},
},
]);
export const dailyCountContainer = style({
height: 0,
flexGrow: 1,
display: 'flex',
width: `calc(var(--item-count) * 100%)`,
transition: 'transform .15s ease',
transform:
'translateX(calc(var(--active-index) * 100% / var(--item-count) * -1))',
});
export const dailyCountItem = style({
width: 'calc(100% / var(--item-count))',
height: '100%',
});
export const dailyCountContent = style({
padding: '8px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const dailyCountEmpty = style({
width: '100%',
height: '100%',
maxHeight: 220,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
lineHeight: '24px',
fontSize: 15,
color: cssVar('textSecondaryColor'),
textAlign: 'center',
padding: '0 70px',
fontWeight: 400,
});
// page item
export const pageItem = style([
interactive,
{
width: '100%',
display: 'flex',
alignItems: 'center',
borderRadius: 4,
padding: '0 4px',
gap: 8,
height: 30,
selectors: {
'&[aria-selected="true"]': {
backgroundColor: cssVar('hoverColor'),
},
},
},
]);
export const pageItemIcon = style({
width: 20,
height: 20,
color: cssVar('iconColor'),
});
export const pageItemLabel = style({
width: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 500,
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
textAlign: 'left',
selectors: {
'[aria-selected="true"] &': {
// TODO: wait for design
color: cssVar('primaryColor'),
},
},
});
// conflict
export const journalConflictBlock = style({
padding: '0 16px 16px 16px',
});
export const journalConflictWrapper = style({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))',
rowGap: 4,
columnGap: 8,
});
export const journalConflictMoreTrigger = style([
interactive,
{
color: cssVar('textSecondaryColor'),
height: 30,
borderRadius: 4,
padding: '0px 8px',
fontSize: cssVar('fontSm'),
display: 'flex',
alignItems: 'center',
},
]);
// customize date-picker cell
export const journalDateCell = style([
interactive,
{
width: '100%',
height: '100%',
borderRadius: 8,
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
fontWeight: 400,
position: 'relative',
selectors: {
'&[data-is-today="true"]': {
fontWeight: 600,
color: cssVar('brandColor'),
},
'&[data-not-current-month="true"]': {
color: cssVar('black10'),
},
'&[data-selected="true"]': {
backgroundColor: cssVar('brandColor'),
fontWeight: 500,
color: cssVar('pureWhite'),
},
'&[data-is-journal="false"][data-selected="true"]': {
backgroundColor: 'transparent',
color: 'var(--affine-text-primary-color)',
fontWeight: 500,
border: `1px solid ${cssVar('primaryColor')}`,
},
},
},
]);
export const journalDateCellDot = style({
width: 4,
height: 4,
borderRadius: '50%',
backgroundColor: cssVar('primaryColor'),
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translateX(-50%)',
});

View File

@@ -0,0 +1,398 @@
import {
type DateCell,
DatePicker,
IconButton,
Menu,
Scrollable,
} from '@affine/component';
import { MoveToTrash } from '@affine/core/components/page-list';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import {
useJournalHelper,
useJournalInfoHelper,
useJournalRouteHelper,
} from '@affine/core/hooks/use-journal';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
EdgelessIcon,
MoreHorizontalIcon,
PageIcon,
TodayIcon,
} from '@blocksuite/icons';
import type { PageRecord } from '@toeverything/infra';
import {
Doc,
PageRecordList,
useLiveData,
Workspace,
} from '@toeverything/infra';
import { useService } from '@toeverything/infra/di';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import dayjs from 'dayjs';
import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { SidebarTab } from '../sidebar-tab';
import * as styles from './journal.css';
/**
* @internal
*/
const CountDisplay = ({
count,
max = 99,
...attrs
}: { count: number; max?: number } & HTMLAttributes<HTMLSpanElement>) => {
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
};
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
pageRecord: PageRecord;
right?: ReactNode;
}
const PageItem = ({
pageRecord,
right,
className,
...attrs
}: PageItemProps) => {
const title = useLiveData(pageRecord.title);
const mode = useLiveData(pageRecord.mode);
const workspace = useService(Workspace);
const { isJournal } = useJournalInfoHelper(
workspace.blockSuiteWorkspace,
pageRecord.id
);
const Icon = isJournal
? TodayIcon
: mode === 'edgeless'
? EdgelessIcon
: PageIcon;
return (
<div
aria-label={title}
className={clsx(className, styles.pageItem)}
{...attrs}
>
<div className={styles.pageItemIcon}>
<Icon width={20} height={20} />
</div>
<span className={styles.pageItemLabel}>{title}</span>
{right}
</div>
);
};
type NavItemName = 'createdToday' | 'updatedToday';
interface NavItem {
name: NavItemName;
label: string;
count: number;
}
interface JournalBlockProps {
date: dayjs.Dayjs;
}
const EditorJournalPanel = () => {
const t = useAFFiNEI18N();
const doc = useService(Doc);
const workspace = useService(Workspace);
const { journalDate, isJournal } = useJournalInfoHelper(
workspace.blockSuiteWorkspace,
doc.id
);
const { openJournal } = useJournalRouteHelper(workspace.blockSuiteWorkspace);
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
useEffect(() => {
journalDate && setDate(journalDate.format('YYYY-MM-DD'));
}, [journalDate]);
const onDateSelect = useCallback(
(date: string) => {
if (journalDate && dayjs(date).isSame(dayjs(journalDate))) return;
openJournal(date);
},
[journalDate, openJournal]
);
const customDayRenderer = useCallback(
(cell: DateCell) => {
// TODO: add a dot to indicate journal
// has performance issue for now, better to calculate it in advance
// const hasJournal = !!getJournalsByDate(cell.date.format('YYYY-MM-DD'))?.length;
const hasJournal = false;
return (
<button
className={styles.journalDateCell}
data-is-date-cell
tabIndex={cell.focused ? 0 : -1}
data-is-today={cell.isToday}
data-not-current-month={cell.notCurrentMonth}
data-selected={cell.selected}
data-is-journal={isJournal}
data-has-journal={hasJournal}
>
{cell.label}
{hasJournal && !cell.selected ? (
<div className={styles.journalDateCellDot} />
) : null}
</button>
);
},
[isJournal]
);
return (
<div className={styles.journalPanel} data-is-journal={isJournal}>
<div className={styles.calendar}>
<DatePicker
weekDays={t['com.affine.calendar-date-picker.week-days']()}
monthNames={t['com.affine.calendar-date-picker.month-names']()}
todayLabel={t['com.affine.calendar-date-picker.today']()}
customDayRenderer={customDayRenderer}
value={date}
onChange={onDateSelect}
/>
</div>
<JournalConflictBlock date={dayjs(date)} />
<JournalDailyCountBlock date={dayjs(date)} />
</div>
);
};
const sortPagesByDate = (
pages: PageRecord[],
field: 'updatedDate' | 'createDate',
order: 'asc' | 'desc' = 'desc'
) => {
return [...pages].sort((a, b) => {
return (
(order === 'asc' ? 1 : -1) *
dayjs(b.meta.value[field]).diff(dayjs(a.meta.value[field]))
);
});
};
const DailyCountEmptyFallback = ({ name }: { name: NavItemName }) => {
const t = useAFFiNEI18N();
return (
<div className={styles.dailyCountEmpty}>
{name === 'createdToday'
? t['com.affine.journal.daily-count-created-empty-tips']()
: t['com.affine.journal.daily-count-updated-empty-tips']()}
</div>
);
};
const JournalDailyCountBlock = ({ date }: JournalBlockProps) => {
const workspace = useService(Workspace);
const nodeRef = useRef<HTMLDivElement>(null);
const t = useAFFiNEI18N();
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
const pageRecordList = useService(PageRecordList);
const pageRecords = useLiveData(pageRecordList.records);
const navigateHelper = useNavigateHelper();
const getTodaysPages = useCallback(
(field: 'createDate' | 'updatedDate') => {
return sortPagesByDate(
pageRecords.filter(pageRecord => {
if (pageRecord.meta.value.trash) return false;
return (
pageRecord.meta.value[field] &&
dayjs(pageRecord.meta.value[field]).isSame(date, 'day')
);
}),
field
);
},
[date, pageRecords]
);
const createdToday = useMemo(
() => getTodaysPages('createDate'),
[getTodaysPages]
);
const updatedToday = useMemo(
() => getTodaysPages('updatedDate'),
[getTodaysPages]
);
const headerItems = useMemo<NavItem[]>(
() => [
{
name: 'createdToday',
label: t['com.affine.journal.created-today'](),
count: createdToday.length,
},
{
name: 'updatedToday',
label: t['com.affine.journal.updated-today'](),
count: updatedToday.length,
},
],
[createdToday.length, t, updatedToday.length]
);
const activeIndex = headerItems.findIndex(({ name }) => name === activeItem);
const vars = assignInlineVars({
'--active-index': String(activeIndex),
'--item-count': String(headerItems.length),
});
return (
<div className={styles.dailyCount} style={vars}>
<header className={styles.dailyCountHeader}>
{headerItems.map(({ label, count, name }, index) => {
return (
<button
onClick={() => setActiveItem(name)}
aria-selected={activeItem === name}
className={styles.dailyCountNav}
key={index}
>
{label}
&nbsp;
<CountDisplay count={count} />
</button>
);
})}
</header>
<main className={styles.dailyCountContainer} data-active={activeItem}>
{headerItems.map(({ name }) => {
const renderList =
name === 'createdToday' ? createdToday : updatedToday;
if (renderList.length === 0)
return (
<div key={name} className={styles.dailyCountItem}>
<DailyCountEmptyFallback name={name} />
</div>
);
return (
<Scrollable.Root key={name} className={styles.dailyCountItem}>
<Scrollable.Scrollbar />
<Scrollable.Viewport>
<div className={styles.dailyCountContent} ref={nodeRef}>
{renderList.map((pageRecord, index) => (
<PageItem
onClick={() =>
navigateHelper.openPage(workspace.id, pageRecord.id)
}
tabIndex={name === activeItem ? 0 : -1}
key={index}
pageRecord={pageRecord}
/>
))}
</div>
</Scrollable.Viewport>
</Scrollable.Root>
);
})}
</main>
</div>
);
};
const MAX_CONFLICT_COUNT = 5;
interface ConflictListProps
extends PropsWithChildren,
HTMLAttributes<HTMLDivElement> {
pageRecords: PageRecord[];
}
const ConflictList = ({
pageRecords,
children,
className,
...attrs
}: ConflictListProps) => {
const navigateHelper = useNavigateHelper();
const workspace = useService(Workspace);
const currentDoc = useService(Doc);
const { setTrashModal } = useTrashModalHelper(workspace.blockSuiteWorkspace);
const handleOpenTrashModal = useCallback(
(pageRecord: PageRecord) => {
setTrashModal({
open: true,
pageIds: [pageRecord.id],
pageTitles: [pageRecord.meta.value.title],
});
},
[setTrashModal]
);
return (
<div className={clsx(styles.journalConflictWrapper, className)} {...attrs}>
{pageRecords.map(pageRecord => {
const isCurrent = pageRecord.id === currentDoc.id;
return (
<PageItem
aria-selected={isCurrent}
pageRecord={pageRecord}
key={pageRecord.id}
right={
<Menu
items={
<MoveToTrash
onSelect={() => handleOpenTrashModal(pageRecord)}
/>
}
>
<IconButton type="plain">
<MoreHorizontalIcon />
</IconButton>
</Menu>
}
onClick={() => navigateHelper.openPage(workspace.id, pageRecord.id)}
/>
);
})}
{children}
</div>
);
};
const JournalConflictBlock = ({ date }: JournalBlockProps) => {
const t = useAFFiNEI18N();
const workspace = useService(Workspace);
const pageRecordList = useService(PageRecordList);
const journalHelper = useJournalHelper(workspace.blockSuiteWorkspace);
const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD'));
const pageRecords = useLiveData(pageRecordList.records).filter(v => {
return docs.some(doc => doc.id === v.id);
});
if (docs.length <= 1) return null;
return (
<ConflictList
className={styles.journalConflictBlock}
pageRecords={pageRecords.slice(0, MAX_CONFLICT_COUNT)}
>
{docs.length > MAX_CONFLICT_COUNT ? (
<Menu
items={
<ConflictList pageRecords={pageRecords.slice(MAX_CONFLICT_COUNT)} />
}
>
<div className={styles.journalConflictMoreTrigger}>
{t['com.affine.journal.conflict-show-more']({
count: (pageRecords.length - MAX_CONFLICT_COUNT).toFixed(0),
})}
</div>
</Menu>
) : null}
</ConflictList>
);
};
export const journalTab: SidebarTab = {
name: 'journal',
icon: <TodayIcon />,
Component: EditorJournalPanel,
};

View File

@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,40 @@
import { assertExists } from '@blocksuite/global/utils';
import { TocIcon } from '@blocksuite/icons';
import { OutlinePanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react';
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
import * as styles from './outline.css';
// A wrapper for TOCNotesPanel
const EditorOutline = ({ editor }: SidebarTabProps) => {
const outlinePanelRef = useRef<OutlinePanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(outlinePanelRef.current, 'toc panel should be initialized');
container.append(outlinePanelRef.current);
}
}, []);
if (!editor) {
return;
}
if (!outlinePanelRef.current) {
outlinePanelRef.current = new OutlinePanel();
}
if (editor !== outlinePanelRef.current?.editor) {
(outlinePanelRef.current as OutlinePanel).editor = editor;
(outlinePanelRef.current as OutlinePanel).fitPadding = [20, 20, 20, 20];
}
return <div className={styles.root} ref={onRefChange} />;
};
export const outlineTab: SidebarTab = {
name: 'outline',
icon: <TocIcon />,
Component: EditorOutline,
};

View File

@@ -0,0 +1,4 @@
export type { SidebarTabName } from './entities/sidebar-tab';
export { sidebarTabs } from './entities/sidebar-tabs';
export { MultiTabSidebarBody } from './view/body';
export { MultiTabSidebarHeaderSwitcher } from './view/header-switcher';

View File

@@ -0,0 +1,13 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
width: '100%',
height: '100%',
minWidth: '320px',
overflow: 'hidden',
alignItems: 'center',
borderTop: `1px solid ${cssVar('borderColor')}`,
});

View File

@@ -0,0 +1,17 @@
import type { PropsWithChildren } from 'react';
import type { SidebarTab, SidebarTabProps } from '../entities/sidebar-tab';
import * as styles from './body.css';
export const MultiTabSidebarBody = (
props: PropsWithChildren<SidebarTabProps & { tab?: SidebarTab | null }>
) => {
const Component = props.tab?.Component;
return (
<div className={styles.root}>
{props.children}
{Component ? <Component {...props} /> : null}
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { cssVar } from '@toeverything/theme';
import { createVar, style } from '@vanilla-extract/css';
export const activeIdx = createVar();
export const switchRootWrapper = style({
height: '52px',
display: 'flex',
alignItems: 'center',
});
export const switchRoot = style({
vars: {
[activeIdx]: '0',
},
display: 'flex',
alignItems: 'center',
gap: '8px',
height: '32px',
borderRadius: '12px',
padding: '4px',
position: 'relative',
background: cssVar('backgroundSecondaryColor'),
'::after': {
content: '""',
display: 'block',
width: '24px',
height: '24px',
background: cssVar('backgroundPrimaryColor'),
boxShadow: cssVar('shadow1'),
borderRadius: '8px',
position: 'absolute',
transform: `translateX(calc(${activeIdx} * 32px))`,
transition: 'all .15s',
},
});
export const button = style({
width: '24px',
height: '24px',
borderRadius: '8px',
color: cssVar('iconColor'),
position: 'relative',
zIndex: 1,
selectors: {
'&[data-active=true]': {
pointerEvents: 'none',
},
},
});

View File

@@ -0,0 +1,77 @@
import { IconButton } from '@affine/component';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { useWorkspaceEnabledFeatures } from '@affine/core/hooks/use-workspace-features';
import { FeatureType } from '@affine/graphql';
import { Doc, useService, Workspace } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useEffect, useMemo } from 'react';
import type { SidebarTab, SidebarTabName } from '../entities/sidebar-tab';
import * as styles from './header-switcher.css';
export interface MultiTabSidebarHeaderSwitcherProps {
tabs: SidebarTab[];
activeTabName: SidebarTabName | null;
setActiveTabName: (ext: SidebarTabName) => void;
}
// provide a switcher for active extensions
// will be used in global top header (MacOS) or sidebar (Windows)
export const MultiTabSidebarHeaderSwitcher = ({
tabs,
activeTabName,
setActiveTabName,
}: MultiTabSidebarHeaderSwitcherProps) => {
const workspace = useService(Workspace);
const doc = useService(Doc);
const copilotEnabled = useWorkspaceEnabledFeatures(workspace.meta).includes(
FeatureType.Copilot
);
const { isJournal } = useJournalInfoHelper(
workspace.blockSuiteWorkspace,
doc.id
);
const exts = useMemo(
() =>
tabs.filter(ext => {
if (ext.name === 'copilot' && !copilotEnabled) return false;
return true;
}),
[copilotEnabled, tabs]
);
const activeExtension = exts.find(ext => ext.name === activeTabName);
// if journal is active, set selected to journal
useEffect(() => {
const journalExtension = tabs.find(ext => ext.name === 'journal');
isJournal && journalExtension && setActiveTabName('journal');
}, [tabs, isJournal, setActiveTabName]);
const vars = assignInlineVars({
[styles.activeIdx]: String(
exts.findIndex(ext => ext.name === activeExtension?.name) ?? 0
),
});
return (
<div className={styles.switchRootWrapper}>
<div className={styles.switchRoot} style={vars}>
{exts.map(extension => {
return (
<IconButton
onClick={() => setActiveTabName(extension.name)}
key={extension.name}
data-active={activeExtension === extension}
className={styles.button}
>
{extension.icon}
</IconButton>
);
})}
</div>
</div>
);
};

View File

@@ -1,3 +0,0 @@
export class PageListView {
constructor() {}
}

View File

@@ -0,0 +1,6 @@
import { createIsland } from '../../../utils/island';
export class RightSidebarView {
readonly body = createIsland();
readonly header = createIsland();
}

View File

@@ -0,0 +1,47 @@
import { LiveData } from '@toeverything/infra/livedata';
import type { RightSidebarView } from './right-sidebar-view';
export class RightSidebar {
readonly isOpen = new LiveData(false);
readonly views = new LiveData<RightSidebarView[]>([]);
readonly front = this.views.map(
stack => stack[0] as RightSidebarView | undefined
);
readonly hasViews = this.views.map(stack => stack.length > 0);
open() {
this.isOpen.next(true);
}
toggle() {
this.isOpen.next(!this.isOpen.value);
}
close() {
this.isOpen.next(false);
}
/**
* @private use `RightSidebarViewIsland` instead
*/
_append(view: RightSidebarView) {
this.views.next([...this.views.value, view]);
}
/**
* @private use `RightSidebarViewIsland` instead
*/
_moveToFront(view: RightSidebarView) {
if (this.views.value.includes(view)) {
this.views.next([view, ...this.views.value.filter(v => v !== view)]);
}
}
/**
* @private use `RightSidebarViewIsland` instead
*/
_remove(view: RightSidebarView) {
this.views.next(this.views.value.filter(v => v !== view));
}
}

View File

@@ -0,0 +1,3 @@
export { RightSidebar } from './entities/right-sidebar';
export { RightSidebarContainer } from './view/container';
export { RightSidebarViewIsland } from './view/view-island';

View File

@@ -0,0 +1,40 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const sidebarContainerInner = style({
display: 'flex',
background: cssVar('backgroundPrimaryColor'),
flexDirection: 'column',
overflow: 'hidden',
height: '100%',
width: '100%',
});
export const sidebarContainer = style({
display: 'flex',
flexShrink: 0,
height: '100%',
selectors: {
[`&[data-client-border=true]`]: {
paddingLeft: 9,
},
[`&[data-client-border=false]`]: {
borderLeft: `1px solid ${cssVar('borderColor')}`,
},
},
});
export const sidebarBodyTarget = style({
flex: 1,
width: '100%',
minWidth: '320px',
overflow: 'hidden',
selectors: {
[`&[data-client-border=true]`]: {
paddingLeft: 9,
},
[`&[data-client-border=false]`]: {
borderLeft: `1px solid ${cssVar('borderColor')}`,
},
},
});

View File

@@ -0,0 +1,68 @@
import { ResizePanel } from '@affine/component/resize-panel';
import { appSettingAtom } from '@toeverything/infra/atom';
import { useService } from '@toeverything/infra/di';
import { useLiveData } from '@toeverything/infra/livedata';
import { useAtomValue } from 'jotai';
import { useCallback, useState } from 'react';
import { RightSidebar } from '../entities/right-sidebar';
import * as styles from './container.css';
import { Header } from './header';
const MIN_SIDEBAR_WIDTH = 320;
const MAX_SIDEBAR_WIDTH = 800;
export const RightSidebarContainer = () => {
const { clientBorder } = useAtomValue(appSettingAtom);
const [width, setWidth] = useState(300);
const [resizing, setResizing] = useState(false);
const rightSidebar = useService(RightSidebar);
const frontView = useLiveData(rightSidebar.front);
const open = useLiveData(rightSidebar.isOpen) && frontView !== undefined;
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
rightSidebar.open();
} else {
rightSidebar.close();
}
},
[rightSidebar]
);
const handleToggleOpen = useCallback(() => {
rightSidebar.toggle();
}, [rightSidebar]);
return (
<ResizePanel
resizeHandlePos="left"
resizeHandleOffset={clientBorder ? 4 : 0}
width={width}
resizing={resizing}
onResizing={setResizing}
className={styles.sidebarContainer}
data-client-border={clientBorder && open}
open={open}
onOpen={handleOpenChange}
onWidthChange={setWidth}
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}
>
{frontView && (
<div className={styles.sidebarContainerInner}>
<Header
floating={false}
onToggle={handleToggleOpen}
view={frontView}
/>
<frontView.body.Target
className={styles.sidebarBodyTarget}
></frontView.body.Target>
</div>
)}
</ResizePanel>
);
};

View File

@@ -0,0 +1,43 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
height: '52px',
width: '100%',
alignItems: 'center',
flexShrink: 0,
padding: '0 16px',
gap: '12px',
background: cssVar('backgroundPrimaryColor'),
selectors: {
'&[data-sidebar-floating="false"]': {
['WebkitAppRegion' as string]: 'drag',
},
},
'@media': {
print: {
display: 'none',
},
},
});
export const spacer = style({
flexGrow: 1,
minWidth: 12,
});
export const standaloneExtensionSwitcherWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
height: '52px',
position: 'relative',
});
export const windowsAppControlsContainer = style({
display: 'flex',
height: '100%',
marginRight: '-16px',
});

View File

@@ -0,0 +1,69 @@
import { IconButton } from '@affine/component';
import { RightSidebarIcon } from '@blocksuite/icons';
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
import type { RightSidebarView } from '../entities/right-sidebar-view';
import * as styles from './header.css';
export type HeaderProps = {
floating: boolean;
onToggle?: () => void;
view: RightSidebarView;
};
function Container({
children,
style,
className,
floating,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
floating?: boolean;
}) {
return (
<div
data-testid="header"
style={style}
className={className}
data-sidebar-floating={floating}
>
{children}
</div>
);
}
const ToggleButton = ({ onToggle }: { onToggle?: () => void }) => {
return (
<IconButton size="large" onClick={onToggle}>
<RightSidebarIcon />
</IconButton>
);
};
const Windows = ({ floating, onToggle, view }: HeaderProps) => {
return (
<Container className={styles.header} floating={floating}>
<view.header.Target></view.header.Target>
<div className={styles.spacer} />
<ToggleButton onToggle={onToggle} />
<div className={styles.windowsAppControlsContainer}>
<WindowsAppControls />
</div>
</Container>
);
};
const NonWindows = ({ floating, view, onToggle }: HeaderProps) => {
return (
<Container className={styles.header} floating={floating}>
<view.header.Target></view.header.Target>
<div className={styles.spacer} />
<ToggleButton onToggle={onToggle} />
</Container>
);
};
export const Header =
environment.isDesktop && environment.isWindows ? Windows : NonWindows;

View File

@@ -0,0 +1,42 @@
import { useService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
import { RightSidebar } from '../entities/right-sidebar';
import { RightSidebarView } from '../entities/right-sidebar-view';
export interface RightSidebarViewProps {
body: JSX.Element;
header?: JSX.Element | null;
name?: string;
active?: boolean;
}
export const RightSidebarViewIsland = ({
body,
header,
active,
}: RightSidebarViewProps) => {
const rightSidebar = useService(RightSidebar);
const view = useMemo(() => new RightSidebarView(), []);
useEffect(() => {
rightSidebar._append(view);
return () => {
rightSidebar._remove(view);
};
}, [rightSidebar, view]);
useEffect(() => {
if (active) {
rightSidebar._moveToFront(view);
}
}, [active, rightSidebar, view]);
return (
<>
<view.header.Provider>{header}</view.header.Provider>
<view.body.Provider>{body}</view.body.Provider>
</>
);
};

View File

@@ -11,6 +11,7 @@ import {
LocalStorageGlobalCache,
LocalStorageGlobalState,
} from './infra-web/storage';
import { RightSidebar } from './right-sidebar/entities/right-sidebar';
import { Workbench } from './workbench';
import {
CurrentWorkspaceService,
@@ -23,6 +24,7 @@ export function configureBusinessServices(services: ServiceCollection) {
services
.scope(WorkspaceScope)
.add(Workbench)
.add(RightSidebar)
.add(WorkspacePropertiesAdapter, [Workspace])
.add(CollectionService, [Workspace])
.add(WorkspaceLegacyProperties, [Workspace]);

View File

@@ -4,6 +4,8 @@ import { createMemoryHistory } from 'history';
import { nanoid } from 'nanoid';
import { Observable } from 'rxjs';
import { createIsland } from '../../../utils/island';
export class View {
id = nanoid();
@@ -19,6 +21,9 @@ export class View {
this.history.location
);
header = createIsland();
body = createIsland();
push(path: To) {
this.history.push(path);
}

View File

@@ -1,2 +1,7 @@
export * from './view';
export * from './workbench';
export { View } from './entities/view';
export { Workbench } from './entities/workbench';
export { useIsActiveView } from './view/use-is-active-view';
export { ViewBodyIsland } from './view/view-body-island';
export { ViewHeaderIsland } from './view/view-header-island';
export { WorkbenchLink } from './view/workbench-link';
export { WorkbenchRoot } from './view/workbench-root';

View File

@@ -4,7 +4,7 @@ import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useLocation, useNavigate } from 'react-router-dom';
import type { Workbench } from './workbench';
import type { Workbench } from '../entities/workbench';
/**
* This hook binds the workbench to the browser router.

View File

@@ -3,7 +3,7 @@ import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useLocation } from 'react-router-dom';
import type { Workbench } from './workbench';
import type { Workbench } from '../entities/workbench';
/**
* This hook binds the workbench to the browser router.

View File

@@ -1 +0,0 @@
export * from './view';

View File

@@ -0,0 +1,57 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
overflow: 'hidden',
width: '100%',
position: 'relative',
flexDirection: 'column',
minWidth: 0,
background: cssVar('backgroundPrimaryColor'),
});
export const header = style({
display: 'flex',
height: '52px',
width: '100%',
alignItems: 'center',
flexShrink: 0,
background: cssVar('backgroundPrimaryColor'),
padding: '0 16px',
['WebkitAppRegion' as string]: 'drag',
'@media': {
print: {
display: 'none',
},
},
});
export const viewBodyContainer = style({
display: 'flex',
flex: 1,
overflow: 'hidden',
});
export const leftSidebarButton = style({
margin: '0 16px 0 0',
});
export const rightSidebarButton = style({
margin: '0 0 0 16px',
});
export const viewHeaderContainer = style({
display: 'flex',
height: '100%',
flexGrow: 1,
minWidth: 12,
});
export const windowsAppControlsContainer = style({
display: 'flex',
height: '100%',
marginRight: '-16px',
paddingLeft: '16px',
});

View File

@@ -0,0 +1,76 @@
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';
import { useService } from '@toeverything/infra/di';
import { useAtomValue } from 'jotai';
import { Suspense, useCallback } from 'react';
import { RightSidebar } from '../../right-sidebar';
import * as styles from './route-container.css';
import { useView } from './use-view';
import { useViewPosition } from './use-view-position';
export interface Props {
route: {
Component: React.ComponentType;
};
}
const ToggleButton = ({
onToggle,
className,
}: {
onToggle?: () => void;
className: string;
}) => {
return (
<IconButton size="large" onClick={onToggle} className={className}>
<RightSidebarIcon />
</IconButton>
);
};
export const RouteContainer = ({ route }: Props) => {
const view = useView();
const viewPosition = useViewPosition();
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
const rightSidebar = useService(RightSidebar);
const rightSidebarOpen = useLiveData(rightSidebar.isOpen);
const rightSidebarHasViews = useLiveData(rightSidebar.hasViews);
const handleToggleRightSidebar = useCallback(() => {
rightSidebar.toggle();
}, [rightSidebar]);
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
return (
<div className={styles.root}>
<div className={styles.header}>
{viewPosition.isFirst && !leftSidebarOpen && (
<SidebarSwitch className={styles.leftSidebarButton} />
)}
<view.header.Target className={styles.viewHeaderContainer} />
{viewPosition.isLast && !rightSidebarOpen && rightSidebarHasViews && (
<>
<ToggleButton
className={styles.rightSidebarButton}
onToggle={handleToggleRightSidebar}
/>
{isWindowsDesktop && (
<div className={styles.windowsAppControlsContainer}>
<WindowsAppControls />
</div>
)}
</>
)}
</div>
<view.body.Target className={styles.viewBodyContainer} />
<Suspense fallback={<>loading</>}>
<route.Component />
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,12 @@
import { useService } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import { Workbench } from '../entities/workbench';
import { useView } from './use-view';
export function useIsActiveView() {
const workbench = useService(Workbench);
const currentView = useView();
const activeView = useLiveData(workbench.activeView);
return currentView === activeView;
}

View File

@@ -0,0 +1,35 @@
import { useService } from '@toeverything/infra/di';
import { useEffect, useState } from 'react';
import type { View } from '../entities/view';
import { Workbench } from '../entities/workbench';
import { useView } from './use-view';
export const useViewPosition = () => {
const workbench = useService(Workbench);
const view = useView();
const [position, setPosition] = useState(() =>
calcPosition(view, workbench.views.value)
);
useEffect(() => {
const subscription = workbench.views.subscribe(views => {
setPosition(calcPosition(view, views));
});
return () => {
subscription.unsubscribe();
};
}, [view, workbench]);
return position;
};
function calcPosition(view: View, viewList: View[]) {
const index = viewList.indexOf(view);
return {
index: index,
isFirst: index === 0,
isLast: index === viewList.length - 1,
};
}

View File

@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
import type { View } from '../entities/view';
export const ViewContext = createContext<View | null>(null);
export const useView = () => {
const view = useContext(ViewContext);
if (!view) {
throw new Error(
'No view found in context. Make sure you are rendering inside a ViewRoot.'
);
}
return view;
};

View File

@@ -0,0 +1,6 @@
import { useView } from './use-view';
export const ViewBodyIsland = ({ children }: React.PropsWithChildren) => {
const view = useView();
return <view.body.Provider>{children}</view.body.Provider>;
};

View File

@@ -0,0 +1,6 @@
import { useView } from './use-view';
export const ViewHeaderIsland = ({ children }: React.PropsWithChildren) => {
const view = useView();
return <view.header.Provider>{children}</view.header.Provider>;
};

View File

@@ -1,5 +1,5 @@
import { useLiveData } from '@toeverything/infra/livedata';
import { useEffect, useMemo } from 'react';
import { lazy as reactLazy, useEffect, useMemo } from 'react';
import {
createMemoryRouter,
RouterProvider,
@@ -8,10 +8,30 @@ import {
} from 'react-router-dom';
import { viewRoutes } from '../../../router';
import type { View } from './view';
import type { View } from '../entities/view';
import { RouteContainer } from './route-container';
import { ViewContext } from './use-view';
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
const Component = reactLazy(() =>
lazy().then(m => ({
default: m.Component as React.ComponentType,
}))
);
const route = {
Component,
};
return {
path,
Component: () => {
return <RouteContainer route={route} />;
},
};
});
export const ViewRoot = ({ view }: { view: View }) => {
const viewRouter = useMemo(() => createMemoryRouter(viewRoutes), []);
const viewRouter = useMemo(() => createMemoryRouter(warpedRoutes), []);
const location = useLiveData(view.location);
@@ -23,16 +43,18 @@ export const ViewRoot = ({ view }: { view: View }) => {
// https://github.com/remix-run/react-router/issues/7375#issuecomment-975431736
return (
<UNSAFE_LocationContext.Provider value={null as any}>
<UNSAFE_RouteContext.Provider
value={{
outlet: null,
matches: [],
isDataRoute: false,
}}
>
<RouterProvider router={viewRouter} />
</UNSAFE_RouteContext.Provider>
</UNSAFE_LocationContext.Provider>
<ViewContext.Provider value={view}>
<UNSAFE_LocationContext.Provider value={null as any}>
<UNSAFE_RouteContext.Provider
value={{
outlet: null,
matches: [],
isDataRoute: false,
}}
>
<RouterProvider router={viewRouter} />
</UNSAFE_RouteContext.Provider>
</UNSAFE_LocationContext.Provider>
</ViewContext.Provider>
);
};

View File

@@ -2,7 +2,7 @@ import { useService } from '@toeverything/infra/di';
import type { To } from 'history';
import { useCallback } from 'react';
import { Workbench } from './workbench';
import { Workbench } from '../entities/workbench';
export const WorkbenchLink = ({
to,
@@ -17,11 +17,15 @@ export const WorkbenchLink = ({
(event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
// TODO: open this when multi view control is implemented
// if (environment.isDesktop && (event.ctrlKey || event.metaKey)) {
// workbench.open(to, { at: 'beside' });
// } else {
workbench.open(to);
// }
if (
(window as any).enableMultiView &&
environment.isDesktop &&
(event.ctrlKey || event.metaKey)
) {
workbench.open(to, { at: 'beside' });
} else {
workbench.open(to);
}
onClick?.(event);
},

View File

@@ -3,8 +3,17 @@ import { style } from '@vanilla-extract/css';
export const workbenchRootContainer = style({
display: 'flex',
height: '100%',
flex: 1,
overflow: 'hidden',
selectors: {
[`&[data-client-border="true"]`]: {
gap: '8px',
},
},
});
export const workbenchViewContainer = style({
flex: 1,
overflow: 'hidden',
height: '100%',
});

View File

@@ -1,17 +1,16 @@
import { appSettingAtom } from '@toeverything/infra/atom';
import { useService } from '@toeverything/infra/di';
import { useLiveData } from '@toeverything/infra/livedata';
import { useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import type { View } from '../entities/view';
import { Workbench } from '../entities/workbench';
import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
import type { View } from './view';
import { ViewRoot } from './view/view-root';
import { Workbench } from './workbench';
import {
workbenchRootContainer,
workbenchViewContainer,
} from './workbench-root.css';
import { ViewRoot } from './view-root';
import * as styles from './workbench-root.css';
const useAdapter = environment.isDesktop
? useBindWorkbenchToDesktopRouter
@@ -30,8 +29,13 @@ export const WorkbenchRoot = () => {
useAdapter(workbench, basename);
const { clientBorder } = useAtomValue(appSettingAtom);
return (
<div className={workbenchRootContainer}>
<div
className={styles.workbenchRootContainer}
data-client-border={!!clientBorder}
>
{views.map((view, index) => (
<WorkbenchView key={view.id} view={view} index={index} />
))}
@@ -46,8 +50,25 @@ const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
workbench.active(index);
}, [workbench, index]);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (containerRef.current) {
const element = containerRef.current;
element.addEventListener('mousedown', handleOnFocus, {
capture: true,
});
return () => {
element.removeEventListener('mousedown', handleOnFocus, {
capture: true,
});
};
}
return;
}, [handleOnFocus]);
return (
<div className={workbenchViewContainer} onMouseDownCapture={handleOnFocus}>
<div className={styles.workbenchViewContainer} ref={containerRef}>
<ViewRoot key={view.id} view={view} />
</div>
);