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

View File

@@ -1,9 +1,11 @@
import { createContext, type Dispatch, type SetStateAction } from 'react'; import { createContext, type Dispatch, type SetStateAction } from 'react';
import type { DocListItemView } from './docs-view/doc-list-item';
import type { ExplorerPreference } from './types'; import type { ExplorerPreference } from './types';
export type DocExplorerContextType = ExplorerPreference & { export type DocExplorerContextType = ExplorerPreference & {
view: 'list' | 'grid' | 'masonry'; view: DocListItemView;
setView: Dispatch<SetStateAction<DocListItemView>>;
groups: Array<{ key: string; items: string[] }>; groups: Array<{ key: string; items: string[] }>;
collapsed: string[]; collapsed: string[];
selectMode?: boolean; selectMode?: boolean;
@@ -17,6 +19,7 @@ export type DocExplorerContextType = ExplorerPreference & {
export const DocExplorerContext = createContext<DocExplorerContextType>({ export const DocExplorerContext = createContext<DocExplorerContextType>({
view: 'list', view: 'list',
setView: () => {},
groups: [], groups: [],
collapsed: [], collapsed: [],
selectedDocIds: [], selectedDocIds: [],

View File

@@ -1,4 +1,10 @@
import { Button, Divider, Menu, MenuSub } from '@affine/component'; import {
Button,
Divider,
Menu,
type MenuProps,
MenuSub,
} from '@affine/component';
import type { import type {
GroupByParams, GroupByParams,
OrderByParams, OrderByParams,
@@ -92,12 +98,14 @@ export const ExplorerDisplayMenuButton = ({
style, style,
className, className,
preference, preference,
menuProps,
onChange, onChange,
}: { }: {
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
preference: ExplorerPreference; preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void; onChange?: (preference: ExplorerPreference) => void;
menuProps?: Omit<MenuProps, 'items' | 'children'>;
}) => { }) => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -105,6 +113,7 @@ export const ExplorerDisplayMenuButton = ({
items={ items={
<ExplorerDisplayMenu preference={preference} onChange={onChange} /> <ExplorerDisplayMenu preference={preference} onChange={onChange} />
} }
{...menuProps}
> >
<Button <Button
className={className} className={className}

View File

@@ -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,
},
},
});

View File

@@ -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>
);
};

View File

@@ -1,6 +1,6 @@
import { IconButton } from '@affine/component'; import { IconButton } from '@affine/component';
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { Header } from '@affine/core/components/pure/header'; 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 { PlusIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -26,7 +26,7 @@ export const AllCollectionHeader = ({
)} )}
/> />
} }
center={<WorkspaceModeFilterTab activeFilter={'collections'} />} left={<ExplorerNavigation active={'collections'} />}
/> />
); );
}; };

View File

@@ -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 />;
};

View File

@@ -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 />
</>
}
/>
);
};

View File

@@ -1,4 +1,3 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const scrollContainer = style({ export const scrollContainer = style({
flex: 1, flex: 1,
@@ -7,6 +6,7 @@ export const scrollContainer = style({
}); });
export const headerCreateNewButton = style({ export const headerCreateNewButton = style({
transition: 'opacity 0.1s ease-in-out', transition: 'opacity 0.1s ease-in-out',
marginRight: 16,
}); });
export const headerCreateNewCollectionIconButton = style({ export const headerCreateNewCollectionIconButton = style({
@@ -28,17 +28,3 @@ export const body = style({
height: '100%', height: '100%',
width: '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',
});

View File

@@ -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 />;
};

View File

@@ -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',
});

View File

@@ -1,105 +1,75 @@
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils'; import { type MenuProps, RadioGroup, type RadioItem } from '@affine/component';
import { import { DocExplorerContext } from '@affine/core/components/explorer/context';
AllPageListOperationsMenu, import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
PageDisplayMenu, import { DocListViewIcon } from '@affine/core/components/explorer/docs-view/doc-list-item';
PageListNewPageButton, import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
} from '@affine/core/components/page-list'; import type { ExplorerPreference } from '@affine/core/components/explorer/types';
import { Header } from '@affine/core/components/pure/header'; import { type Dispatch, type SetStateAction, useContext } from 'react';
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 * as styles from './all-page.css'; import * as styles from './all-page-header.css';
export const AllPageHeader = ({ const views = [
showCreateNew, {
filters, label: <DocListViewIcon view="masonry" />,
onChangeFilters, value: 'masonry',
}: { className: styles.viewToggleItem,
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] {
); label: <DocListViewIcon view="grid" />,
value: 'grid',
const onImportFile = useCallback(() => { className: styles.viewToggleItem,
track.$.header.importModal.open(); },
workspaceDialogService.open('import', undefined, payload => { {
if (!payload) { label: <DocListViewIcon view="list" />,
return; value: 'list',
} className: styles.viewToggleItem,
handleOpenDocs(payload); },
}); ] satisfies RadioItem[];
}, [workspaceDialogService, handleOpenDocs]);
const ViewToggle = () => {
const { view, setView } = useContext(DocExplorerContext);
return ( return (
<Header <RadioGroup
left={ itemHeight={24}
<AllPageListOperationsMenu gap={8}
filterList={filters} padding={0}
onChangeFilterList={onChangeFilters} items={views}
propertiesMeta={workspace.docCollection.meta.properties} value={view}
/> onChange={setView}
} className={styles.viewToggle}
right={ borderRadius={4}
<> indicatorClassName={styles.viewToggleIndicator}
<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'} />}
/> />
); );
}; };
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>
);
};

View File

@@ -1,3 +1,4 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const scrollContainer = style({ export const scrollContainer = style({
flex: 1, flex: 1,
@@ -26,4 +27,36 @@ export const body = style({
flex: 1, flex: 1,
height: '100%', height: '100%',
width: '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',
},
},
}); });

View File

@@ -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 { import {
PageListHeader, DocExplorerContext,
useFilteredPageMetas, type DocExplorerContextType,
VirtualizedPageList, } from '@affine/core/components/explorer/context';
} from '@affine/core/components/page-list'; import {
import { GlobalContextService } from '@affine/core/modules/global-context'; DocListItem,
import { IntegrationService } from '@affine/core/modules/integration'; type DocListItemView,
import { WorkspacePermissionService } from '@affine/core/modules/permissions'; } from '@affine/core/components/explorer/docs-view/doc-list-item';
import { WorkspaceService } from '@affine/core/modules/workspace'; import type { ExplorerPreference } from '@affine/core/components/explorer/types';
import type { Filter } from '@affine/env/filter'; import { Filters } from '@affine/core/components/filter';
import { useI18n } from '@affine/i18n'; 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 { 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 { import {
useIsActiveView,
ViewBody, ViewBody,
ViewHeader, ViewHeader,
ViewIcon, ViewIcon,
ViewTitle, ViewTitle,
} from '../../../../modules/workbench'; } from '../../../../modules/workbench';
import { AllPage as AllPageOld } from '../all-page-old/all-page';
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs'; import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import { EmptyPageList } from '../page-list-empty';
import * as styles from './all-page.css'; import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter'; import { AllDocsHeader } from './all-page-header';
import { AllPageHeader } from './all-page-header'; import { MigrationAllDocsDataNotification } from './migration-data';
export const AllPage = () => { const GroupHeader = memo(function GroupHeader({
const currentWorkspace = useService(WorkspaceService).workspace; groupId,
const globalContext = useService(GlobalContextService).globalContext; collapsed,
const permissionService = useService(WorkspacePermissionService); itemCount,
const integrationService = useService(IntegrationService); }: {
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); groupId: string;
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true); collapsed?: boolean;
const isAdmin = useLiveData(permissionService.permission.isAdmin$); itemCount: number;
const isOwner = useLiveData(permissionService.permission.isOwner$); }) {
const importing = useLiveData(integrationService.importing$); const { groupBy } = useContext(DocExplorerContext);
const propertyService = useService(WorkspacePropertyService);
const allProperties = useLiveData(propertyService.sortedProperties$);
const [filters, setFilters] = useState<Filter[]>([]); const groupType = groupBy?.type;
const filteredPageMetas = useFilteredPageMetas(pageMetas, { const groupKey = groupBy?.key;
filters: filters,
});
const isActiveView = useIsActiveView(); const header = useMemo(() => {
if (groupType === 'property') {
const property = allProperties.find(p => p.id === groupKey);
if (!property) return null;
useEffect(() => { const config = WorkspacePropertyTypes[property.type];
if (isActiveView) { if (!config?.groupHeader) return null;
globalContext.isAllDocs.set(true); return (
<config.groupHeader
return () => { groupId={groupId}
globalContext.isAllDocs.set(false); docCount={itemCount}
}; collapsed={!!collapsed}
/>
);
} else {
return '// TODO: ' + groupType;
} }
return; }, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]);
}, [globalContext, isActiveView]);
const t = useI18n(); if (!groupType) {
if (importing) {
return null; 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 ( return (
<> <DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={t['All pages']()} /> <ViewTitle title={t['All pages']()} />
<ViewIcon icon="allDocs" /> <ViewIcon icon="allDocs" />
<ViewHeader> <ViewHeader>
<AllPageHeader <AllDocsHeader
showCreateNew={!hideHeaderCreateNew} explorerPreference={explorerPreference}
filters={filters} setExplorerPreference={setExplorerPreference}
onChangeFilters={setFilters}
/> />
</ViewHeader> </ViewHeader>
<ViewBody> <ViewBody>
<div className={styles.body}> <div className={styles.body}>
<FilterContainer filters={filters} onChangeFilters={setFilters} /> <div className={styles.filterArea}>
{filteredPageMetas.length > 0 ? ( <MigrationAllDocsDataNotification />
<VirtualizedPageList <Filters
disableMultiDelete={!isAdmin && !isOwner} filters={explorerPreference.filters ?? []}
setHideHeaderCreateNewPage={setHideHeaderCreateNew} onChange={handleFilterChange}
filters={filters}
/> />
) : (
<EmptyPageList type="all" heading={<PageListHeader />} />
)}
</div> </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> </ViewBody>
<AllDocSidebarTabs /> <AllDocSidebarTabs />
</> </DocExplorerContext.Provider>
); );
}; };
export const Component = () => { export const Component = () => {
return <AllPage />; const featureFlagService = useService(FeatureFlagService);
const enableNewAllDocsPage = useLiveData(
featureFlagService.flags.enable_new_all_docs_page.$
);
return enableNewAllDocsPage ? <AllPage /> : <AllPageOld />;
}; };

View File

@@ -1,6 +1,6 @@
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { Header } from '@affine/core/components/pure/header'; import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
export const AllTagHeader = () => { export const AllTagHeader = () => {
return <Header center={<WorkspaceModeFilterTab activeFilter={'tags'} />} />; return <Header left={<ExplorerNavigation active={'tags'} />} />;
}; };

View File

@@ -1,7 +1,7 @@
import { IconButton } from '@affine/component'; import { IconButton } from '@affine/component';
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { PageDisplayMenu } from '@affine/core/components/page-list'; import { PageDisplayMenu } from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header'; 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 { PlusIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -31,7 +31,7 @@ export const CollectionDetailHeader = ({
<PageDisplayMenu /> <PageDisplayMenu />
</> </>
} }
center={<WorkspaceModeFilterTab activeFilter={'collections'} />} left={<ExplorerNavigation active="collections" />}
/> />
); );
}; };

View File

@@ -1,11 +1,11 @@
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { PageDisplayMenu } from '@affine/core/components/page-list'; import { PageDisplayMenu } from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header'; import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
export const TagDetailHeader = () => { export const TagDetailHeader = () => {
return ( return (
<Header <Header
center={<WorkspaceModeFilterTab activeFilter={'tags'} />} left={<ExplorerNavigation active={'tags'} />}
right={<PageDisplayMenu />} right={<PageDisplayMenu />}
/> />
); );

View File

@@ -6,8 +6,8 @@ export const workbenchRoutes = [
lazy: () => import('./pages/workspace/all-page/all-page'), lazy: () => import('./pages/workspace/all-page/all-page'),
}, },
{ {
path: '/all-new', path: '/all-old',
lazy: () => import('./pages/workspace/all-page-new/all-page'), lazy: () => import('./pages/workspace/all-page-old/all-page'),
}, },
{ {
path: '/collection', path: '/collection',

View File

@@ -283,6 +283,13 @@ export const AFFINE_FLAGS = {
configurable: false, configurable: false,
defaultState: isCanaryBuild, 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 }; } satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare // oxlint-disable-next-line no-redeclare