feat(component): new right sidebar (#5169)

Refactor AFFiNE layout to support new right sidebar.

The new layout:
![image](https://github.com/toeverything/AFFiNE/assets/584378/678a05f5-bd48-4dbe-ad78-7a0bcc979918)

**Highlights:**
- new sidebar UI/UX
- favoring top-down UI components that are composed by basic building blocks in each route, instead of creating universal component like `WorkspaceHeader` that renders every possible cases (which I think is really hard to maintain)
- remove plugin based solution

**Pros/cons for current plugin-based solution:**

The current solution is somewhat a Dependency Injection (DI) approach, where the layout is defined at the top and UI items can be injected using Jotai atom slots.
This approach works well if we want a fully configurable system with everything being handled by plugins. It provides flexibility for custom extensions.
However, this solution is more suitable for single-page applications where the UI is completely controlled by configuration. It becomes challenging to achieve an optimized and visually appealing UI that remains under our control. An example of such a scenario would be a customizable dashboard like Grafana.
Another drawback of the existing solution is that we need to use Jotai and hooks to access context values, resulting in an unclear data flow within the component hierarchy.

**Alternatively, our approach in this PR** provides layout building blocks such as headers and sidebars, which can then be composed in individual route components. The good is that we have cleaner biz component instead of vague all-in-one layout component (like `<WorkspaceHeader />`).

**Issues of the implementation in this PR:**
Some UI layouts that that seems to be defined at the root layout are now defined in individual route component instead.
New 3-col layout component like the right sidebar still needs some abstraction and they are right now just for the detail editor only.
This commit is contained in:
Peng Xiao
2023-12-08 01:03:47 +00:00
parent 980831f9f1
commit 5352736eba
60 changed files with 1424 additions and 1052 deletions
@@ -18,11 +18,11 @@ import {
} from 'react-router-dom';
import { applyUpdate } from 'yjs';
import { PageDetailEditor } from '../../adapters/shared';
import type { PageMode } from '../../atoms';
import { AppContainer } from '../../components/affine/app-container';
import { ShareHeader } from '../../components/share-header';
import { PageDetailEditor } from '../../components/page-detail-editor';
import { SharePageNotFoundError } from '../../components/share-page-not-found-error';
import { ShareHeader } from './share-header';
type LoaderData = {
page: Page;
@@ -0,0 +1,38 @@
import type { Workspace } from '@blocksuite/store';
import type { PageMode } from '../../atoms';
import { BlockSuiteHeaderTitle } from '../../components/blocksuite/block-suite-header-title';
import ShareHeaderLeftItem from '../../components/cloud/share-header-left-item';
import ShareHeaderRightItem from '../../components/cloud/share-header-right-item';
import { Header } from '../../components/pure/header';
import { useWorkspace } from '../../hooks/use-workspace';
export function ShareHeader({
workspace,
pageId,
publishMode,
}: {
workspace: Workspace;
pageId: string;
publishMode: PageMode;
}) {
const currentWorkspace = useWorkspace(workspace.id);
return (
<Header
left={<ShareHeaderLeftItem />}
center={
<BlockSuiteHeaderTitle
workspace={currentWorkspace}
pageId={pageId}
isPublic={true}
publicMode={publishMode}
/>
}
right={
<ShareHeaderRightItem workspaceId={workspace.id} pageId={pageId} />
}
bottomBorder
/>
);
}
@@ -0,0 +1,60 @@
import {
FilterList,
SaveAsCollectionButton,
useCollectionManager,
} from '@affine/component/page-list';
import type { Collection, Filter } from '@affine/env/filter';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useCallback } from 'react';
import { collectionsCRUDAtom } from '../../../atoms/collections';
import { filterContainerStyle } from '../../../components/filter-container.css';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { useWorkspace } from '../../../hooks/use-workspace';
export const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
const currentWorkspace = useWorkspace(workspaceId);
const navigateHelper = useNavigateHelper();
const setting = useCollectionManager(collectionsCRUDAtom);
const saveToCollection = useCallback(
async (collection: Collection) => {
await setting.createCollection({
...collection,
filterList: setting.currentCollection.filterList,
});
navigateHelper.jumpToCollection(workspaceId, collection.id);
},
[setting, navigateHelper, workspaceId]
);
const onFilterChange = useAsyncCallback(
async (filterList: Filter[]) => {
await setting.updateCollection({
...setting.currentCollection,
filterList,
});
},
[setting]
);
if (!setting.isDefault || !setting.currentCollection.filterList.length) {
return null;
}
return (
<div className={filterContainerStyle}>
<div style={{ flex: 1 }}>
<FilterList
propertiesMeta={currentWorkspace.blockSuiteWorkspace.meta.properties}
value={setting.currentCollection.filterList}
onChange={onFilterChange}
/>
</div>
<div>
{setting.currentCollection.filterList.length > 0 ? (
<SaveAsCollectionButton onConfirm={saveToCollection} />
) : null}
</div>
</div>
);
};
@@ -67,3 +67,10 @@ export const headerCreateNewButtonHidden = style({
opacity: 0,
pointerEvents: 'none',
});
export const headerRightWindows = style({
display: 'flex',
alignItems: 'center',
gap: 8,
transform: 'translateX(16px)',
});
@@ -1,5 +1,6 @@
import { toast } from '@affine/component';
import {
CollectionList,
currentCollectionAtom,
FloatingToolbar,
NewPageButton as PureNewPageButton,
@@ -8,7 +9,7 @@ import {
useCollectionManager,
VirtualizedPageList,
} from '@affine/component/page-list';
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
@@ -18,7 +19,7 @@ import {
PlusIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import type { PageMeta, Workspace } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
import { getCurrentStore } from '@toeverything/infra/atom';
@@ -34,16 +35,22 @@ import type { LoaderFunction } from 'react-router-dom';
import { redirect } from 'react-router-dom';
import { NIL } from 'uuid';
import { collectionsCRUDAtom } from '../../atoms/collections';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { WorkspaceHeader } from '../../components/workspace-header';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { performanceRenderLogger } from '../../shared';
import { collectionsCRUDAtom } from '../../../atoms/collections';
import { HubIsland } from '../../../components/affine/hub-island';
import { usePageHelper } from '../../../components/blocksuite/block-suite-page-list/utils';
import { Header } from '../../../components/pure/header';
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
import { WorkspaceModeFilterTab } from '../../../components/pure/workspace-mode-filter-tab';
import { useAllPageListConfig } from '../../../hooks/affine/use-all-page-list-config';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useDeleteCollectionInfo } from '../../../hooks/affine/use-delete-collection-info';
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { performanceRenderLogger } from '../../../shared';
import { EmptyPageList } from '../page-list-empty';
import { useFilteredPageMetas } from '../pages';
import * as styles from './all-page.css';
import { EmptyPageList } from './page-list-empty';
import { useFilteredPageMetas } from './pages';
import { FilterContainer } from './all-page-filter';
export const loader: LoaderFunction = async args => {
const rootStore = getCurrentStore();
@@ -217,6 +224,50 @@ const NewPageButton = ({
);
};
const AllPageHeader = ({
workspace,
showCreateNew,
}: {
workspace: Workspace;
showCreateNew: boolean;
}) => {
const setting = useCollectionManager(collectionsCRUDAtom);
const config = useAllPageListConfig();
const userInfo = useDeleteCollectionInfo();
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
return (
<>
<Header
left={
<CollectionList
userInfo={userInfo}
allPageListConfig={config}
setting={setting}
propertiesMeta={workspace.meta.properties}
/>
}
right={
<div className={styles.headerRightWindows}>
<NewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
>
<PlusIcon />
</NewPageButton>
{isWindowsDesktop ? <WindowsAppControls /> : null}
</div>
}
center={<WorkspaceModeFilterTab />}
/>
<FilterContainer workspaceId={workspace.id} />
</>
);
};
// even though it is called all page, it is also being used for collection route as well
export const AllPage = () => {
const [currentWorkspace] = useCurrentWorkspace();
@@ -250,22 +301,9 @@ export const AllPage = () => {
return (
<div className={styles.root}>
{currentWorkspace.flavour !== WorkspaceFlavour.AFFINE_PUBLIC ? (
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.ALL,
}}
rightSlot={
<NewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
hideHeaderCreateNewPage && styles.headerCreateNewButtonHidden
)}
>
<PlusIcon />
</NewPageButton>
}
<AllPageHeader
workspace={currentWorkspace.blockSuiteWorkspace}
showCreateNew={!hideHeaderCreateNewPage}
/>
) : null}
{filteredPageMetas.length > 0 ? (
@@ -299,6 +337,7 @@ export const AllPage = () => {
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
<HubIsland />
</div>
);
};
@@ -30,7 +30,7 @@ import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { WorkspaceSubPath } from '../../shared';
import { getWorkspaceSetting } from '../../utils/workspace-setting';
import { AllPage } from './all-page';
import { AllPage } from './all-page/all-page';
import * as styles from './collection.css';
export const loader: LoaderFunction = async args => {
@@ -1,215 +0,0 @@
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import {
createTagFilter,
useCollectionManager,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { globalBlockSuiteSchema } from '@affine/workspace/manager';
import { SyncEngineStep } from '@affine/workspace/providers';
import { assertExists } from '@blocksuite/global/utils';
import type { EditorContainer } from '@blocksuite/presets';
import type { Page } from '@blocksuite/store';
import {
contentLayoutAtom,
currentPageIdAtom,
currentWorkspaceIdAtom,
} from '@toeverything/infra/atom';
import { useAtomValue, useSetAtom } from 'jotai';
import { type ReactElement, useCallback, useEffect, useState } from 'react';
import { type LoaderFunction, useParams } from 'react-router-dom';
import type { Map as YMap } from 'yjs';
import { getUIAdapter } from '../../adapters/workspace';
import { setPageModeAtom } from '../../atoms';
import { collectionsCRUDAtom } from '../../atoms/collections';
import { currentModeAtom } from '../../atoms/mode';
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
import { GlobalPageHistoryModal } from '../../components/affine/page-history-modal';
import { WorkspaceHeader } from '../../components/workspace-header';
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands';
import {
useCurrentSyncEngine,
useCurrentSyncEngineStatus,
} from '../../hooks/current/use-current-sync-engine';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { performanceRenderLogger } from '../../shared';
const DetailPageImpl = (): ReactElement => {
const { openPage, jumpToSubPath } = useNavigateHelper();
const currentPageId = useAtomValue(currentPageIdAtom);
const [currentWorkspace] = useCurrentWorkspace();
assertExists(currentWorkspace);
assertExists(currentPageId);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const { setTemporaryFilter } = useCollectionManager(collectionsCRUDAtom);
const mode = useAtomValue(currentModeAtom);
const setPageMode = useSetAtom(setPageModeAtom);
useRegisterBlocksuiteEditorCommands(currentPageId, mode);
const onLoad = useCallback(
(page: Page, editor: EditorContainer) => {
try {
const surfaceBlock = page.getBlockByFlavour('affine:surface')[0];
// hotfix for old page
if (
surfaceBlock &&
(surfaceBlock.yBlock.get('prop:elements') as YMap<any>).get(
'type'
) !== '$blocksuite:internal:native$'
) {
globalBlockSuiteSchema.upgradePage(
0,
{
'affine:surface': 3,
},
page.spaceDoc
);
}
} catch {}
setPageMode(currentPageId, mode);
const dispose = editor.slots.pageLinkClicked.on(({ pageId }) => {
return openPage(blockSuiteWorkspace.id, pageId);
});
const disposeTagClick = editor.slots.tagClicked.on(async ({ tagId }) => {
jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
setTemporaryFilter([createTagFilter(tagId)]);
});
return () => {
dispose.dispose();
disposeTagClick.dispose();
};
},
[
blockSuiteWorkspace.id,
currentPageId,
currentWorkspace.id,
jumpToSubPath,
mode,
openPage,
setPageMode,
setTemporaryFilter,
]
);
const { PageDetail } = getUIAdapter(currentWorkspace.flavour);
return (
<>
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
pageId: currentPageId,
}}
/>
<PageDetail
currentWorkspaceId={currentWorkspace.id}
currentPageId={currentPageId}
onLoadEditor={onLoad}
/>
<GlobalPageHistoryModal />
</>
);
};
export const DetailPage = (): ReactElement => {
const [currentWorkspace] = useCurrentWorkspace();
const currentSyncEngineStatus = useCurrentSyncEngineStatus();
const currentSyncEngine = useCurrentSyncEngine();
const currentPageId = useAtomValue(currentPageIdAtom);
const [page, setPage] = useState<Page | null>(null);
// set sync engine priority target
useEffect(() => {
if (!currentPageId) {
return;
}
currentSyncEngine?.setPriorityRule(id => id.endsWith(currentPageId));
}, [currentPageId, currentSyncEngine, currentWorkspace]);
// load page by current page id
useEffect(() => {
if (!currentPageId) {
setPage(null);
return;
}
const exists = currentWorkspace.blockSuiteWorkspace.getPage(currentPageId);
if (exists) {
setPage(exists);
return;
}
const dispose = currentWorkspace.blockSuiteWorkspace.slots.pagesUpdated.on(
() => {
const exists =
currentWorkspace.blockSuiteWorkspace.getPage(currentPageId);
if (exists) {
setPage(exists);
}
}
);
return dispose.dispose;
}, [currentPageId, currentWorkspace]);
const navigate = useNavigateHelper();
// if sync engine has been synced and the page is null, wait 1s and jump to 404 page.
useEffect(() => {
if (currentSyncEngineStatus?.step === SyncEngineStep.Synced && !page) {
const timeout = setTimeout(() => {
navigate.jumpTo404();
}, 1000);
return () => {
clearTimeout(timeout);
};
}
return;
}, [currentSyncEngineStatus, navigate, page]);
if (!currentPageId || !page) {
return <PageDetailSkeleton key="current-page-is-null" />;
}
if (page.meta.jumpOnce) {
currentWorkspace.blockSuiteWorkspace.setPageMeta(page.id, {
jumpOnce: false,
});
}
return <DetailPageImpl />;
};
export const loader: LoaderFunction = async () => {
return null;
};
export const Component = () => {
performanceRenderLogger.info('DetailPage');
const setContentLayout = useSetAtom(contentLayoutAtom);
const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom);
const setCurrentPageId = useSetAtom(currentPageIdAtom);
const params = useParams();
useEffect(() => {
setContentLayout('editor');
if (params.workspaceId) {
localStorage.setItem('last_workspace_id', params.workspaceId);
setCurrentWorkspaceId(params.workspaceId);
}
if (params.pageId) {
localStorage.setItem('last_page_id', params.pageId);
setCurrentPageId(params.pageId);
}
}, [params, setContentLayout, setCurrentPageId, setCurrentWorkspaceId]);
// Add a key to force rerender when page changed, to avoid error boundary persisting.
return (
<AffineErrorBoundary key={params.pageId}>
<DetailPage />
</AffineErrorBoundary>
);
};
@@ -0,0 +1,59 @@
import type { ComplexStyleRule } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
height: '52px',
width: '100%',
alignItems: 'center',
flexShrink: 0,
background: 'var(--affine-background-primary-color)',
borderBottom: '1px solid var(--affine-border-color)',
selectors: {
'&[data-sidebar-floating="false"]': {
WebkitAppRegion: 'drag',
},
},
'@media': {
print: {
display: 'none',
},
},
':has([data-popper-placement])': {
WebkitAppRegion: 'no-drag',
},
} as ComplexStyleRule);
export const mainHeader = style([
header,
{
padding: '0 16px',
},
]);
export const sidebarHeader = style([
header,
{
padding: '0 14px',
gap: '12px',
},
]);
export const mainHeaderRight = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
});
export const spacer = style({
flexGrow: 1,
});
export const standaloneExtensionSwitcherWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
height: '52px',
position: 'relative',
});
@@ -0,0 +1,159 @@
import { IconButton } from '@affine/component';
import {
appSidebarFloatingAtom,
appSidebarOpenAtom,
SidebarSwitch,
} from '@affine/component/app-sidebar';
import type { AllWorkspace } from '@affine/core/shared';
import { RightSidebarIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import { useAtomValue, useSetAtom } from 'jotai';
import { SharePageButton } from '../../../components/affine/share-page-modal';
import { BlockSuiteHeaderTitle } from '../../../components/blocksuite/block-suite-header-title';
import { HeaderDivider } from '../../../components/pure/header';
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
import * as styles from './detail-page-header.css';
import { ExtensionTabs } from './editor-sidebar';
import {
editorSidebarOpenAtom,
editorSidebarToggleAtom,
} from './editor-sidebar/atoms';
interface PageHeaderRightProps {
showSidebarSwitch?: boolean;
}
const ToggleSidebarButton = () => {
const toggle = useSetAtom(editorSidebarToggleAtom);
return (
<IconButton size="large" onClick={toggle}>
<RightSidebarIcon />
</IconButton>
);
};
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const WindowsMainPageHeaderRight = ({
showSidebarSwitch,
}: PageHeaderRightProps) => {
const editorSidebarOpen = useAtomValue(editorSidebarOpenAtom);
if (editorSidebarOpen) {
return null;
}
return (
<>
<HeaderDivider />
<div
className={styles.mainHeaderRight}
style={{
marginRight: editorSidebarOpen ? 0 : -16,
}}
>
{showSidebarSwitch ? <ToggleSidebarButton /> : null}
<WindowsAppControls />
</div>
</>
);
};
const NonWindowsMainPageHeaderRight = ({
showSidebarSwitch,
}: PageHeaderRightProps) => {
const editorSidebarOpen = useAtomValue(editorSidebarOpenAtom);
if (editorSidebarOpen || !showSidebarSwitch) {
return null;
}
return (
<>
<HeaderDivider />
<div className={styles.mainHeaderRight}>
<ToggleSidebarButton />
</div>
</>
);
};
function Header({
children,
style,
className,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
return (
<div
data-testid="header"
style={style}
className={className}
data-sidebar-floating={appSidebarFloating}
>
{children}
</div>
);
}
export function DetailPageHeader({
page,
workspace,
showSidebarSwitch = true,
}: {
page: Page;
workspace: AllWorkspace;
showSidebarSwitch?: boolean;
}) {
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
const RightHeader = isWindowsDesktop
? WindowsMainPageHeaderRight
: NonWindowsMainPageHeaderRight;
return (
<Header className={styles.mainHeader}>
<SidebarSwitch show={!leftSidebarOpen} />
{!leftSidebarOpen ? <HeaderDivider /> : null}
<BlockSuiteHeaderTitle pageId={page.id} workspace={workspace} />
<div className={styles.spacer} />
{page ? <SharePageButton workspace={workspace} page={page} /> : null}
<RightHeader showSidebarSwitch={showSidebarSwitch} />
</Header>
);
}
function WindowsSidebarHeader() {
return (
<>
<Header className={styles.sidebarHeader} style={{ paddingRight: 0 }}>
<div className={styles.spacer} />
<ToggleSidebarButton />
<WindowsAppControls />
</Header>
<div className={styles.standaloneExtensionSwitcherWrapper}>
<ExtensionTabs />
</div>
</>
);
}
function NonWindowsSidebarHeader() {
return (
<Header className={styles.sidebarHeader}>
<ExtensionTabs />
<div className={styles.spacer} />
<ToggleSidebarButton />
</Header>
);
}
export function RightSidebarHeader() {
return isWindowsDesktop ? (
<WindowsSidebarHeader />
) : (
<NonWindowsSidebarHeader />
);
}
@@ -0,0 +1,95 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
overflow: 'hidden',
width: '100%',
});
export const mainContainer = style({
display: 'flex',
flex: 1,
height: '100%',
position: 'relative',
flexDirection: 'column',
width: '100%',
selectors: {
[`${root}[data-client-border] &`]: {
borderRadius: '4px',
},
},
});
export const editorContainer = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'hidden',
zIndex: 0, // it will create stacking context to limit layer of child elements and be lower than after auto zIndex
});
export const resizeHandle = style({
width: '1px',
position: 'relative',
backgroundColor: 'var(--affine-border-color)',
selectors: {
'&[data-collapsed=true]': {
display: 'none',
},
[`${root}[data-client-border] &`]: {
width: '8px',
backgroundColor: 'transparent',
},
},
});
export const resizeHandleInner = style({
height: '100%',
width: '10px', // this is the real hit box
position: 'absolute',
transform: 'translateX(-50%)',
zIndex: 10,
transition: 'all 0.2s ease-in-out',
display: 'flex',
justifyContent: 'center',
'::before': {
content: '""',
width: '0px',
height: '100%',
borderRadius: '2px',
transition: 'all 0.2s ease-in-out',
},
selectors: {
[`${root}[data-client-border] &`]: {
transform: 'translateX(-1px)',
},
[`:is(${resizeHandle}:hover, ${resizeHandle}[data-resize-handle-active]) &::before`]:
{
width: '2px',
backgroundColor: 'var(--affine-primary-color)',
},
[`${resizeHandle}[data-resize-handle-active] &::before`]: {
width: '4px',
borderRadius: '4px',
},
},
});
export const sidebarContainer = style({
transition: 'flex 0.2s ease-in-out',
display: 'flex',
flexDirection: 'column',
selectors: {
[`${resizeHandle}[data-resize-handle-active] + &`]: {
transition: 'none',
},
[`${root}[data-disable-animation] &`]: {
transition: 'none',
},
[`${root}[data-client-border] &`]: {
borderRadius: '4px',
},
},
});
@@ -0,0 +1,363 @@
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import {
createTagFilter,
useCollectionManager,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { globalBlockSuiteSchema } from '@affine/workspace/manager';
import { SyncEngineStep } from '@affine/workspace/providers';
import { assertExists } from '@blocksuite/global/utils';
import type { EditorContainer } from '@blocksuite/presets';
import type { Page, Workspace } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import {
currentPageIdAtom,
currentWorkspaceIdAtom,
} from '@toeverything/infra/atom';
import { useAtomValue, useSetAtom } from 'jotai';
import {
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { PanelOnResize } from 'react-resizable-panels';
import {
type ImperativePanelHandle,
Panel,
PanelGroup,
PanelResizeHandle,
} from 'react-resizable-panels';
import { type LoaderFunction, useParams } from 'react-router-dom';
import type { Map as YMap } from 'yjs';
import { setPageModeAtom } from '../../../atoms';
import { collectionsCRUDAtom } from '../../../atoms/collections';
import { currentModeAtom } from '../../../atoms/mode';
import { appSettingAtom } from '../../../atoms/settings';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
import { HubIsland } from '../../../components/affine/hub-island';
import { GlobalPageHistoryModal } from '../../../components/affine/page-history-modal';
import { PageDetailEditor } from '../../../components/page-detail-editor';
import { TrashPageFooter } from '../../../components/pure/trash-page-footer';
import { TopTip } from '../../../components/top-tip';
import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-register-blocksuite-editor-commands';
import {
useCurrentSyncEngine,
useCurrentSyncEngineStatus,
} from '../../../hooks/current/use-current-sync-engine';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { performanceRenderLogger } from '../../../shared';
import * as styles from './detail-page.css';
import { DetailPageHeader, RightSidebarHeader } from './detail-page-header';
import {
EditorSidebar,
editorSidebarOpenAtom,
editorSidebarStateAtom,
editorSidebarWidthAtom,
} from './editor-sidebar';
interface DetailPageLayoutProps {
main: ReactNode;
header: ReactNode;
footer: ReactNode;
sidebar: ReactNode;
}
// disable animation to avoid UI flash
function useEnableAnimation() {
const [enable, setEnable] = useState(false);
useEffect(() => {
window.setTimeout(() => {
setEnable(true);
}, 500);
}, []);
return enable;
}
// todo: consider move to a shared place if we also want to reuse the layout for other routes
const DetailPageLayout = ({
main,
header,
footer,
sidebar,
}: DetailPageLayoutProps): ReactElement => {
const sidebarState = useAtomValue(editorSidebarStateAtom);
const setSidebarWidth = useSetAtom(editorSidebarWidthAtom);
const setSidebarOpen = useSetAtom(editorSidebarOpenAtom);
const { clientBorder } = useAtomValue(appSettingAtom);
const sidebarRef = useRef<ImperativePanelHandle>(null);
const onExpandSidebar = useCallback(() => {
setSidebarOpen(true);
}, [setSidebarOpen]);
const onCollapseSidebar = useCallback(() => {
setSidebarOpen(false);
}, [setSidebarOpen]);
const onResize: PanelOnResize = useCallback(
e => {
if (e.sizePixels > 0) {
setSidebarWidth(e.sizePixels);
}
},
[setSidebarWidth]
);
useEffect(() => {
const panelHandle = sidebarRef.current;
if (!panelHandle) {
return;
}
if (sidebarState.isOpen) {
panelHandle.expand();
} else {
panelHandle.collapse();
}
}, [sidebarState.isOpen]);
const enableAnimation = useEnableAnimation();
return (
<PanelGroup
direction="horizontal"
className={styles.root}
dataAttributes={{
'data-disable-animation': !enableAnimation ? 'true' : undefined,
'data-client-border': clientBorder ? 'true' : undefined,
}}
>
<Panel id="editor" className={styles.mainContainer}>
{header}
{main}
{footer}
</Panel>
{sidebar ? (
<>
<PanelResizeHandle
dataAttributes={{
'data-collapsed': !sidebarState.isOpen,
}}
className={styles.resizeHandle}
>
<div className={styles.resizeHandleInner} />
</PanelResizeHandle>
<Panel
id="editor-sidebar"
className={styles.sidebarContainer}
onResize={onResize}
collapsedSizePixels={0}
collapsible
onCollapse={onCollapseSidebar}
onExpand={onExpandSidebar}
ref={sidebarRef}
defaultSizePixels={Math.max(sidebarState.width, 240)}
minSizePixels={240}
maxSizePercentage={50}
>
{sidebar}
</Panel>
</>
) : null}
</PanelGroup>
);
};
const DetailPageImpl = ({ page }: { page: Page }) => {
const currentPageId = page.id;
const { openPage, jumpToSubPath } = useNavigateHelper();
const [currentWorkspace] = useCurrentWorkspace();
assertExists(
currentWorkspace,
'current workspace is null when rendering detail'
);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
meta => meta.id === page.id
);
const isInTrash = pageMeta?.trash;
const { setTemporaryFilter } = useCollectionManager(collectionsCRUDAtom);
const mode = useAtomValue(currentModeAtom);
const setPageMode = useSetAtom(setPageModeAtom);
useRegisterBlocksuiteEditorCommands(currentPageId, mode);
const onLoad = useCallback(
(page: Page, editor: EditorContainer) => {
try {
// todo(joooye34): improve the following migration code
const surfaceBlock = page.getBlockByFlavour('affine:surface')[0];
// hotfix for old page
if (
surfaceBlock &&
(surfaceBlock.yBlock.get('prop:elements') as YMap<any>).get(
'type'
) !== '$blocksuite:internal:native$'
) {
globalBlockSuiteSchema.upgradePage(
0,
{
'affine:surface': 3,
},
page.spaceDoc
);
}
} catch {}
setPageMode(currentPageId, mode);
const dispose = editor.slots.pageLinkClicked.on(({ pageId }) => {
return openPage(blockSuiteWorkspace.id, pageId);
});
const disposeTagClick = editor.slots.tagClicked.on(async ({ tagId }) => {
jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
setTemporaryFilter([createTagFilter(tagId)]);
});
return () => {
dispose.dispose();
disposeTagClick.dispose();
};
},
[
blockSuiteWorkspace.id,
currentPageId,
currentWorkspace.id,
jumpToSubPath,
mode,
openPage,
setPageMode,
setTemporaryFilter,
]
);
return (
<>
<DetailPageLayout
header={
<>
<DetailPageHeader
page={page}
workspace={currentWorkspace}
showSidebarSwitch={!isInTrash}
/>
<TopTip workspace={currentWorkspace} />
</>
}
main={
<div className={styles.editorContainer}>
<PageDetailEditor
pageId={currentPageId}
onLoad={onLoad}
workspace={blockSuiteWorkspace}
/>
<HubIsland />
</div>
}
footer={isInTrash ? <TrashPageFooter pageId={page.id} /> : null}
sidebar={
!isInTrash ? (
<>
<RightSidebarHeader />
<EditorSidebar />
</>
) : null
}
/>
<GlobalPageHistoryModal />
</>
);
};
const useForceUpdate = () => {
const [, setCount] = useState(0);
return useCallback(() => setCount(count => count + 1), []);
};
const useSafePage = (workspace: Workspace, pageId: string) => {
const forceUpdate = useForceUpdate();
useEffect(() => {
const disposable = workspace.slots.pagesUpdated.on(() => {
forceUpdate();
});
return disposable.dispose;
}, [pageId, workspace.slots.pagesUpdated, forceUpdate]);
return workspace.getPage(pageId);
};
export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => {
const [currentWorkspace] = useCurrentWorkspace();
const currentSyncEngineStatus = useCurrentSyncEngineStatus();
const currentSyncEngine = useCurrentSyncEngine();
// set sync engine priority target
useEffect(() => {
currentSyncEngine?.setPriorityRule(id => id.endsWith(pageId));
}, [pageId, currentSyncEngine, currentWorkspace]);
const page = useSafePage(currentWorkspace?.blockSuiteWorkspace, pageId);
const navigate = useNavigateHelper();
// if sync engine has been synced and the page is null, wait 1s and jump to 404 page.
useEffect(() => {
if (currentSyncEngineStatus?.step === SyncEngineStep.Synced && !page) {
const timeout = setTimeout(() => {
navigate.jumpTo404();
}, 1000);
return () => {
clearTimeout(timeout);
};
}
return;
}, [currentSyncEngineStatus, navigate, page]);
if (!page) {
return <PageDetailSkeleton key="current-page-is-null" />;
}
if (page.meta.jumpOnce) {
currentWorkspace.blockSuiteWorkspace.setPageMeta(page.id, {
jumpOnce: false,
});
}
return <DetailPageImpl page={page} />;
};
export const loader: LoaderFunction = async () => {
return null;
};
export const Component = () => {
performanceRenderLogger.info('DetailPage');
const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom);
const setCurrentPageId = useSetAtom(currentPageIdAtom);
const params = useParams();
useEffect(() => {
if (params.workspaceId) {
localStorage.setItem('last_workspace_id', params.workspaceId);
setCurrentWorkspaceId(params.workspaceId);
}
if (params.pageId) {
localStorage.setItem('last_page_id', params.pageId);
setCurrentPageId(params.pageId);
}
}, [params, setCurrentPageId, setCurrentWorkspaceId]);
const pageId = params.pageId;
// Add a key to force rerender when page changed, to avoid error boundary persisting.
return (
<AffineErrorBoundary key={params.pageId}>
{pageId ? <DetailPage pageId={pageId} /> : null}
</AffineErrorBoundary>
);
};
@@ -0,0 +1,79 @@
// main editor sidebar states
import { assertExists } from '@blocksuite/global/utils';
import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { outlineExtension } from './extensions/outline';
import type { EditorExtension, EditorExtensionName } from './types';
// the list of all possible extensions in affine.
// order matters (determines the order of the tabs)
export const extensions: EditorExtension[] = [outlineExtension];
export interface EditorSidebarState {
isOpen: boolean;
width: number;
activeExtension?: EditorExtension;
extensions: EditorExtension[];
}
const baseStateAtom = atom<EditorSidebarState>({
isOpen: false,
width: 300, // todo: should be resizable
activeExtension: extensions[0],
extensions: extensions, // todo: maybe should be dynamic (by feature flag?)
});
export const editorSidebarStateAtom = atom(get => get(baseStateAtom));
const isOpenAtom = selectAtom(baseStateAtom, state => state.isOpen);
const activeExtensionAtom = selectAtom(
baseStateAtom,
state => state.activeExtension
);
const widthAtom = selectAtom(baseStateAtom, state => state.width);
export const editorExtensionsAtom = selectAtom(
baseStateAtom,
state => state.extensions
);
// get/set sidebar open state
export const editorSidebarOpenAtom = atom(
get => get(isOpenAtom),
(_, set, isOpen: boolean) => {
set(baseStateAtom, prev => {
return { ...prev, isOpen };
});
}
);
// get/set active extension
export const editorSidebarActiveExtensionAtom = atom(
get => get(activeExtensionAtom),
(_, set, extension: EditorExtensionName) => {
set(baseStateAtom, prev => {
const extensions = prev.extensions;
const newExtension = extensions.find(e => e.name === extension);
assertExists(newExtension, `extension ${extension} not found`);
return { ...prev, activeExtension: newExtension };
});
}
);
// toggle sidebar (write only)
export const editorSidebarToggleAtom = atom(null, (_, set) => {
set(baseStateAtom, prev => {
return { ...prev, isOpen: !prev.isOpen };
});
});
// get/set sidebar width
export const editorSidebarWidthAtom = atom(
get => get(widthAtom),
(_, set, width: number) => {
set(baseStateAtom, prev => {
return { ...prev, width };
});
}
);
@@ -0,0 +1,10 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'auto',
width: '100%',
minWidth: '300px',
});
@@ -0,0 +1,16 @@
import { useAtomValue } from 'jotai';
import { editorSidebarStateAtom } from './atoms';
import * as styles from './editor-sidebar.css';
export const EditorSidebar = () => {
const sidebarState = useAtomValue(editorSidebarStateAtom);
const Component = sidebarState.activeExtension?.Component;
// do we need this?
if (!sidebarState.isOpen) {
return null;
}
return <div className={styles.root}>{Component ? <Component /> : null}</div>;
};
@@ -0,0 +1,45 @@
import { createVar, style } from '@vanilla-extract/css';
export const activeIdx = createVar();
export const switchRoot = style({
vars: {
[activeIdx]: '0',
},
display: 'flex',
alignItems: 'center',
gap: '8px',
height: '32px',
borderRadius: '12px',
padding: '4px',
position: 'relative',
background: 'var(--affine-background-secondary-color)',
'::after': {
content: '""',
display: 'block',
width: '24px',
height: '24px',
background: 'var(--affine-background-primary-color)',
boxShadow: 'var(--affine-shadow-1)',
borderRadius: '8px',
position: 'absolute',
transform: `translateX(calc(${activeIdx} * 32px))`,
transition: 'all .15s',
},
});
export const button = style({
width: '24px',
height: '24px',
borderRadius: '8px',
color: 'var(--affine-icon-color)',
position: 'relative',
zIndex: 1,
selectors: {
'&[data-active=true]': {
color: 'var(--affine-primary-color)',
},
},
});
@@ -0,0 +1,37 @@
import { IconButton } from '@affine/component';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useAtom, useAtomValue } from 'jotai';
import {
editorExtensionsAtom,
editorSidebarActiveExtensionAtom,
} from '../atoms';
import * as styles from './extensions.css';
// provide a switcher for active extensions
// will be used in global top header (MacOS) or sidebar (Windows)
export const ExtensionTabs = () => {
const exts = useAtomValue(editorExtensionsAtom);
const [selected, setSelected] = useAtom(editorSidebarActiveExtensionAtom);
const vars = assignInlineVars({
[styles.activeIdx]: String(
exts.findIndex(ext => ext.name === selected?.name) ?? 0
),
});
return (
<div className={styles.switchRoot} style={vars}>
{exts.map(extension => {
return (
<IconButton
onClick={() => setSelected(extension.name)}
key={extension.name}
data-active={selected?.name === extension.name}
className={styles.button}
>
{extension.icon}
</IconButton>
);
})}
</div>
);
};
@@ -0,0 +1,7 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});
@@ -0,0 +1,41 @@
import { TOCNotesPanel } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import { FrameIcon } from '@blocksuite/icons';
import { useCallback, useRef } from 'react';
import { useCurrentPage } from '../../../../../hooks/current/use-current-page';
import type { EditorExtension } from '../types';
import * as styles from './frame.css';
// A wrapper for TOCNotesPanel
const EditorOutline = () => {
const tocPanelRef = useRef<TOCNotesPanel | null>(null);
const currentPage = useCurrentPage();
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(tocPanelRef.current, 'toc panel should be initialized');
container.append(tocPanelRef.current);
}
}, []);
if (!currentPage) {
return;
}
if (!tocPanelRef.current) {
tocPanelRef.current = new TOCNotesPanel();
}
if (currentPage !== tocPanelRef.current?.page) {
(tocPanelRef.current as TOCNotesPanel).page = currentPage;
}
return <div className={styles.root} ref={onRefChange} />;
};
export const frameExtension: EditorExtension = {
name: 'frame',
icon: <FrameIcon />,
Component: EditorOutline,
};
@@ -0,0 +1 @@
export * from './extensions';
@@ -0,0 +1,7 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});
@@ -0,0 +1,41 @@
import { TOCNotesPanel } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import { TocIcon } from '@blocksuite/icons';
import { useCallback, useRef } from 'react';
import { useCurrentPage } from '../../../../../hooks/current/use-current-page';
import type { EditorExtension } from '../types';
import * as styles from './outline.css';
// A wrapper for TOCNotesPanel
const EditorOutline = () => {
const tocPanelRef = useRef<TOCNotesPanel | null>(null);
const currentPage = useCurrentPage();
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(tocPanelRef.current, 'toc panel should be initialized');
container.append(tocPanelRef.current);
}
}, []);
if (!currentPage) {
return;
}
if (!tocPanelRef.current) {
tocPanelRef.current = new TOCNotesPanel();
}
if (currentPage !== tocPanelRef.current?.page) {
(tocPanelRef.current as TOCNotesPanel).page = currentPage;
}
return <div className={styles.root} ref={onRefChange} />;
};
export const outlineExtension: EditorExtension = {
name: 'outline',
icon: <TocIcon />,
Component: EditorOutline,
};
@@ -0,0 +1,4 @@
export * from './atoms';
export * from './editor-sidebar';
export * from './extensions';
export * from './types';
@@ -0,0 +1,7 @@
export type EditorExtensionName = 'outline' | 'frame';
export interface EditorExtension {
name: EditorExtensionName;
icon: React.ReactNode;
Component: React.ComponentType;
}
@@ -0,0 +1 @@
export * from './detail-page';
@@ -0,0 +1,16 @@
import { style } from '@vanilla-extract/css';
export { root } from './all-page/all-page.css';
export const trashTitle = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 8px',
fontWeight: 600,
});
export const trashIcon = style({
color: 'var(--affine-icon-color)',
fontSize: 'var(--affine-font-h-5)',
});
@@ -3,20 +3,43 @@ import {
TrashOperationCell,
VirtualizedPageList,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { DeleteIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useCallback } from 'react';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { WorkspaceHeader } from '../../components/workspace-header';
import { Header } from '../../components/pure/header';
import { WindowsAppControls } from '../../components/pure/header/windows-app-controls';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import * as styles from './all-page.css';
import { EmptyPageList } from './page-list-empty';
import { useFilteredPageMetas } from './pages';
import * as styles from './trash-page.css';
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const TrashHeader = () => {
const t = useAFFiNEI18N();
return (
<Header
left={
<div className={styles.trashTitle}>
<DeleteIcon className={styles.trashIcon} />
{t['com.affine.workspaceSubPath.trash']()}
</div>
}
right={
isWindowsDesktop ? (
<div style={{ marginRight: -16 }}>
<WindowsAppControls />
</div>
) : null
}
/>
);
};
export const TrashPage = () => {
const [currentWorkspace] = useCurrentWorkspace();
@@ -60,12 +83,7 @@ export const TrashPage = () => {
);
return (
<div className={styles.root}>
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.TRASH,
}}
/>
<TrashHeader />
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
pages={filteredPageMetas}