refactor(core): workbench (#7355)

Merge the right sidebar logic into the workbench. this can simplify our logic.

Previously we had 3 modules

* workbench
* right-sidebar (Control sidebar open&close)
* multi-tab-sidebar (Control tabs)

Now everything is managed in Workbench.

# Behavioral changes

The sidebar button is always visible and can be opened at any time.
If there is no content to display,  will be `No Selection`

![CleanShot 2024-06-28 at 14.00.41.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/d74b3a60-2299-452e-877e-188186fe5ee5.png)

Elements in the sidebar can now be defined as`unmountOnInactive=false`. Inactive sidebars are marked with `display: none` but not unmount, so the `ChatPanel` can always remain in the DOM and user input will be retained even if the sidebar is closed.
This commit is contained in:
EYHN
2024-07-12 04:11:05 +00:00
parent 5f16cb400d
commit 5dd7382693
55 changed files with 831 additions and 704 deletions
@@ -11,7 +11,6 @@ import { configurePeekViewModule } from './peek-view';
import { configurePermissionsModule } from './permissions';
import { configureWorkspacePropertiesModule } from './properties';
import { configureQuickSearchModule } from './quicksearch';
import { configureRightSidebarModule } from './right-sidebar';
import { configureShareDocsModule } from './share-doc';
import { configureStorageImpls } from './storage';
import { configureTagModule } from './tag';
@@ -22,7 +21,6 @@ export function configureCommonModules(framework: Framework) {
configureInfraModules(framework);
configureCollectionModule(framework);
configureNavigationModule(framework);
configureRightSidebarModule(framework);
configureTagModule(framework);
configureWorkbenchModule(framework);
configureWorkspacePropertiesModule(framework);
@@ -1,4 +0,0 @@
export type { SidebarTabName } from './multi-tabs/sidebar-tab';
export { sidebarTabs, type TabOnLoadFn } from './multi-tabs/sidebar-tabs';
export { MultiTabSidebarBody } from './view/body';
export { MultiTabSidebarHeaderSwitcher } from './view/header-switcher';
@@ -1,14 +0,0 @@
import type { AffineEditorContainer } from '@blocksuite/presets';
export type SidebarTabName = 'outline' | 'frame' | 'chat' | 'journal';
export interface SidebarTabProps {
editor: AffineEditorContainer | null;
onLoad: ((component: HTMLElement) => void) | null;
}
export interface SidebarTab {
name: SidebarTabName;
icon: React.ReactNode;
Component: React.ComponentType<SidebarTabProps>;
}
@@ -1,16 +0,0 @@
import type { SidebarTab } from './sidebar-tab';
import { chatTab } from './tabs/chat';
import { framePanelTab } from './tabs/frame';
import { journalTab } from './tabs/journal';
import { outlineTab } from './tabs/outline';
export type TabOnLoadFn = (component: HTMLElement) => void;
// the list of all possible tabs in affine.
// order matters (determines the order of the tabs)
export const sidebarTabs: SidebarTab[] = [
chatTab,
journalTab,
outlineTab,
framePanelTab,
];
@@ -1,6 +0,0 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});
@@ -1,66 +0,0 @@
import { ChatPanel } from '@affine/core/blocksuite/presets/ai';
import { assertExists } from '@blocksuite/global/utils';
import { AiIcon } from '@blocksuite/icons/rc';
import { useCallback, useEffect, useRef } from 'react';
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
import * as styles from './chat.css';
// A wrapper for CopilotPanel
const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
const chatPanelRef = useRef<ChatPanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(chatPanelRef.current, 'chat panel should be initialized');
container.append(chatPanelRef.current);
}
}, []);
useEffect(() => {
if (onLoad && chatPanelRef.current) {
(chatPanelRef.current as ChatPanel).updateComplete
.then(() => {
onLoad(chatPanelRef.current as HTMLElement);
})
.catch(console.error);
}
}, [onLoad]);
useEffect(() => {
if (!editor) return;
const pageService = editor.host.spec.getService('affine:page');
const disposable = [
pageService.slots.docLinkClicked.on(() => {
(chatPanelRef.current as ChatPanel).doc = editor.doc;
}),
pageService.docModeService.onModeChange(() => {
if (!editor.host) return;
(chatPanelRef.current as ChatPanel).host = editor.host;
}),
];
return () => disposable.forEach(d => d.dispose());
}, [editor]);
if (!editor) {
return;
}
if (!chatPanelRef.current) {
chatPanelRef.current = new ChatPanel();
}
(chatPanelRef.current as ChatPanel).host = editor.host;
(chatPanelRef.current as ChatPanel).doc = editor.doc;
// (copilotPanelRef.current as CopilotPanel).fitPadding = [20, 20, 20, 20];
return <div className={styles.root} ref={onRefChange} />;
};
export const chatTab: SidebarTab = {
name: 'chat',
icon: <AiIcon />,
Component: EditorChatPanel,
};
@@ -1,6 +0,0 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});
@@ -1,40 +0,0 @@
import { assertExists } from '@blocksuite/global/utils';
import { FrameIcon } from '@blocksuite/icons/rc';
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,
};
@@ -1,227 +0,0 @@
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(@catsjuice): 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%)',
});
@@ -1,389 +0,0 @@
import type { DateCell } from '@affine/component';
import { 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 { useI18n } from '@affine/i18n';
import {
EdgelessIcon,
MoreHorizontalIcon,
PageIcon,
TodayIcon,
} from '@blocksuite/icons/rc';
import type { DocRecord } from '@toeverything/infra';
import {
DocService,
DocsService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
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> {
docRecord: DocRecord;
right?: ReactNode;
}
const PageItem = ({ docRecord, right, className, ...attrs }: PageItemProps) => {
const title = useLiveData(docRecord.title$);
const mode = useLiveData(docRecord.mode$);
const workspace = useService(WorkspaceService).workspace;
const { isJournal } = useJournalInfoHelper(
workspace.docCollection,
docRecord.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 = useI18n();
const doc = useService(DocService).doc;
const workspace = useService(WorkspaceService).workspace;
const { journalDate, isJournal } = useJournalInfoHelper(
workspace.docCollection,
doc.id
);
const { openJournal } = useJournalRouteHelper(workspace.docCollection);
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(@catsjuice): 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 = (
docs: DocRecord[],
field: 'updatedDate' | 'createDate',
order: 'asc' | 'desc' = 'desc'
) => {
return [...docs].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 = useI18n();
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(WorkspaceService).workspace;
const nodeRef = useRef<HTMLDivElement>(null);
const t = useI18n();
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
const docRecords = useLiveData(useService(DocsService).list.docs$);
const navigateHelper = useNavigateHelper();
const getTodaysPages = useCallback(
(field: 'createDate' | 'updatedDate') => {
return sortPagesByDate(
docRecords.filter(docRecord => {
const meta = docRecord.meta$.value;
if (meta.trash) return false;
return meta[field] && dayjs(meta[field]).isSame(date, 'day');
}),
field
);
},
[date, docRecords]
);
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}
docRecord={pageRecord}
/>
))}
</div>
</Scrollable.Viewport>
</Scrollable.Root>
);
})}
</main>
</div>
);
};
const MAX_CONFLICT_COUNT = 5;
interface ConflictListProps
extends PropsWithChildren,
HTMLAttributes<HTMLDivElement> {
docRecords: DocRecord[];
}
const ConflictList = ({
docRecords,
children,
className,
...attrs
}: ConflictListProps) => {
const navigateHelper = useNavigateHelper();
const workspace = useService(WorkspaceService).workspace;
const currentDoc = useService(DocService).doc;
const { setTrashModal } = useTrashModalHelper(workspace.docCollection);
const handleOpenTrashModal = useCallback(
(docRecord: DocRecord) => {
setTrashModal({
open: true,
pageIds: [docRecord.id],
pageTitles: [docRecord.title$.value],
});
},
[setTrashModal]
);
return (
<div className={clsx(styles.journalConflictWrapper, className)} {...attrs}>
{docRecords.map(docRecord => {
const isCurrent = docRecord.id === currentDoc.id;
return (
<PageItem
aria-selected={isCurrent}
docRecord={docRecord}
key={docRecord.id}
right={
<Menu
items={
<MoveToTrash
onSelect={() => handleOpenTrashModal(docRecord)}
/>
}
>
<IconButton type="plain">
<MoreHorizontalIcon />
</IconButton>
</Menu>
}
onClick={() => navigateHelper.openPage(workspace.id, docRecord.id)}
/>
);
})}
{children}
</div>
);
};
const JournalConflictBlock = ({ date }: JournalBlockProps) => {
const t = useI18n();
const workspace = useService(WorkspaceService).workspace;
const docRecordList = useService(DocsService).list;
const journalHelper = useJournalHelper(workspace.docCollection);
const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD'));
const docRecords = useLiveData(
docRecordList.docs$.map(records =>
records.filter(v => {
return docs.some(doc => doc.id === v.id);
})
)
);
if (docs.length <= 1) return null;
return (
<ConflictList
className={styles.journalConflictBlock}
docRecords={docRecords.slice(0, MAX_CONFLICT_COUNT)}
>
{docs.length > MAX_CONFLICT_COUNT ? (
<Menu
items={
<ConflictList docRecords={docRecords.slice(MAX_CONFLICT_COUNT)} />
}
>
<div className={styles.journalConflictMoreTrigger}>
{t['com.affine.journal.conflict-show-more']({
count: (docRecords.length - MAX_CONFLICT_COUNT).toFixed(0),
})}
</div>
</Menu>
) : null}
</ConflictList>
);
};
export const journalTab: SidebarTab = {
name: 'journal',
icon: <TodayIcon />,
Component: EditorJournalPanel,
};
@@ -1,6 +0,0 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});
@@ -1,40 +0,0 @@
import { assertExists } from '@blocksuite/global/utils';
import { TocIcon } from '@blocksuite/icons/rc';
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,
};
@@ -1,12 +0,0 @@
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%',
overflow: 'hidden',
alignItems: 'center',
borderTop: `1px solid ${cssVar('borderColor')}`,
});
@@ -1,17 +0,0 @@
import type { PropsWithChildren } from 'react';
import type { SidebarTab, SidebarTabProps } from '../multi-tabs/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>
);
};
@@ -1,43 +0,0 @@
import type { RadioItem } from '@affine/component';
import { RadioGroup } from '@affine/component';
import { cssVar } from '@toeverything/theme';
import { useMemo } from 'react';
import type { SidebarTab, SidebarTabName } from '../multi-tabs/sidebar-tab';
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 tabItems = useMemo(() => {
return tabs.map(extension => {
return {
value: extension.name,
label: extension.icon,
style: { padding: 0, fontSize: 20, width: 24 },
} satisfies RadioItem;
});
}, [tabs]);
return (
<RadioGroup
borderRadius={8}
itemHeight={24}
padding={4}
gap={8}
items={tabItems}
value={activeTabName}
onChange={setActiveTabName}
activeItemStyle={{ color: cssVar('primaryColor') }}
/>
);
};
@@ -1,8 +0,0 @@
import { Entity } from '@toeverything/infra';
import { createIsland } from '../../../utils/island';
export class RightSidebarView extends Entity {
readonly body = createIsland();
readonly header = createIsland();
}
@@ -1,97 +0,0 @@
import type { GlobalState } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import { combineLatest } from 'rxjs';
import type { SidebarTabName } from '../../multi-tab-sidebar';
import { RightSidebarView } from './right-sidebar-view';
const RIGHT_SIDEBAR_KEY = 'app:settings:rightsidebar';
const RIGHT_SIDEBAR_TABS_ACTIVE_KEY = 'app:settings:rightsidebar:tabs:active';
const RIGHT_SIDEBAR_AI_HAS_EVER_OPENED_KEY =
'app:settings:rightsidebar:ai:has-ever-opened';
export class RightSidebar extends Entity {
_disposables: Array<() => void> = [];
constructor(private readonly globalState: GlobalState) {
super();
const sub = combineLatest([this.activeTabName$, this.isOpen$]).subscribe(
([name, open]) => {
if (name === 'chat' && open) {
this.globalState.set(RIGHT_SIDEBAR_AI_HAS_EVER_OPENED_KEY, true);
}
}
);
this._disposables.push(() => sub.unsubscribe());
}
readonly isOpen$ = LiveData.from(
this.globalState.watch<boolean>(RIGHT_SIDEBAR_KEY),
false
).map(Boolean);
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);
readonly activeTabName$ = LiveData.from(
this.globalState.watch<SidebarTabName>(RIGHT_SIDEBAR_TABS_ACTIVE_KEY),
null
);
/** To determine if AI chat has ever been opened, used to show the animation for the first time */
readonly aiChatHasEverOpened$ = LiveData.from(
this.globalState.watch<boolean>(RIGHT_SIDEBAR_AI_HAS_EVER_OPENED_KEY),
false
);
override dispose() {
super.dispose();
this._disposables.forEach(dispose => dispose());
}
setActiveTabName(name: SidebarTabName) {
this.globalState.set(RIGHT_SIDEBAR_TABS_ACTIVE_KEY, name);
}
open() {
this._set(true);
}
toggle() {
this._set(!this.isOpen$.value);
}
close() {
this._set(false);
}
_set(value: boolean) {
this.globalState.set(RIGHT_SIDEBAR_KEY, value);
}
/**
* @private use `RightSidebarViewIsland` instead
*/
_append() {
const view = this.framework.createEntity(RightSidebarView);
this.views$.next([...this.views$.value, view]);
return 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));
}
}
@@ -1,22 +0,0 @@
export { RightSidebar } from './entities/right-sidebar';
export { RightSidebarService } from './services/right-sidebar';
export { RightSidebarContainer } from './view/container';
export { RightSidebarViewIsland } from './view/view-island';
import {
type Framework,
GlobalState,
WorkspaceScope,
} from '@toeverything/infra';
import { RightSidebar } from './entities/right-sidebar';
import { RightSidebarView } from './entities/right-sidebar-view';
import { RightSidebarService } from './services/right-sidebar';
export function configureRightSidebarModule(services: Framework) {
services
.scope(WorkspaceScope)
.service(RightSidebarService)
.entity(RightSidebar, [GlobalState])
.entity(RightSidebarView);
}
@@ -1,7 +0,0 @@
import { Service } from '@toeverything/infra';
import { RightSidebar } from '../entities/right-sidebar';
export class RightSidebarService extends Service {
rightSidebar = this.framework.createEntity(RightSidebar);
}
@@ -1,79 +0,0 @@
import { ResizePanel } from '@affine/component/resize-panel';
import { rightSidebarWidthAtom } from '@affine/core/atoms';
import { appSettingAtom, useLiveData, useService } from '@toeverything/infra';
import { useAtom, useAtomValue } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import { RightSidebarService } from '../services/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] = useAtom(rightSidebarWidthAtom);
const [resizing, setResizing] = useState(false);
const rightSidebar = useService(RightSidebarService).rightSidebar;
const frontView = useLiveData(rightSidebar.front$);
const open = useLiveData(rightSidebar.isOpen$) && frontView !== undefined;
const [floating, setFloating] = useState(false);
useEffect(() => {
const onResize = () => setFloating(!!(window.innerWidth < 768));
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
rightSidebar.open();
} else {
rightSidebar.close();
}
},
[rightSidebar]
);
const handleToggleOpen = useCallback(() => {
rightSidebar.toggle();
}, [rightSidebar]);
return (
<ResizePanel
floating={floating}
resizeHandlePos="left"
resizeHandleOffset={clientBorder ? 3.5 : 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>
);
};
@@ -1,48 +0,0 @@
import { useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import type { RightSidebarView } from '../entities/right-sidebar-view';
import { RightSidebarService } from '../services/right-sidebar';
export interface RightSidebarViewProps {
body: JSX.Element;
header?: JSX.Element | null;
name?: string;
active?: boolean;
}
export const RightSidebarViewIsland = ({
body,
header,
active,
}: RightSidebarViewProps) => {
const rightSidebar = useService(RightSidebarService).rightSidebar;
const [view, setView] = useState<RightSidebarView | null>(null);
useEffect(() => {
const view = rightSidebar._append();
setView(view);
return () => {
rightSidebar._remove(view);
setView(null);
};
}, [rightSidebar]);
useEffect(() => {
if (active && view) {
rightSidebar._moveToFront(view);
}
}, [active, rightSidebar, view]);
if (!view) {
return null;
}
return (
<>
<view.header.Provider>{header}</view.header.Provider>
<view.body.Provider>{body}</view.body.Provider>
</>
);
};
@@ -0,0 +1,43 @@
# Workbench
```
┌─────────────Workbench─────-----──────┐
| Tab1 | Tab2 | Tab3 - □ x |
│ ┌───────┐ ┌───────┐ ┌───────┐ ┌──────┤
│ │header │ │header │ │header │ │ │
│ │ │ │ │ │ │ │ side │
│ │ │ │ │ │ │ │ bar │
│ │ view │ │ view │ │ view │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
│ └───────┘ └───────┘ └───────┘ │ │
└───────────────────────────────┴──────┘
```
`Workbench` is the window manager in affine, including the main area and the right sidebar area.
`View` is a managed window under the workbench. Each view has its own history(Support go back and forward) and currently URL.
The view renders the content as defined by the router ([here](../../router.tsx)).
Each route can render its own `Header`, `Body`, and several `Sidebar`s by [ViewIsland](./view/view-islands.tsx).
The `Workbench` manages all Views and decides when to display and close them.
There is always one **active View**, and the URL of the active View is considered the URL of the entire application.
## Sidebar
Each `View` can define its `Sidebar`, which will be displayed in the right area of the screen.
If the same view has multiple sidebars, a switcher will be displayed so that users can switch between multiple sidebars.
> only the sidebar of the currently active view will be displayed.
## Tab
WIP
## Persistence
When close the application and reopen, the entire workbench should be restored to its previous state.
WIP
> If running in a browser, the workbench will passing the browser's back and forward navigation to the active view.
@@ -0,0 +1,5 @@
import { Entity } from '@toeverything/infra';
export class SidebarTab extends Entity<{ id: string }> {
readonly id = this.props.id;
}
@@ -2,19 +2,36 @@ import { Entity, LiveData } from '@toeverything/infra';
import type { Location, To } from 'history';
import { Observable } from 'rxjs';
import { createIsland } from '../../../utils/island';
import { createNavigableHistory } from '../../../utils/navigable-history';
import type { ViewScope } from '../scopes/view';
import { ViewScope } from '../scopes/view';
import { SidebarTab } from './sidebar-tab';
export class View extends Entity {
id = this.scope.props.id;
export class View extends Entity<{
id: string;
defaultLocation?: To | undefined;
}> {
scope = this.framework.createScope(ViewScope, {
view: this as View,
});
id = this.props.id;
constructor(public readonly scope: ViewScope) {
sidebarTabs$ = new LiveData<SidebarTab[]>([]);
// _activeTabId may point to a non-existent tab.
// In this case, we still retain the activeTabId data and wait for the non-existent tab to be mounted.
_activeSidebarTabId$ = new LiveData<string | null>(null);
activeSidebarTab$ = LiveData.computed(get => {
const activeTabId = get(this._activeSidebarTabId$);
const tabs = get(this.sidebarTabs$);
return tabs.length > 0
? tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
: null;
});
constructor() {
super();
this.history = createNavigableHistory({
initialEntries: [
this.scope.props.defaultLocation ?? { pathname: '/all' },
],
initialEntries: [this.props.defaultLocation ?? { pathname: '/all' }],
initialIndex: 0,
});
}
@@ -45,11 +62,6 @@ export class View extends Entity {
);
size$ = new LiveData(100);
/** Width of header content in px (excludes sidebar-toggle/windows button/...) */
headerContentWidth$ = new LiveData(1920);
header = createIsland();
body = createIsland();
push(path: To) {
this.history.push(path);
@@ -66,4 +78,24 @@ export class View extends Entity {
setSize(size?: number) {
this.size$.next(size ?? 100);
}
addSidebarTab(id: string) {
this.sidebarTabs$.next([
...this.sidebarTabs$.value,
this.scope.createEntity(SidebarTab, {
id,
}),
]);
return id;
}
removeSidebarTab(id: string) {
this.sidebarTabs$.next(
this.sidebarTabs$.value.filter(tab => tab.id !== id)
);
}
activeSidebarTab(id: string | null) {
this._activeSidebarTabId$.next(id);
}
}
@@ -2,11 +2,8 @@ import { Unreachable } from '@affine/env/constant';
import { Entity, LiveData } from '@toeverything/infra';
import type { To } from 'history';
import { nanoid } from 'nanoid';
import { combineLatest, map, switchMap } from 'rxjs';
import { ViewScope } from '../scopes/view';
import { ViewService } from '../services/view';
import type { View } from './view';
import { View } from './view';
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
@@ -17,24 +14,20 @@ interface WorkbenchOpenOptions {
export class Workbench extends Entity {
readonly views$ = new LiveData([
this.framework.createScope(ViewScope, { id: nanoid() }).get(ViewService)
.view,
this.framework.createEntity(View, { id: nanoid() }),
]);
activeViewIndex$ = new LiveData(0);
activeView$ = LiveData.from(
combineLatest([this.views$, this.activeViewIndex$]).pipe(
map(([views, index]) => views[index])
),
this.views$.value[this.activeViewIndex$.value]
);
activeView$ = LiveData.computed(get => {
const activeIndex = get(this.activeViewIndex$);
const views = get(this.views$);
return views[activeIndex];
});
basename$ = new LiveData('/');
location$ = LiveData.from(
this.activeView$.pipe(switchMap(view => view.location$)),
this.views$.value[this.activeViewIndex$.value].history.location
);
location$ = LiveData.computed(get => {
return get(get(this.activeView$).location$);
});
sidebarOpen$ = new LiveData(false);
active(index: number) {
index = Math.max(0, Math.min(index, this.views$.value.length - 1));
@@ -42,13 +35,28 @@ export class Workbench extends Entity {
}
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
const view = this.framework
.createScope(ViewScope, { id: nanoid(), defaultLocation })
.get(ViewService).view;
const view = this.framework.createEntity(View, {
id: nanoid(),
defaultLocation,
});
const newViews = [...this.views$.value];
newViews.splice(this.indexAt(at), 0, view);
this.views$.next(newViews);
return newViews.indexOf(view);
const index = newViews.indexOf(view);
this.active(index);
return index;
}
openSidebar() {
this.sidebarOpen$.next(true);
}
closeSidebar() {
this.sidebarOpen$.next(false);
}
toggleSidebar() {
this.sidebarOpen$.next(!this.sidebarOpen$.value);
}
open(
@@ -1,14 +1,14 @@
export { Workbench } from './entities/workbench';
export { ViewScope as View } from './scopes/view';
export { ViewScope } from './scopes/view';
export { WorkbenchService } from './services/workbench';
export { useIsActiveView } from './view/use-is-active-view';
export { ViewBodyIsland } from './view/view-body-island';
export { ViewHeaderIsland } from './view/view-header-island';
export { ViewBody, ViewHeader, ViewSidebarTab } from './view/view-islands';
export { WorkbenchLink } from './view/workbench-link';
export { WorkbenchRoot } from './view/workbench-root';
import { type Framework, WorkspaceScope } from '@toeverything/infra';
import { SidebarTab } from './entities/sidebar-tab';
import { View } from './entities/view';
import { Workbench } from './entities/workbench';
import { ViewScope } from './scopes/view';
@@ -20,7 +20,8 @@ export function configureWorkbenchModule(services: Framework) {
.scope(WorkspaceScope)
.service(WorkbenchService)
.entity(Workbench)
.entity(View)
.scope(ViewScope)
.entity(View, [ViewScope])
.service(ViewService);
.service(ViewService, [ViewScope])
.entity(SidebarTab);
}
@@ -1,7 +1,7 @@
import { Scope } from '@toeverything/infra';
import type { To } from 'history';
import type { View } from '../entities/view';
export class ViewScope extends Scope<{
id: string;
defaultLocation?: To | undefined;
view: View;
}> {}
@@ -1,7 +1,11 @@
import { Service } from '@toeverything/infra';
import { View } from '../entities/view';
import type { ViewScope } from '../scopes/view';
export class ViewService extends Service {
view = this.framework.createEntity(View);
view = this.scope.props.view;
constructor(private readonly scope: ViewScope) {
super();
}
}
@@ -1,17 +1,18 @@
import { IconButton, observeResize } from '@affine/component';
import { IconButton } from '@affine/component';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { RightSidebarIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useAtomValue } from 'jotai';
import { Suspense, useCallback, useEffect, useRef } from 'react';
import { Suspense, useCallback } from 'react';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
import { appSidebarOpenAtom } from '../../../components/app-sidebar/index.jotai';
import { SidebarSwitch } from '../../../components/app-sidebar/sidebar-header/sidebar-switch';
import { RightSidebarService } from '../../right-sidebar';
import { ViewService } from '../services/view';
import { WorkbenchService } from '../services/workbench';
import * as styles from './route-container.css';
import { useViewPosition } from './use-view-position';
import { ViewBodyTarget, ViewHeaderTarget } from './view-islands';
export interface Props {
route: {
@@ -41,25 +42,16 @@ const ToggleButton = ({
};
export const RouteContainer = ({ route }: Props) => {
const viewHeaderContainerRef = useRef<HTMLDivElement | null>(null);
const view = useService(ViewService).view;
const viewPosition = useViewPosition();
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
const rightSidebar = useService(RightSidebarService).rightSidebar;
const rightSidebarOpen = useLiveData(rightSidebar.isOpen$);
const rightSidebarHasViews = useLiveData(rightSidebar.hasViews$);
const handleToggleRightSidebar = useCallback(() => {
rightSidebar.toggle();
}, [rightSidebar]);
const workbench = useService(WorkbenchService).workbench;
const view = useService(ViewService).view;
const sidebarOpen = useLiveData(workbench.sidebarOpen$);
const handleToggleSidebar = useCallback(() => {
workbench.toggleSidebar();
}, [workbench]);
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
useEffect(() => {
const container = viewHeaderContainerRef.current;
if (!container) return;
return observeResize(container, entry => {
view.headerContentWidth$.next(entry.contentRect.width);
});
}, [view.headerContentWidth$]);
return (
<div className={styles.root}>
<div className={styles.header}>
@@ -69,34 +61,32 @@ export const RouteContainer = ({ route }: Props) => {
className={styles.leftSidebarButton}
/>
)}
<view.header.Target
ref={viewHeaderContainerRef}
<ViewHeaderTarget
viewId={view.id}
className={styles.viewHeaderContainer}
/>
{viewPosition.isLast && (
<>
{rightSidebarHasViews && (
<ToggleButton
show={!rightSidebarOpen}
className={styles.rightSidebarButton}
onToggle={handleToggleRightSidebar}
/>
<ToggleButton
show={!sidebarOpen}
className={styles.rightSidebarButton}
onToggle={handleToggleSidebar}
/>
{isWindowsDesktop && !sidebarOpen && (
<div className={styles.windowsAppControlsContainer}>
<WindowsAppControls />
</div>
)}
{isWindowsDesktop &&
!(rightSidebarOpen && rightSidebarHasViews) && (
<div className={styles.windowsAppControlsContainer}>
<WindowsAppControls />
</div>
)}
</>
)}
</div>
<AffineErrorBoundary>
<Suspense>
<route.Component />
</Suspense>
</AffineErrorBoundary>
<view.body.Target className={styles.viewBodyContainer} />
<ViewBodyTarget viewId={view.id} className={styles.viewBodyContainer} />
</div>
);
};
@@ -17,24 +17,26 @@ export const sidebarContainerInner = style({
},
});
export const sidebarContainer = style({
display: 'flex',
flexShrink: 0,
height: '100%',
right: 0,
selectors: {
[`&[data-client-border=true]`]: {
paddingLeft: 8,
borderRadius: 6,
},
[`&[data-client-border=false]`]: {
borderLeft: `1px solid ${cssVar('borderColor')}`,
},
},
});
export const sidebarBodyTarget = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
width: '100%',
height: '100%',
overflow: 'hidden',
alignItems: 'center',
borderTop: `1px solid ${cssVar('borderColor')}`,
});
export const sidebarBodyNoSelection = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
width: '100%',
height: '100%',
overflow: 'hidden',
justifyContent: 'center',
userSelect: 'none',
color: cssVar('--affine-text-secondary-color'),
alignItems: 'center',
});
@@ -0,0 +1,52 @@
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback } from 'react';
import { ViewService } from '../../services/view';
import { WorkbenchService } from '../../services/workbench';
import { ViewSidebarTabBodyTarget } from '../view-islands';
import * as styles from './sidebar-container.css';
import { Header } from './sidebar-header';
import { SidebarHeaderSwitcher } from './sidebar-header-switcher';
export const SidebarContainer = ({
className,
...props
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
const workbenchService = useService(WorkbenchService);
const workbench = workbenchService.workbench;
const viewService = useService(ViewService);
const view = viewService.view;
const sidebarTabs = useLiveData(view.sidebarTabs$);
const activeSidebarTab = useLiveData(view.activeSidebarTab$);
const handleToggleOpen = useCallback(() => {
workbench.toggleSidebar();
}, [workbench]);
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
return (
<div className={clsx(styles.sidebarContainerInner, className)} {...props}>
<Header floating={false} onToggle={handleToggleOpen}>
{!isWindowsDesktop && sidebarTabs.length > 0 && (
<SidebarHeaderSwitcher />
)}
</Header>
{isWindowsDesktop && sidebarTabs.length > 0 && <SidebarHeaderSwitcher />}
{sidebarTabs.length > 0 ? (
sidebarTabs.map(sidebar => (
<ViewSidebarTabBodyTarget
tabId={sidebar.id}
key={sidebar.id}
style={{ display: activeSidebarTab === sidebar ? 'block' : 'none' }}
viewId={view.id}
className={styles.sidebarBodyTarget}
/>
))
) : (
<div className={styles.sidebarBodyNoSelection}>No Selection</div>
)}
</div>
);
};
@@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
export const iconContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
pointerEvents: 'none',
});
@@ -0,0 +1,48 @@
import { RadioGroup } from '@affine/component';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback } from 'react';
import { ViewService } from '../../services/view';
import { ViewSidebarTabIconTarget } from '../view-islands';
import * as styles from './sidebar-header-switcher.css';
// provide a switcher for active extensions
// will be used in global top header (MacOS) or sidebar (Windows)
export const SidebarHeaderSwitcher = () => {
const view = useService(ViewService).view;
const tabs = useLiveData(view.sidebarTabs$);
const activeTab = useLiveData(view.activeSidebarTab$);
const tabItems = tabs.map(tab => ({
value: tab.id,
label: (
<ViewSidebarTabIconTarget
className={styles.iconContainer}
viewId={view.id}
tabId={tab.id}
/>
),
style: { padding: 0, fontSize: 20, width: 24 },
}));
const handleActiveTabChange = useCallback(
(tabId: string) => {
view.activeSidebarTab(tabId);
},
[view]
);
return (
<RadioGroup
borderRadius={8}
itemHeight={24}
padding={4}
gap={8}
items={tabItems}
value={activeTab}
onChange={handleActiveTabChange}
activeItemStyle={{ color: cssVar('primaryColor') }}
/>
);
};
@@ -1,14 +1,13 @@
import { IconButton } from '@affine/component';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { RightSidebarIcon } from '@blocksuite/icons/rc';
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
import type { RightSidebarView } from '../entities/right-sidebar-view';
import * as styles from './header.css';
import * as styles from './sidebar-header.css';
export type HeaderProps = {
floating: boolean;
onToggle?: () => void;
view: RightSidebarView;
children?: React.ReactNode;
};
function Container({
@@ -42,10 +41,10 @@ const ToggleButton = ({ onToggle }: { onToggle?: () => void }) => {
);
};
const Windows = ({ floating, onToggle, view }: HeaderProps) => {
const Windows = ({ floating, onToggle, children }: HeaderProps) => {
return (
<Container className={styles.header} floating={floating}>
<view.header.Target></view.header.Target>
{children}
<div className={styles.spacer} />
<ToggleButton onToggle={onToggle} />
<div className={styles.windowsAppControlsContainer}>
@@ -55,10 +54,10 @@ const Windows = ({ floating, onToggle, view }: HeaderProps) => {
);
};
const NonWindows = ({ floating, view, onToggle }: HeaderProps) => {
const NonWindows = ({ floating, children, onToggle }: HeaderProps) => {
return (
<Container className={styles.header} floating={floating}>
<view.header.Target></view.header.Target>
{children}
<div className={styles.spacer} />
<ToggleButton onToggle={onToggle} />
</Container>
@@ -1,8 +0,0 @@
import { useService } from '@toeverything/infra';
import { ViewService } from '../services/view';
export const ViewBodyIsland = ({ children }: React.PropsWithChildren) => {
const view = useService(ViewService).view;
return <view.body.Provider>{children}</view.body.Provider>;
};
@@ -1,8 +0,0 @@
import { useService } from '@toeverything/infra';
import { ViewService } from '../services/view';
export const ViewHeaderIsland = ({ children }: React.PropsWithChildren) => {
const view = useService(ViewService).view;
return <view.header.Provider>{children}</view.header.Provider>;
};
@@ -0,0 +1,219 @@
/**
* # View Islands
*
* This file defines some components that allow each UI area to be defined inside each View route as shown below,
* and the Workbench is responsible for rendering these areas into their containers.
*
* ```tsx
* const MyView = () => {
* return <>
* <ViewHeader>
* ...
* </ViewHeader>
* <ViewBody>
* ...
* </ViewBody>
* <ViewSidebarTab tabId="my-tab" icon={<MyIcon />}>
* ...
* </ViewSidebarTab>
* </>
* }
*
* const viewRoute = [
* {
* path: '/my-view',
* component: MyView,
* }
* ]
* ```
*
* Each Island is divided into `Target` and `Provider`.
* The `Provider` wraps the content to be rendered, while the `Target` is placed where it needs to be rendered.
* Then you get a view portal.
*/
import { createIsland, type Island } from '@affine/core/utils/island';
import { useLiveData, useService } from '@toeverything/infra';
import type React from 'react';
import {
createContext,
forwardRef,
type Ref,
useContext,
useEffect,
useState,
} from 'react';
import { ViewService } from '../services/view';
interface ViewIslandRegistry {
[key: string]: Island | undefined;
}
/**
* A registry context will be placed at the top level of the workbench.
*
* The `View` will create islands and place them in the registry,
* while `Workbench` can use the KEY to retrieve and display the islands.
*/
const ViewIslandRegistryContext = createContext<ViewIslandRegistry>({});
const ViewIslandSetContext = createContext<React.Dispatch<
React.SetStateAction<ViewIslandRegistry>
> | null>(null);
const ViewIsland = ({
id,
children,
}: React.PropsWithChildren<{ id: string }>) => {
const setter = useContext(ViewIslandSetContext);
if (!setter) {
throw new Error(
'ViewIslandProvider must be used inside ViewIslandRegistryProvider'
);
}
const [island] = useState<Island>(createIsland());
useEffect(() => {
setter(prev => ({ ...prev, [id]: island }));
return () => {
setter(prev => {
const next = { ...prev };
delete next[id];
return next;
});
};
}, [id, island, setter]);
return <island.Provider>{children}</island.Provider>;
};
const ViewIslandTarget = forwardRef(function ViewIslandTarget(
{
id,
children,
...otherProps
}: { id: string } & React.HTMLProps<HTMLDivElement>,
ref: Ref<HTMLDivElement>
) {
const island = useContext(ViewIslandRegistryContext)[id];
if (!island) {
return <div ref={ref} {...otherProps} />;
}
return (
<island.Target ref={ref} {...otherProps}>
{children}
</island.Target>
);
});
export const ViewIslandRegistryProvider = ({
children,
}: React.PropsWithChildren) => {
const [contextValue, setContextValue] = useState<ViewIslandRegistry>({});
return (
<ViewIslandRegistryContext.Provider value={contextValue}>
<ViewIslandSetContext.Provider value={setContextValue}>
{children}
</ViewIslandSetContext.Provider>
</ViewIslandRegistryContext.Provider>
);
};
export const ViewBody = ({ children }: React.PropsWithChildren) => {
const view = useService(ViewService).view;
return <ViewIsland id={`${view.id}:body`}>{children}</ViewIsland>;
};
export const ViewBodyTarget = forwardRef(function ViewBodyTarget(
{
viewId,
...otherProps
}: React.HTMLProps<HTMLDivElement> & { viewId: string },
ref: React.ForwardedRef<HTMLDivElement>
) {
return <ViewIslandTarget id={`${viewId}:body`} {...otherProps} ref={ref} />;
});
export const ViewHeader = ({ children }: React.PropsWithChildren) => {
const view = useService(ViewService).view;
return <ViewIsland id={`${view.id}:header`}>{children}</ViewIsland>;
};
export const ViewHeaderTarget = forwardRef(function ViewHeaderTarget(
{
viewId,
...otherProps
}: React.HTMLProps<HTMLDivElement> & { viewId: string },
ref: React.ForwardedRef<HTMLDivElement>
) {
return <ViewIslandTarget id={`${viewId}:header`} {...otherProps} ref={ref} />;
});
export const ViewSidebarTab = ({
children,
tabId,
icon,
unmountOnInactive = true,
}: React.PropsWithChildren<{
tabId: string;
icon: React.ReactNode;
unmountOnInactive?: boolean;
}>) => {
const view = useService(ViewService).view;
const activeTab = useLiveData(view.activeSidebarTab$);
useEffect(() => {
view.addSidebarTab(tabId);
return () => {
view.removeSidebarTab(tabId);
};
}, [tabId, view]);
return (
<>
<ViewIsland id={`${view.id}:sidebar:${tabId}:icon`}>{icon}</ViewIsland>
<ViewIsland id={`${view.id}:sidebar:${tabId}:body`}>
{unmountOnInactive && activeTab?.id !== tabId ? null : children}
</ViewIsland>
</>
);
};
export const ViewSidebarTabIconTarget = forwardRef(
function ViewSidebarTabIconTarget({
viewId,
tabId,
...otherProps
}: React.HTMLProps<HTMLDivElement> & { tabId: string; viewId: string }) {
return (
<ViewIslandTarget
id={`${viewId}:sidebar:${tabId}:icon`}
{...otherProps}
/>
);
}
);
export const ViewSidebarTabBodyTarget = forwardRef(
function ViewSidebarTabBodyTarget({
viewId,
tabId,
...otherProps
}: React.HTMLProps<HTMLDivElement> & {
tabId: string;
viewId: string;
}) {
return (
<ViewIslandTarget
id={`${viewId}:sidebar:${tabId}:body`}
{...otherProps}
/>
);
}
);
@@ -1,3 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const workbenchRootContainer = style({
@@ -12,3 +13,19 @@ export const workbenchViewContainer = style({
overflow: 'hidden',
height: '100%',
});
export const workbenchSidebar = style({
display: 'flex',
flexShrink: 0,
height: '100%',
right: 0,
selectors: {
[`&[data-client-border=true]`]: {
paddingLeft: 8,
borderRadius: 6,
},
[`&[data-client-border=false]`]: {
borderLeft: `1px solid ${cssVar('borderColor')}`,
},
},
});
@@ -1,12 +1,22 @@
import { useLiveData, useService } from '@toeverything/infra';
import { memo, useCallback, useEffect, useRef } from 'react';
import { ResizePanel } from '@affine/component/resize-panel';
import { rightSidebarWidthAtom } from '@affine/core/atoms';
import {
appSettingAtom,
FrameworkScope,
useLiveData,
useService,
} from '@toeverything/infra';
import { useAtom, useAtomValue } from 'jotai';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import type { View } from '../entities/view';
import { WorkbenchService } from '../services/workbench';
import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
import { SidebarContainer } from './sidebar/sidebar-container';
import { SplitView } from './split-view/split-view';
import { ViewIslandRegistryProvider } from './view-islands';
import { ViewRoot } from './view-root';
import * as styles from './workbench-root.css';
@@ -43,12 +53,15 @@ export const WorkbenchRoot = memo(() => {
}, [basename, workbench.basename$]);
return (
<SplitView
className={styles.workbenchRootContainer}
views={views}
renderer={panelRenderer}
onMove={onMove}
/>
<ViewIslandRegistryProvider>
<SplitView
className={styles.workbenchRootContainer}
views={views}
renderer={panelRenderer}
onMove={onMove}
/>
<WorkbenchSidebar />
</ViewIslandRegistryProvider>
);
});
@@ -84,3 +97,67 @@ const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
</div>
);
};
const MIN_SIDEBAR_WIDTH = 320;
const MAX_SIDEBAR_WIDTH = 800;
const WorkbenchSidebar = () => {
const { clientBorder } = useAtomValue(appSettingAtom);
const [width, setWidth] = useAtom(rightSidebarWidthAtom);
const [resizing, setResizing] = useState(false);
const workbench = useService(WorkbenchService).workbench;
const views = useLiveData(workbench.views$);
const activeView = useLiveData(workbench.activeView$);
const sidebarOpen = useLiveData(workbench.sidebarOpen$);
const [floating, setFloating] = useState(false);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
workbench.openSidebar();
} else {
workbench.closeSidebar();
}
},
[workbench]
);
useEffect(() => {
const onResize = () => setFloating(!!(window.innerWidth < 768));
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
return (
<ResizePanel
floating={floating}
resizeHandlePos="left"
resizeHandleOffset={clientBorder ? 3.5 : 0}
width={width}
resizing={resizing}
onResizing={setResizing}
className={styles.workbenchSidebar}
data-client-border={clientBorder && sidebarOpen}
open={sidebarOpen}
onOpen={handleOpenChange}
onWidthChange={setWidth}
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}
unmountOnExit={false}
>
{views.map(view => (
<FrameworkScope key={view.id} scope={view.scope}>
<SidebarContainer
style={{ display: activeView !== view ? 'none' : undefined }}
/>
</FrameworkScope>
))}
</ResizePanel>
);
};