perf(core): optimize rendering of all docs (#12188)

close AF-2605

[CleanShot 2025-05-08 at 13.56.38.mp4 <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/LakojjjzZNf6ogjOVwKE/4e36e838-7c7f-4f0a-89a8-fd582c2ef573.mp4" />](https://app.graphite.dev/media/video/LakojjjzZNf6ogjOVwKE/4e36e838-7c7f-4f0a-89a8-fd582c2ef573.mp4)

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

## Summary by CodeRabbit

- **Refactor**
  - Centralized state management in the document explorer using a reactive context, replacing prop drilling and local state with observable streams for preferences, selection, grouping, and view options.
  - Updated multiple components (display menus, quick actions, doc list items, group headers) to consume and update state directly via context observables.
  - Simplified component signatures by removing now-unnecessary props related to preferences and state handlers.

- **Style**
  - Adjusted user avatar display: avatars now only have right margin when user names are shown, and vertical alignment was improved.

- **New Features**
  - User avatar elements now include a data attribute to indicate if the user name is displayed.

- **Bug Fixes**
  - Improved handling of document tags to prevent errors when tags are not in the expected format.

- **Documentation**
  - Added missing group header property for updated date fields in workspace property types.

- **Chores**
  - Clarified and renamed internal types for quick actions to improve code clarity.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-10 07:01:49 +00:00
parent 76b4da54b7
commit 66500669c8
16 changed files with 274 additions and 351 deletions
@@ -1,10 +1,13 @@
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 {
type DocListItemView,
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 { useLiveData } from '@toeverything/infra';
import { useCallback, useContext } from 'react';
import * as styles from './all-page-header.css';
@@ -27,7 +30,17 @@ const views = [
] satisfies RadioItem[];
const ViewToggle = () => {
const { view, setView } = useContext(DocExplorerContext);
const explorerContextValue = useContext(DocExplorerContext);
const view = useLiveData(explorerContextValue.view$);
const handleViewChange = useCallback(
(view: DocListItemView) => {
explorerContextValue.view$?.next(view);
},
[explorerContextValue.view$]
);
return (
<RadioGroup
itemHeight={24}
@@ -35,7 +48,7 @@ const ViewToggle = () => {
padding={0}
items={views}
value={view}
onChange={setView}
onChange={handleViewChange}
className={styles.viewToggle}
borderRadius={4}
indicatorClassName={styles.viewToggleIndicator}
@@ -51,24 +64,14 @@ const menuProps: Partial<MenuProps> = {
sideOffset: 8,
},
};
export const AllDocsHeader = ({
explorerPreference,
setExplorerPreference,
}: {
explorerPreference: ExplorerPreference;
setExplorerPreference: Dispatch<SetStateAction<ExplorerPreference>>;
}) => {
export const AllDocsHeader = () => {
return (
<div className={styles.header}>
<ExplorerNavigation active="docs" />
<div className={styles.actions}>
<ViewToggle />
<ExplorerDisplayMenuButton
preference={explorerPreference}
onChange={setExplorerPreference}
menuProps={menuProps}
/>
<ExplorerDisplayMenuButton menuProps={menuProps} />
</div>
</div>
);
@@ -1,13 +1,9 @@
import { Masonry, type MasonryGroup, useConfirmModal } from '@affine/component';
import {
createDocExplorerContext,
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 { DocListItem } from '@affine/core/components/explorer/docs-view/doc-list-item';
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';
@@ -49,9 +45,10 @@ const GroupHeader = memo(function GroupHeader({
collapsed?: boolean;
itemCount: number;
}) {
const { groupBy } = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const propertyService = useService(WorkspacePropertyService);
const allProperties = useLiveData(propertyService.sortedProperties$);
const groupBy = useLiveData(contextValue.groupBy$);
const groupType = groupBy?.type;
const groupKey = groupBy?.key;
@@ -103,30 +100,17 @@ const DocListItemComponent = memo(function DocListItemComponent({
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 [explorerContextValue] = useState(createDocExplorerContext);
const [groups, setGroups] = useState<any>([]);
const view = useLiveData(explorerContextValue.view$);
const filters = useLiveData(explorerContextValue.filters$);
const groupBy = useLiveData(explorerContextValue.groupBy$);
const orderBy = useLiveData(explorerContextValue.orderBy$);
const groups = useLiveData(explorerContextValue.groups$);
const selectedDocIds = useLiveData(explorerContextValue.selectedDocIds$);
const collapsedGroups = useLiveData(explorerContextValue.collapsedGroups$);
const selectMode = useLiveData(explorerContextValue.selectMode$);
const { openConfirmModal } = useConfirmModal();
@@ -159,14 +143,10 @@ export const AllPage = () => {
const collectionRulesService = useService(CollectionRulesService);
useEffect(() => {
const subscription = collectionRulesService
.watch(
explorerPreference.filters ?? [],
explorerPreference.groupBy,
explorerPreference.orderBy
)
.watch(filters ?? [], groupBy, orderBy)
.subscribe({
next: result => {
setGroups(result.groups);
explorerContextValue.groups$.next(result.groups);
},
error: error => {
console.error(error);
@@ -177,58 +157,38 @@ export const AllPage = () => {
};
}, [
collectionRulesService,
explorerPreference.filters,
explorerPreference.groupBy,
explorerPreference.orderBy,
explorerContextValue.groups$,
filters,
groupBy,
orderBy,
]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectMode(false);
setSelectedDocIds([]);
setPrevCheckAnchorId(null);
explorerContextValue.selectMode$.next(false);
explorerContextValue.selectedDocIds$.next([]);
explorerContextValue.prevCheckAnchorId$.next(null);
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, []);
}, [explorerContextValue]);
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 handleFilterChange = useCallback(
(filters: FilterParams[]) => {
explorerContextValue.filters$.next(filters);
},
[]
[explorerContextValue]
);
const handleCloseFloatingToolbar = useCallback(() => {
setSelectMode(false);
setSelectedDocIds([]);
}, []);
explorerContextValue.selectMode$.next(false);
explorerContextValue.selectedDocIds$.next([]);
}, [explorerContextValue]);
const handleMultiDelete = useCallback(() => {
if (selectedDocIds.length === 0) {
return;
@@ -257,54 +217,18 @@ export const AllPage = () => {
});
}, [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>
<AllDocsHeader
explorerPreference={explorerPreference}
setExplorerPreference={setExplorerPreference}
/>
<AllDocsHeader />
</ViewHeader>
<ViewBody>
<div className={styles.body}>
<div className={styles.filterArea}>
<MigrationAllDocsDataNotification />
<Filters
filters={explorerPreference.filters ?? []}
onChange={handleFilterChange}
/>
<Filters filters={filters ?? []} onChange={handleFilterChange} />
</div>
<div className={styles.scrollArea}>
<Masonry