feat(core): new all docs header (#12182)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a new grouped document explorer with a masonry layout, advanced filtering, and multi-select capabilities.
  - Added a floating toolbar for batch actions, such as deleting multiple documents.
  - Implemented a feature flag to enable the new "All Docs" page, allowing gradual rollout.
  - Added a new navigation component with links for Docs, Collections, and Tags.
  - Introduced a display menu button supporting additional menu properties.

- **Enhancements**
  - Redesigned navigation headers across document, collection, and tag pages for improved consistency and usability.
  - Added new display and view toggling options for document lists.
  - Improved styling and responsiveness for explorer and header components.
  - Updated context to support dynamic view switching in the document explorer.

- **Bug Fixes**
  - Updated context and prop types to enhance state management and user interactions.

- **Refactor**
  - Replaced legacy page list views with a modern, grouped explorer interface.
  - Simplified and modularized header components and styles for better maintainability.
  - Removed workspace-specific controls from document headers to focus on explorer functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-09 09:38:48 +00:00
parent 5f5de8e89d
commit 7013f9b804
21 changed files with 755 additions and 544 deletions
@@ -1,9 +1,11 @@
import { createContext, type Dispatch, type SetStateAction } from 'react';
import type { DocListItemView } from './docs-view/doc-list-item';
import type { ExplorerPreference } from './types';
export type DocExplorerContextType = ExplorerPreference & {
view: 'list' | 'grid' | 'masonry';
view: DocListItemView;
setView: Dispatch<SetStateAction<DocListItemView>>;
groups: Array<{ key: string; items: string[] }>;
collapsed: string[];
selectMode?: boolean;
@@ -17,6 +19,7 @@ export type DocExplorerContextType = ExplorerPreference & {
export const DocExplorerContext = createContext<DocExplorerContextType>({
view: 'list',
setView: () => {},
groups: [],
collapsed: [],
selectedDocIds: [],
@@ -1,4 +1,10 @@
import { Button, Divider, Menu, MenuSub } from '@affine/component';
import {
Button,
Divider,
Menu,
type MenuProps,
MenuSub,
} from '@affine/component';
import type {
GroupByParams,
OrderByParams,
@@ -92,12 +98,14 @@ export const ExplorerDisplayMenuButton = ({
style,
className,
preference,
menuProps,
onChange,
}: {
style?: React.CSSProperties;
className?: string;
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
menuProps?: Omit<MenuProps, 'items' | 'children'>;
}) => {
const t = useI18n();
return (
@@ -105,6 +113,7 @@ export const ExplorerDisplayMenuButton = ({
items={
<ExplorerDisplayMenu preference={preference} onChange={onChange} />
}
{...menuProps}
>
<Button
className={className}
@@ -0,0 +1,20 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
gap: 12,
alignItems: 'center',
fontSize: 18,
lineHeight: '26px',
fontWeight: 600,
});
export const item = style({
color: cssVarV2.text.secondary,
selectors: {
'&[data-active="true"]': {
color: cssVarV2.text.primary,
},
},
});
@@ -0,0 +1,47 @@
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import * as styles from './navigation.css';
const items = [
{
value: 'docs',
label: 'com.affine.docs.header',
testId: 'workspace-docs-button',
to: '/all',
},
{
value: 'collections',
label: 'com.affine.collections.header',
testId: 'workspace-collections-button',
to: '/collection',
},
{
value: 'tags',
label: 'Tags',
testId: 'workspace-tags-button',
to: '/tag',
},
] as const;
type NavigationKey = (typeof items)[number]['value'];
export const ExplorerNavigation = ({ active }: { active: NavigationKey }) => {
const t = useI18n();
return (
<div className={styles.container}>
{items.map(item => (
<WorkbenchLink
key={item.value}
data-testid={item.testId}
data-active={active === item.value}
to={item.to}
className={styles.item}
>
{t[item.label]()}
</WorkbenchLink>
))}
</div>
);
};
@@ -1,6 +1,6 @@
import { IconButton } from '@affine/component';
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { PlusIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
@@ -26,7 +26,7 @@ export const AllCollectionHeader = ({
)}
/>
}
center={<WorkspaceModeFilterTab activeFilter={'collections'} />}
left={<ExplorerNavigation active={'collections'} />}
/>
);
};
@@ -1,360 +0,0 @@
import {
Masonry,
type MasonryGroup,
RadioGroup,
useConfirmModal,
} from '@affine/component';
import {
DocExplorerContext,
type DocExplorerContextType,
} from '@affine/core/components/explorer/context';
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
import {
DocListItem,
type DocListItemView,
} from '@affine/core/components/explorer/docs-view/doc-list-item';
import type { ExplorerPreference } from '@affine/core/components/explorer/types';
import { Filters } from '@affine/core/components/filter';
import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar';
import { WorkspacePropertyTypes } from '@affine/core/components/workspace-property-types';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
import { DocsService } from '@affine/core/modules/doc';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../../modules/workbench';
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import * as styles from './all-page.css';
import { MigrationAllDocsDataNotification } from './migration-data';
const GroupHeader = memo(function GroupHeader({
groupId,
collapsed,
itemCount,
}: {
groupId: string;
collapsed?: boolean;
itemCount: number;
}) {
const { groupBy } = useContext(DocExplorerContext);
const propertyService = useService(WorkspacePropertyService);
const allProperties = useLiveData(propertyService.sortedProperties$);
const groupType = groupBy?.type;
const groupKey = groupBy?.key;
const header = useMemo(() => {
if (groupType === 'property') {
const property = allProperties.find(p => p.id === groupKey);
if (!property) return null;
const config = WorkspacePropertyTypes[property.type];
if (!config?.groupHeader) return null;
return (
<config.groupHeader
groupId={groupId}
docCount={itemCount}
collapsed={!!collapsed}
/>
);
} else {
return '// TODO: ' + groupType;
}
}, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]);
if (!groupType) {
return null;
}
return header;
});
const calcCardHeightById = (id: string) => {
const max = 5;
const min = 1;
const code = id.charCodeAt(0);
const value = Math.floor((code % (max - min)) + min);
return 250 + value * 10;
};
const DocListItemComponent = memo(function DocListItemComponent({
itemId,
groupId,
}: {
groupId: string;
itemId: string;
}) {
return <DocListItem docId={itemId} groupId={groupId} />;
});
export const AllPage = () => {
const t = useI18n();
const docsService = useService(DocsService);
const [view, setView] = useState<DocListItemView>('masonry');
const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
const [selectMode, setSelectMode] = useState(false);
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
const [prevCheckAnchorId, setPrevCheckAnchorId] = useState<string | null>(
null
);
const [explorerPreference, setExplorerPreference] =
useState<ExplorerPreference>({
filters: [
{
type: 'system',
key: 'trash',
value: 'false',
method: 'is',
},
],
displayProperties: [],
showDocIcon: true,
showDocPreview: true,
});
const [groups, setGroups] = useState<any>([]);
const { openConfirmModal } = useConfirmModal();
const masonryItems = useMemo(() => {
const items = groups.map((group: any) => {
return {
id: group.key,
Component: groups.length > 1 ? GroupHeader : undefined,
height: groups.length > 1 ? 24 : 0,
className: styles.groupHeader,
items: group.items.map((docId: string) => {
return {
id: docId,
Component: DocListItemComponent,
height:
view === 'list'
? 42
: view === 'grid'
? 280
: calcCardHeightById(docId),
'data-view': view,
className: styles.docItem,
};
}),
} satisfies MasonryGroup;
});
return items;
}, [groups, view]);
const collectionRulesService = useService(CollectionRulesService);
useEffect(() => {
const subscription = collectionRulesService
.watch(
explorerPreference.filters ?? [],
explorerPreference.groupBy,
explorerPreference.orderBy
)
.subscribe({
next: result => {
setGroups(result.groups);
},
error: error => {
console.error(error);
},
});
return () => {
subscription.unsubscribe();
};
}, [
collectionRulesService,
explorerPreference.filters,
explorerPreference.groupBy,
explorerPreference.orderBy,
]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectMode(false);
setSelectedDocIds([]);
setPrevCheckAnchorId(null);
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, []);
const handleFilterChange = useCallback((filters: FilterParams[]) => {
setExplorerPreference(prev => ({
...prev,
filters,
}));
}, []);
const toggleGroupCollapse = useCallback((groupId: string) => {
setCollapsedGroups(prev => {
return prev.includes(groupId)
? prev.filter(id => id !== groupId)
: [...prev, groupId];
});
}, []);
const toggleDocSelect = useCallback((docId: string) => {
setSelectMode(true);
setSelectedDocIds(prev => {
return prev.includes(docId)
? prev.filter(id => id !== docId)
: [...prev, docId];
});
}, []);
const onSelect = useCallback(
(...args: Parameters<typeof setSelectedDocIds>) => {
setSelectMode(true);
setSelectedDocIds(...args);
},
[]
);
const handleCloseFloatingToolbar = useCallback(() => {
setSelectMode(false);
setSelectedDocIds([]);
}, []);
const handleMultiDelete = useCallback(() => {
if (selectedDocIds.length === 0) {
return;
}
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title.multiple']({
number: selectedDocIds.length.toString(),
}),
description: t[
'com.affine.moveToTrash.confirmModal.description.multiple'
]({
number: selectedDocIds.length.toString(),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
for (const docId of selectedDocIds) {
const doc = docsService.list.doc$(docId).value;
doc?.moveToTrash();
}
},
});
}, [docsService.list, openConfirmModal, selectedDocIds, t]);
const explorerContextValue = useMemo(
() =>
({
...explorerPreference,
view,
groups,
collapsed: collapsedGroups,
selectedDocIds,
selectMode,
prevCheckAnchorId,
setPrevCheckAnchorId,
onToggleCollapse: toggleGroupCollapse,
onToggleSelect: toggleDocSelect,
onSelect,
}) satisfies DocExplorerContextType,
[
collapsedGroups,
explorerPreference,
groups,
onSelect,
prevCheckAnchorId,
selectMode,
selectedDocIds,
toggleDocSelect,
toggleGroupCollapse,
view,
]
);
return (
<DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={t['All pages']()} />
<ViewIcon icon="allDocs" />
<ViewHeader></ViewHeader>
<ViewBody>
<div className={styles.body}>
<MigrationAllDocsDataNotification />
<div>
<RadioGroup
items={['masonry', 'grid', 'list']}
value={view}
onChange={setView}
width={240}
/>
<Filters
filters={explorerPreference.filters ?? []}
onChange={handleFilterChange}
/>
<ExplorerDisplayMenuButton
preference={explorerPreference}
onChange={setExplorerPreference}
/>
</div>
<div className={styles.scrollArea}>
<Masonry
items={masonryItems}
gapY={12}
gapX={12}
groupsGap={12}
groupHeaderGapWithItems={12}
columns={view === 'list' ? 1 : undefined}
itemWidthMin={220}
preloadHeight={100}
itemWidth={'stretch'}
virtualScroll
collapsedGroups={collapsedGroups}
paddingX={useCallback(
(w: number) => (w > 500 ? 24 : w > 393 ? 20 : 16),
[]
)}
/>
</div>
</div>
<ListFloatingToolbar
open={selectMode}
onDelete={handleMultiDelete}
onClose={handleCloseFloatingToolbar}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={selectedDocIds.length}
>
<div style={{ color: cssVarV2.text.secondary }}>
{{ count: selectedDocIds.length } as any}
</div>
selected
</Trans>
}
/>
</ViewBody>
<AllDocSidebarTabs />
</DocExplorerContext.Provider>
);
};
export const Component = () => {
return <AllPage />;
};
@@ -0,0 +1,103 @@
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import {
AllPageListOperationsMenu,
PageDisplayMenu,
PageListNewPageButton,
} from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { inferOpenMode } from '@affine/core/utils';
import type { Filter } from '@affine/env/filter';
import { track } from '@affine/track';
import { PlusIcon } from '@blocksuite/icons/rc';
import { useServices } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback } from 'react';
import * as styles from './all-page.css';
export const AllPageHeader = ({
showCreateNew,
filters,
onChangeFilters,
}: {
showCreateNew: boolean;
filters: Filter[];
onChangeFilters: (filters: Filter[]) => void;
}) => {
const { workspaceService, workspaceDialogService, workbenchService } =
useServices({
WorkspaceService,
WorkspaceDialogService,
WorkbenchService,
});
const workbench = workbenchService.workbench;
const workspace = workspaceService.workspace;
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
const handleOpenDocs = useCallback(
(result: {
docIds: string[];
entryId?: string;
isWorkspaceFile?: boolean;
}) => {
const { docIds, entryId, isWorkspaceFile } = result;
// If the imported file is a workspace file, open the entry page.
if (isWorkspaceFile && entryId) {
workbench.openDoc(entryId);
} else if (!docIds.length) {
return;
}
// Open all the docs when there are multiple docs imported.
if (docIds.length > 1) {
workbench.openAll();
} else {
// Otherwise, open the only doc.
workbench.openDoc(docIds[0]);
}
},
[workbench]
);
const onImportFile = useCallback(() => {
track.$.header.importModal.open();
workspaceDialogService.open('import', undefined, payload => {
if (!payload) {
return;
}
handleOpenDocs(payload);
});
}, [workspaceDialogService, handleOpenDocs]);
return (
<Header
left={<ExplorerNavigation active={'docs'} />}
right={
<>
<PageListNewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
onCreateEdgeless={e => createEdgeless({ at: inferOpenMode(e) })}
onCreatePage={e => createPage('page', { at: inferOpenMode(e) })}
onCreateDoc={e => createPage(undefined, { at: inferOpenMode(e) })}
onImportFile={onImportFile}
>
<PlusIcon />
</PageListNewPageButton>
<AllPageListOperationsMenu
filterList={filters}
onChangeFilterList={onChangeFilters}
propertiesMeta={workspace.docCollection.meta.properties}
/>
<PageDisplayMenu />
</>
}
/>
);
};
@@ -1,4 +1,3 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const scrollContainer = style({
flex: 1,
@@ -7,6 +6,7 @@ export const scrollContainer = style({
});
export const headerCreateNewButton = style({
transition: 'opacity 0.1s ease-in-out',
marginRight: 16,
});
export const headerCreateNewCollectionIconButton = style({
@@ -28,17 +28,3 @@ export const body = style({
height: '100%',
width: '100%',
});
export const scrollArea = style({
height: 0,
flex: 1,
});
// group
export const groupHeader = style({
background: cssVarV2.layer.background.primary,
});
export const docItem = style({
transition: 'width 0.2s ease-in-out',
});
@@ -0,0 +1,96 @@
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
PageListHeader,
useFilteredPageMetas,
VirtualizedPageList,
} from '@affine/core/components/page-list';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { IntegrationService } from '@affine/core/modules/integration';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Filter } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import {
useIsActiveView,
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../../modules/workbench';
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import { EmptyPageList } from '../page-list-empty';
import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter';
import { AllPageHeader } from './all-page-header';
export const AllPage = () => {
const currentWorkspace = useService(WorkspaceService).workspace;
const globalContext = useService(GlobalContextService).globalContext;
const permissionService = useService(WorkspacePermissionService);
const integrationService = useService(IntegrationService);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const importing = useLiveData(integrationService.importing$);
const [filters, setFilters] = useState<Filter[]>([]);
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
filters: filters,
});
const isActiveView = useIsActiveView();
useEffect(() => {
if (isActiveView) {
globalContext.isAllDocs.set(true);
return () => {
globalContext.isAllDocs.set(false);
};
}
return;
}, [globalContext, isActiveView]);
const t = useI18n();
if (importing) {
return null;
}
return (
<>
<ViewTitle title={t['All pages']()} />
<ViewIcon icon="allDocs" />
<ViewHeader>
<AllPageHeader
showCreateNew={!hideHeaderCreateNew}
filters={filters}
onChangeFilters={setFilters}
/>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
<FilterContainer filters={filters} onChangeFilters={setFilters} />
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
disableMultiDelete={!isAdmin && !isOwner}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
filters={filters}
/>
) : (
<EmptyPageList type="all" heading={<PageListHeader />} />
)}
</div>
</ViewBody>
<AllDocSidebarTabs />
</>
);
};
export const Component = () => {
return <AllPage />;
};
@@ -0,0 +1,35 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const header = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const actions = style({
display: 'flex',
alignItems: 'center',
gap: 16,
});
export const viewToggle = style({
backgroundColor: 'transparent',
});
export const viewToggleItem = style({
padding: 0,
fontSize: 16,
width: 24,
color: cssVarV2.icon.primary,
selectors: {
'&[data-state=checked]': {
color: cssVarV2.icon.primary,
},
},
});
export const viewToggleIndicator = style({
backgroundColor: cssVarV2.layer.background.hoverOverlay,
boxShadow: 'none',
});
@@ -1,105 +1,75 @@
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
import {
AllPageListOperationsMenu,
PageDisplayMenu,
PageListNewPageButton,
} from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { inferOpenMode } from '@affine/core/utils';
import type { Filter } from '@affine/env/filter';
import { track } from '@affine/track';
import { PlusIcon } from '@blocksuite/icons/rc';
import { useServices } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback } from 'react';
import { type MenuProps, RadioGroup, type RadioItem } from '@affine/component';
import { DocExplorerContext } from '@affine/core/components/explorer/context';
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
import { DocListViewIcon } from '@affine/core/components/explorer/docs-view/doc-list-item';
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import type { ExplorerPreference } from '@affine/core/components/explorer/types';
import { type Dispatch, type SetStateAction, useContext } from 'react';
import * as styles from './all-page.css';
import * as styles from './all-page-header.css';
export const AllPageHeader = ({
showCreateNew,
filters,
onChangeFilters,
}: {
showCreateNew: boolean;
filters: Filter[];
onChangeFilters: (filters: Filter[]) => void;
}) => {
const { workspaceService, workspaceDialogService, workbenchService } =
useServices({
WorkspaceService,
WorkspaceDialogService,
WorkbenchService,
});
const workbench = workbenchService.workbench;
const workspace = workspaceService.workspace;
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
const handleOpenDocs = useCallback(
(result: {
docIds: string[];
entryId?: string;
isWorkspaceFile?: boolean;
}) => {
const { docIds, entryId, isWorkspaceFile } = result;
// If the imported file is a workspace file, open the entry page.
if (isWorkspaceFile && entryId) {
workbench.openDoc(entryId);
} else if (!docIds.length) {
return;
}
// Open all the docs when there are multiple docs imported.
if (docIds.length > 1) {
workbench.openAll();
} else {
// Otherwise, open the only doc.
workbench.openDoc(docIds[0]);
}
},
[workbench]
);
const onImportFile = useCallback(() => {
track.$.header.importModal.open();
workspaceDialogService.open('import', undefined, payload => {
if (!payload) {
return;
}
handleOpenDocs(payload);
});
}, [workspaceDialogService, handleOpenDocs]);
const views = [
{
label: <DocListViewIcon view="masonry" />,
value: 'masonry',
className: styles.viewToggleItem,
},
{
label: <DocListViewIcon view="grid" />,
value: 'grid',
className: styles.viewToggleItem,
},
{
label: <DocListViewIcon view="list" />,
value: 'list',
className: styles.viewToggleItem,
},
] satisfies RadioItem[];
const ViewToggle = () => {
const { view, setView } = useContext(DocExplorerContext);
return (
<Header
left={
<AllPageListOperationsMenu
filterList={filters}
onChangeFilterList={onChangeFilters}
propertiesMeta={workspace.docCollection.meta.properties}
/>
}
right={
<>
<PageListNewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
onCreateEdgeless={e => createEdgeless({ at: inferOpenMode(e) })}
onCreatePage={e => createPage('page', { at: inferOpenMode(e) })}
onCreateDoc={e => createPage(undefined, { at: inferOpenMode(e) })}
onImportFile={onImportFile}
>
<PlusIcon />
</PageListNewPageButton>
<PageDisplayMenu />
</>
}
center={<WorkspaceModeFilterTab activeFilter={'docs'} />}
<RadioGroup
itemHeight={24}
gap={8}
padding={0}
items={views}
value={view}
onChange={setView}
className={styles.viewToggle}
borderRadius={4}
indicatorClassName={styles.viewToggleIndicator}
/>
);
};
const menuProps: Partial<MenuProps> = {
contentOptions: {
side: 'bottom',
align: 'end',
alignOffset: 0,
sideOffset: 8,
},
};
export const AllDocsHeader = ({
explorerPreference,
setExplorerPreference,
}: {
explorerPreference: ExplorerPreference;
setExplorerPreference: Dispatch<SetStateAction<ExplorerPreference>>;
}) => {
return (
<div className={styles.header}>
<ExplorerNavigation active="docs" />
<div className={styles.actions}>
<ViewToggle />
<ExplorerDisplayMenuButton
preference={explorerPreference}
onChange={setExplorerPreference}
menuProps={menuProps}
/>
</div>
</div>
);
};
@@ -1,3 +1,4 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const scrollContainer = style({
flex: 1,
@@ -26,4 +27,36 @@ export const body = style({
flex: 1,
height: '100%',
width: '100%',
containerName: 'docs-body',
containerType: 'size',
});
export const scrollArea = style({
height: 0,
flex: 1,
});
// group
export const groupHeader = style({
background: cssVarV2.layer.background.primary,
});
export const docItem = style({
transition: 'width 0.2s ease-in-out',
});
export const filterArea = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: '0 24px',
paddingTop: '24px',
'@container': {
'docs-body (width <= 500px)': {
padding: '0 20px',
},
'docs-body (width <= 393px)': {
padding: '0 16px',
},
},
});
@@ -1,96 +1,358 @@
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { Masonry, type MasonryGroup, useConfirmModal } from '@affine/component';
import {
PageListHeader,
useFilteredPageMetas,
VirtualizedPageList,
} from '@affine/core/components/page-list';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { IntegrationService } from '@affine/core/modules/integration';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Filter } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
DocExplorerContext,
type DocExplorerContextType,
} from '@affine/core/components/explorer/context';
import {
DocListItem,
type DocListItemView,
} from '@affine/core/components/explorer/docs-view/doc-list-item';
import type { ExplorerPreference } from '@affine/core/components/explorer/types';
import { Filters } from '@affine/core/components/filter';
import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar';
import { WorkspacePropertyTypes } from '@affine/core/components/workspace-property-types';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
import { DocsService } from '@affine/core/modules/doc';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
useIsActiveView,
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../../modules/workbench';
import { AllPage as AllPageOld } from '../all-page-old/all-page';
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import { EmptyPageList } from '../page-list-empty';
import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter';
import { AllPageHeader } from './all-page-header';
import { AllDocsHeader } from './all-page-header';
import { MigrationAllDocsDataNotification } from './migration-data';
export const AllPage = () => {
const currentWorkspace = useService(WorkspaceService).workspace;
const globalContext = useService(GlobalContextService).globalContext;
const permissionService = useService(WorkspacePermissionService);
const integrationService = useService(IntegrationService);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const importing = useLiveData(integrationService.importing$);
const GroupHeader = memo(function GroupHeader({
groupId,
collapsed,
itemCount,
}: {
groupId: string;
collapsed?: boolean;
itemCount: number;
}) {
const { groupBy } = useContext(DocExplorerContext);
const propertyService = useService(WorkspacePropertyService);
const allProperties = useLiveData(propertyService.sortedProperties$);
const [filters, setFilters] = useState<Filter[]>([]);
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
filters: filters,
});
const groupType = groupBy?.type;
const groupKey = groupBy?.key;
const isActiveView = useIsActiveView();
const header = useMemo(() => {
if (groupType === 'property') {
const property = allProperties.find(p => p.id === groupKey);
if (!property) return null;
useEffect(() => {
if (isActiveView) {
globalContext.isAllDocs.set(true);
return () => {
globalContext.isAllDocs.set(false);
};
const config = WorkspacePropertyTypes[property.type];
if (!config?.groupHeader) return null;
return (
<config.groupHeader
groupId={groupId}
docCount={itemCount}
collapsed={!!collapsed}
/>
);
} else {
return '// TODO: ' + groupType;
}
return;
}, [globalContext, isActiveView]);
}, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]);
const t = useI18n();
if (importing) {
if (!groupType) {
return null;
}
return header;
});
const calcCardHeightById = (id: string) => {
const max = 5;
const min = 1;
const code = id.charCodeAt(0);
const value = Math.floor((code % (max - min)) + min);
return 250 + value * 10;
};
const DocListItemComponent = memo(function DocListItemComponent({
itemId,
groupId,
}: {
groupId: string;
itemId: string;
}) {
return <DocListItem docId={itemId} groupId={groupId} />;
});
export const AllPage = () => {
const t = useI18n();
const docsService = useService(DocsService);
const [view, setView] = useState<DocListItemView>('masonry');
const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
const [selectMode, setSelectMode] = useState(false);
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
const [prevCheckAnchorId, setPrevCheckAnchorId] = useState<string | null>(
null
);
const [explorerPreference, setExplorerPreference] =
useState<ExplorerPreference>({
filters: [
{
type: 'system',
key: 'trash',
value: 'false',
method: 'is',
},
],
displayProperties: [],
showDocIcon: true,
showDocPreview: true,
});
const [groups, setGroups] = useState<any>([]);
const { openConfirmModal } = useConfirmModal();
const masonryItems = useMemo(() => {
const items = groups.map((group: any) => {
return {
id: group.key,
Component: groups.length > 1 ? GroupHeader : undefined,
height: groups.length > 1 ? 24 : 0,
className: styles.groupHeader,
items: group.items.map((docId: string) => {
return {
id: docId,
Component: DocListItemComponent,
height:
view === 'list'
? 42
: view === 'grid'
? 280
: calcCardHeightById(docId),
'data-view': view,
className: styles.docItem,
};
}),
} satisfies MasonryGroup;
});
return items;
}, [groups, view]);
const collectionRulesService = useService(CollectionRulesService);
useEffect(() => {
const subscription = collectionRulesService
.watch(
explorerPreference.filters ?? [],
explorerPreference.groupBy,
explorerPreference.orderBy
)
.subscribe({
next: result => {
setGroups(result.groups);
},
error: error => {
console.error(error);
},
});
return () => {
subscription.unsubscribe();
};
}, [
collectionRulesService,
explorerPreference.filters,
explorerPreference.groupBy,
explorerPreference.orderBy,
]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectMode(false);
setSelectedDocIds([]);
setPrevCheckAnchorId(null);
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, []);
const handleFilterChange = useCallback((filters: FilterParams[]) => {
setExplorerPreference(prev => ({
...prev,
filters,
}));
}, []);
const toggleGroupCollapse = useCallback((groupId: string) => {
setCollapsedGroups(prev => {
return prev.includes(groupId)
? prev.filter(id => id !== groupId)
: [...prev, groupId];
});
}, []);
const toggleDocSelect = useCallback((docId: string) => {
setSelectMode(true);
setSelectedDocIds(prev => {
return prev.includes(docId)
? prev.filter(id => id !== docId)
: [...prev, docId];
});
}, []);
const onSelect = useCallback(
(...args: Parameters<typeof setSelectedDocIds>) => {
setSelectMode(true);
setSelectedDocIds(...args);
},
[]
);
const handleCloseFloatingToolbar = useCallback(() => {
setSelectMode(false);
setSelectedDocIds([]);
}, []);
const handleMultiDelete = useCallback(() => {
if (selectedDocIds.length === 0) {
return;
}
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title.multiple']({
number: selectedDocIds.length.toString(),
}),
description: t[
'com.affine.moveToTrash.confirmModal.description.multiple'
]({
number: selectedDocIds.length.toString(),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
for (const docId of selectedDocIds) {
const doc = docsService.list.doc$(docId).value;
doc?.moveToTrash();
}
},
});
}, [docsService.list, openConfirmModal, selectedDocIds, t]);
const explorerContextValue = useMemo(
() =>
({
...explorerPreference,
view,
setView,
groups,
collapsed: collapsedGroups,
selectedDocIds,
selectMode,
prevCheckAnchorId,
setPrevCheckAnchorId,
onToggleCollapse: toggleGroupCollapse,
onToggleSelect: toggleDocSelect,
onSelect,
}) satisfies DocExplorerContextType,
[
collapsedGroups,
explorerPreference,
groups,
onSelect,
prevCheckAnchorId,
selectMode,
selectedDocIds,
toggleDocSelect,
toggleGroupCollapse,
view,
]
);
return (
<>
<DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={t['All pages']()} />
<ViewIcon icon="allDocs" />
<ViewHeader>
<AllPageHeader
showCreateNew={!hideHeaderCreateNew}
filters={filters}
onChangeFilters={setFilters}
<AllDocsHeader
explorerPreference={explorerPreference}
setExplorerPreference={setExplorerPreference}
/>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
<FilterContainer filters={filters} onChangeFilters={setFilters} />
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
disableMultiDelete={!isAdmin && !isOwner}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
filters={filters}
<div className={styles.filterArea}>
<MigrationAllDocsDataNotification />
<Filters
filters={explorerPreference.filters ?? []}
onChange={handleFilterChange}
/>
) : (
<EmptyPageList type="all" heading={<PageListHeader />} />
)}
</div>
<div className={styles.scrollArea}>
<Masonry
items={masonryItems}
gapY={12}
gapX={12}
groupsGap={12}
groupHeaderGapWithItems={12}
columns={view === 'list' ? 1 : undefined}
itemWidthMin={220}
preloadHeight={100}
itemWidth={'stretch'}
virtualScroll
collapsedGroups={collapsedGroups}
paddingX={useCallback(
(w: number) => (w > 500 ? 24 : w > 393 ? 20 : 16),
[]
)}
/>
</div>
</div>
<ListFloatingToolbar
open={selectMode}
onDelete={handleMultiDelete}
onClose={handleCloseFloatingToolbar}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={selectedDocIds.length}
>
<div style={{ color: cssVarV2.text.secondary }}>
{{ count: selectedDocIds.length } as any}
</div>
selected
</Trans>
}
/>
</ViewBody>
<AllDocSidebarTabs />
</>
</DocExplorerContext.Provider>
);
};
export const Component = () => {
return <AllPage />;
const featureFlagService = useService(FeatureFlagService);
const enableNewAllDocsPage = useLiveData(
featureFlagService.flags.enable_new_all_docs_page.$
);
return enableNewAllDocsPage ? <AllPage /> : <AllPageOld />;
};
@@ -1,6 +1,6 @@
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
export const AllTagHeader = () => {
return <Header center={<WorkspaceModeFilterTab activeFilter={'tags'} />} />;
return <Header left={<ExplorerNavigation active={'tags'} />} />;
};
@@ -1,7 +1,7 @@
import { IconButton } from '@affine/component';
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { PageDisplayMenu } from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { PlusIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
@@ -31,7 +31,7 @@ export const CollectionDetailHeader = ({
<PageDisplayMenu />
</>
}
center={<WorkspaceModeFilterTab activeFilter={'collections'} />}
left={<ExplorerNavigation active="collections" />}
/>
);
};
@@ -1,11 +1,11 @@
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { PageDisplayMenu } from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
export const TagDetailHeader = () => {
return (
<Header
center={<WorkspaceModeFilterTab activeFilter={'tags'} />}
left={<ExplorerNavigation active={'tags'} />}
right={<PageDisplayMenu />}
/>
);
@@ -6,8 +6,8 @@ export const workbenchRoutes = [
lazy: () => import('./pages/workspace/all-page/all-page'),
},
{
path: '/all-new',
lazy: () => import('./pages/workspace/all-page-new/all-page'),
path: '/all-old',
lazy: () => import('./pages/workspace/all-page-old/all-page'),
},
{
path: '/collection',
@@ -283,6 +283,13 @@ export const AFFINE_FLAGS = {
configurable: false,
defaultState: isCanaryBuild,
},
enable_new_all_docs_page: {
category: 'affine',
displayName: 'Enable New All Docs Page',
description: 'Use new all docs page',
configurable: isCanaryBuild,
defaultState: false,
},
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare