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,31 +1,50 @@
import { createContext, type Dispatch, type SetStateAction } from 'react'; import { LiveData } from '@toeverything/infra';
import { createContext } from 'react';
import type { DocListItemView } from './docs-view/doc-list-item'; 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 = {
view: DocListItemView; view$: LiveData<DocListItemView>;
setView: Dispatch<SetStateAction<DocListItemView>>; groups$: LiveData<Array<{ key: string; items: string[] }>>;
groups: Array<{ key: string; items: string[] }>; collapsedGroups$: LiveData<string[]>;
collapsed: string[]; selectMode$?: LiveData<boolean>;
selectMode?: boolean; selectedDocIds$: LiveData<string[]>;
selectedDocIds: string[]; prevCheckAnchorId$?: LiveData<string | null>;
prevCheckAnchorId?: string | null; } & {
onToggleCollapse: (groupId: string) => void; [K in keyof ExplorerPreference as `${K}$`]: LiveData<ExplorerPreference[K]>;
onToggleSelect: (docId: string) => void;
onSelect: Dispatch<SetStateAction<string[]>>;
setPrevCheckAnchorId: Dispatch<SetStateAction<string | null>>;
}; };
export const DocExplorerContext = createContext<DocExplorerContextType>({ export const DocExplorerContext = createContext<DocExplorerContextType>(
view: 'list', {} as any
setView: () => {}, );
groups: [],
collapsed: [], export const createDocExplorerContext = () =>
selectedDocIds: [], ({
prevCheckAnchorId: null, view$: new LiveData<DocListItemView>('list'),
onToggleSelect: () => {}, groups$: new LiveData<Array<{ key: string; items: string[] }>>([]),
onToggleCollapse: () => {}, collapsedGroups$: new LiveData<string[]>([]),
onSelect: () => {}, selectMode$: new LiveData<boolean>(false),
setPrevCheckAnchorId: () => {}, selectedDocIds$: new LiveData<string[]>([]),
}); prevCheckAnchorId$: new LiveData<string | null>(null),
filters$: new LiveData<ExplorerPreference['filters']>([
{
type: 'system',
key: 'trash',
value: 'false',
method: 'is',
},
]),
groupBy$: new LiveData<ExplorerPreference['groupBy']>(undefined),
orderBy$: new LiveData<ExplorerPreference['orderBy']>(undefined),
displayProperties$: new LiveData<ExplorerPreference['displayProperties']>(
[]
),
showDocIcon$: new LiveData<ExplorerPreference['showDocIcon']>(true),
showDocPreview$: new LiveData<ExplorerPreference['showDocPreview']>(true),
quickFavorite$: new LiveData<ExplorerPreference['quickFavorite']>(false),
quickSelect$: new LiveData<ExplorerPreference['quickSelect']>(false),
quickSplit$: new LiveData<ExplorerPreference['quickSplit']>(false),
quickTrash$: new LiveData<ExplorerPreference['quickTrash']>(false),
quickTab$: new LiveData<ExplorerPreference['quickTab']>(false),
}) satisfies DocExplorerContextType;
@@ -11,85 +11,63 @@ import type {
} from '@affine/core/modules/collection-rules/types'; } from '@affine/core/modules/collection-rules/types';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc'; import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import type React from 'react'; import type React from 'react';
import { useCallback } from 'react'; import { useCallback, useContext } from 'react';
import type { ExplorerPreference } from '../types'; import { DocExplorerContext } from '../context';
import { GroupByList, GroupByName } from './group'; import { GroupByList, GroupByName } from './group';
import { OrderByList, OrderByName } from './order'; import { OrderByList, OrderByName } from './order';
import { DisplayProperties } from './properties'; import { DisplayProperties } from './properties';
import { QuickActionsConfig } from './quick-actions'; import { QuickActionsConfig } from './quick-actions';
import * as styles from './styles.css'; import * as styles from './styles.css';
const ExplorerDisplayMenu = ({ const ExplorerDisplayMenu = () => {
preference,
onChange,
}: {
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
}) => {
const t = useI18n(); const t = useI18n();
const explorerContextValue = useContext(DocExplorerContext);
const groupBy = useLiveData(explorerContextValue.groupBy$);
const orderBy = useLiveData(explorerContextValue.orderBy$);
const handleGroupByChange = useCallback( const handleGroupByChange = useCallback(
(groupBy: GroupByParams) => { (groupBy: GroupByParams) => {
onChange?.({ explorerContextValue.groupBy$?.next(groupBy);
...preference,
groupBy,
});
}, },
[onChange, preference] [explorerContextValue.groupBy$]
); );
const handleOrderByChange = useCallback( const handleOrderByChange = useCallback(
(orderBy: OrderByParams) => { (orderBy: OrderByParams) => {
onChange?.({ explorerContextValue.orderBy$?.next(orderBy);
...preference,
orderBy,
});
}, },
[onChange, preference] [explorerContextValue.orderBy$]
); );
return ( return (
<div className={styles.displayMenuContainer}> <div className={styles.displayMenuContainer}>
<MenuSub <MenuSub
items={ items={<GroupByList groupBy={groupBy} onChange={handleGroupByChange} />}
<GroupByList
groupBy={preference.groupBy}
onChange={handleGroupByChange}
/>
}
> >
<div className={styles.subMenuSelectorContainer}> <div className={styles.subMenuSelectorContainer}>
<span>{t['com.affine.explorer.display-menu.grouping']()}</span> <span>{t['com.affine.explorer.display-menu.grouping']()}</span>
<span className={styles.subMenuSelectorSelected}> <span className={styles.subMenuSelectorSelected}>
{preference.groupBy ? ( {groupBy ? <GroupByName groupBy={groupBy} /> : null}
<GroupByName groupBy={preference.groupBy} />
) : null}
</span> </span>
</div> </div>
</MenuSub> </MenuSub>
<MenuSub <MenuSub
items={ items={<OrderByList orderBy={orderBy} onChange={handleOrderByChange} />}
<OrderByList
orderBy={preference.orderBy}
onChange={handleOrderByChange}
/>
}
> >
<div className={styles.subMenuSelectorContainer}> <div className={styles.subMenuSelectorContainer}>
<span>{t['com.affine.explorer.display-menu.ordering']()}</span> <span>{t['com.affine.explorer.display-menu.ordering']()}</span>
<span className={styles.subMenuSelectorSelected}> <span className={styles.subMenuSelectorSelected}>
{preference.orderBy ? ( {orderBy ? <OrderByName orderBy={orderBy} /> : null}
<OrderByName orderBy={preference.orderBy} />
) : null}
</span> </span>
</div> </div>
</MenuSub> </MenuSub>
<Divider size="thinner" /> <Divider size="thinner" />
<DisplayProperties preference={preference} onChange={onChange} /> <DisplayProperties />
<Divider size="thinner" /> <Divider size="thinner" />
<QuickActionsConfig preference={preference} onChange={onChange} /> <QuickActionsConfig />
</div> </div>
); );
}; };
@@ -97,24 +75,15 @@ const ExplorerDisplayMenu = ({
export const ExplorerDisplayMenuButton = ({ export const ExplorerDisplayMenuButton = ({
style, style,
className, className,
preference,
menuProps, menuProps,
onChange,
}: { }: {
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
menuProps?: Omit<MenuProps, 'items' | 'children'>; menuProps?: Omit<MenuProps, 'items' | 'children'>;
}) => { }) => {
const t = useI18n(); const t = useI18n();
return ( return (
<Menu <Menu items={<ExplorerDisplayMenu />} {...menuProps}>
items={
<ExplorerDisplayMenu preference={preference} onChange={onChange} />
}
{...menuProps}
>
<Button <Button
className={className} className={className}
style={style} style={style}
@@ -5,11 +5,11 @@ import {
} from '@affine/core/modules/workspace-property'; } from '@affine/core/modules/workspace-property';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useContext, useMemo } from 'react';
import { WorkspacePropertyName } from '../../properties'; import { WorkspacePropertyName } from '../../properties';
import { WorkspacePropertyTypes } from '../../workspace-property-types'; import { WorkspacePropertyTypes } from '../../workspace-property-types';
import type { ExplorerPreference } from '../types'; import { DocExplorerContext } from '../context';
import * as styles from './properties.css'; import * as styles from './properties.css';
export const filterDisplayProperties = < export const filterDisplayProperties = <
@@ -29,20 +29,17 @@ export const filterDisplayProperties = <
})); }));
}; };
export const DisplayProperties = ({ export const DisplayProperties = () => {
preference,
onChange,
}: {
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
}) => {
const t = useI18n(); const t = useI18n();
const explorerContextValue = useContext(DocExplorerContext);
const workspacePropertyService = useService(WorkspacePropertyService); const workspacePropertyService = useService(WorkspacePropertyService);
const propertyList = useLiveData(workspacePropertyService.properties$); const propertyList = useLiveData(workspacePropertyService.properties$);
const displayProperties = preference.displayProperties; const displayProperties = useLiveData(
const showIcon = preference.showDocIcon; explorerContextValue.displayProperties$
const showBody = preference.showDocPreview; );
const showIcon = useLiveData(explorerContextValue.showDocIcon$);
const showBody = useLiveData(explorerContextValue.showDocPreview$);
const propertiesGroups = useMemo( const propertiesGroups = useMemo(
() => [ () => [
@@ -60,12 +57,9 @@ export const DisplayProperties = ({
const handleDisplayPropertiesChange = useCallback( const handleDisplayPropertiesChange = useCallback(
(displayProperties: string[]) => { (displayProperties: string[]) => {
onChange?.({ explorerContextValue.displayProperties$?.next(displayProperties);
...preference,
displayProperties,
});
}, },
[onChange, preference] [explorerContextValue.displayProperties$]
); );
const handlePropertyClick = useCallback( const handlePropertyClick = useCallback(
@@ -80,18 +74,12 @@ export const DisplayProperties = ({
); );
const toggleIcon = useCallback(() => { const toggleIcon = useCallback(() => {
onChange?.({ explorerContextValue.showDocIcon$?.next(!showIcon);
...preference, }, [explorerContextValue.showDocIcon$, showIcon]);
showDocIcon: !showIcon,
});
}, [onChange, preference, showIcon]);
const toggleBody = useCallback(() => { const toggleBody = useCallback(() => {
onChange?.({ explorerContextValue.showDocPreview$?.next(!showBody);
...preference, }, [explorerContextValue.showDocPreview$, showBody]);
showDocPreview: !showBody,
});
}, [onChange, preference, showBody]);
return ( return (
<div className={styles.root}> <div className={styles.root}>
@@ -1,50 +1,46 @@
import { Checkbox, MenuItem, MenuSub } from '@affine/component'; import { Checkbox, MenuItem, MenuSub } from '@affine/component';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useCallback } from 'react'; import { useLiveData } from '@toeverything/infra';
import { useCallback, useContext } from 'react';
import { type QuickActionKey, quickActions } from '../quick-actions.constants'; import { DocExplorerContext } from '../context';
import type { ExplorerPreference } from '../types'; import { type QuickAction, quickActions } from '../quick-actions.constants';
export const QuickActionsConfig = ({ export const QuickActionsConfig = () => {
preference,
onChange,
}: {
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
}) => {
const t = useI18n(); const t = useI18n();
const toggleAction = useCallback(
(key: QuickActionKey) => {
onChange?.({
...preference,
[key]: !preference[key],
});
},
[preference, onChange]
);
return ( return (
<MenuSub <MenuSub
items={quickActions.map(action => { items={quickActions.map(action => {
if (action.disabled) return null; if (action.disabled) return null;
return ( return <QuickActionItem key={action.key} action={action} />;
<MenuItem
key={action.key}
onClick={e => {
// do not close sub menu
e.preventDefault();
toggleAction(action.key);
}}
prefixIcon={<Checkbox checked={!!preference[action.key]} />}
>
{t.t(action.name)}
</MenuItem>
);
})} })}
> >
{t['com.affine.all-docs.quick-actions']()} {t['com.affine.all-docs.quick-actions']()}
</MenuSub> </MenuSub>
); );
}; };
const QuickActionItem = ({ action }: { action: QuickAction }) => {
const t = useI18n();
const explorerContextValue = useContext(DocExplorerContext);
const value = useLiveData(explorerContextValue[`${action.key}$`]);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const value = explorerContextValue[`${action.key}$`]?.value;
explorerContextValue[`${action.key}$`]?.next(!value);
},
[action.key, explorerContextValue]
);
return (
<MenuItem prefixIcon={<Checkbox checked={!!value} />} onClick={handleClick}>
{t.t(action.name)}
</MenuItem>
);
};
@@ -66,52 +66,49 @@ class MixId {
} }
} }
export const DocListItem = ({ ...props }: DocListItemProps) => { export const DocListItem = ({ ...props }: DocListItemProps) => {
const { const contextValue = useContext(DocExplorerContext);
view, const view = useLiveData(contextValue.view$);
groups, const groups = useLiveData(contextValue.groups$);
selectMode, const selectMode = useLiveData(contextValue.selectMode$);
selectedDocIds, const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
prevCheckAnchorId, const prevCheckAnchorId = useLiveData(contextValue.prevCheckAnchorId$);
onSelect,
onToggleSelect,
setPrevCheckAnchorId,
} = useContext(DocExplorerContext);
const handleMultiSelect = useCallback( const handleMultiSelect = useCallback(
(prevCursor: string, currCursor: string) => { (prevCursor: string, currCursor: string) => {
const flattenList = groups.flatMap(group => const flattenList = groups.flatMap(group =>
group.items.map(docId => MixId.create(group.key, docId)) group.items.map(docId => MixId.create(group.key, docId))
); );
const prev = contextValue.selectedDocIds$?.value ?? [];
const prevIndex = flattenList.indexOf(prevCursor); const prevIndex = flattenList.indexOf(prevCursor);
const currIndex = flattenList.indexOf(currCursor); const currIndex = flattenList.indexOf(currCursor);
onSelect(prev => { const lowerIndex = Math.min(prevIndex, currIndex);
const lowerIndex = Math.min(prevIndex, currIndex); const upperIndex = Math.max(prevIndex, currIndex);
const upperIndex = Math.max(prevIndex, currIndex);
const resSet = new Set(prev); const resSet = new Set(prev);
const handledSet = new Set<string>(); const handledSet = new Set<string>();
for (let i = lowerIndex; i <= upperIndex; i++) { for (let i = lowerIndex; i <= upperIndex; i++) {
const mixId = flattenList[i]; const mixId = flattenList[i];
const { groupId, docId } = MixId.parse(mixId); const { groupId, docId } = MixId.parse(mixId);
if (groupId === null || docId === null) { if (groupId === null || docId === null) {
continue; continue;
}
if (handledSet.has(docId) || mixId === prevCursor) {
continue;
}
if (resSet.has(docId)) {
resSet.delete(docId);
} else {
resSet.add(docId);
}
handledSet.add(docId);
} }
return [...resSet]; if (handledSet.has(docId) || mixId === prevCursor) {
}); continue;
setPrevCheckAnchorId(currCursor); }
if (resSet.has(docId)) {
resSet.delete(docId);
} else {
resSet.add(docId);
}
handledSet.add(docId);
}
contextValue.selectedDocIds$?.next(Array.from(resSet));
contextValue.prevCheckAnchorId$?.next(currCursor);
}, },
[groups, onSelect, setPrevCheckAnchorId] [contextValue, groups]
); );
const handleClick = useCallback( const handleClick = useCallback(
@@ -127,13 +124,13 @@ export const DocListItem = ({ ...props }: DocListItemProps) => {
// do multi select // do multi select
handleMultiSelect(prevCheckAnchorId, currCursor); handleMultiSelect(prevCheckAnchorId, currCursor);
} else { } else {
onToggleSelect(docId); contextValue.selectedDocIds$?.next([docId]);
setPrevCheckAnchorId(currCursor); contextValue.prevCheckAnchorId$?.next(currCursor);
} }
} else { } else {
if (e.shiftKey) { if (e.shiftKey) {
onToggleSelect(docId); contextValue.selectedDocIds$?.next([docId]);
setPrevCheckAnchorId(currCursor); contextValue.prevCheckAnchorId$?.next(currCursor);
return; return;
} else { } else {
// as link // as link
@@ -141,14 +138,7 @@ export const DocListItem = ({ ...props }: DocListItemProps) => {
} }
} }
}, },
[ [contextValue, handleMultiSelect, prevCheckAnchorId, props, selectMode]
handleMultiSelect,
onToggleSelect,
prevCheckAnchorId,
props,
selectMode,
setPrevCheckAnchorId,
]
); );
return ( return (
@@ -196,7 +186,8 @@ const DragHandle = memo(function DragHandle({
preview, preview,
...props ...props
}: HTMLProps<HTMLDivElement> & { preview?: ReactNode }) { }: HTMLProps<HTMLDivElement> & { preview?: ReactNode }) {
const { selectMode } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const selectMode = useLiveData(contextValue.selectMode$);
const { dragRef, CustomDragPreview } = useDraggable<AffineDNDData>( const { dragRef, CustomDragPreview } = useDraggable<AffineDNDData>(
() => ({ () => ({
@@ -238,12 +229,13 @@ const Select = memo(function Select({
id, id,
...props ...props
}: HTMLProps<HTMLDivElement>) { }: HTMLProps<HTMLDivElement>) {
const { selectMode, selectedDocIds, onToggleSelect } = const contextValue = useContext(DocExplorerContext);
useContext(DocExplorerContext); const selectMode = useLiveData(contextValue.selectMode$);
const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
const handleSelectChange = useCallback(() => { const handleSelectChange = useCallback(() => {
id && onToggleSelect(id); id && contextValue.selectedDocIds$?.next([id]);
}, [id, onToggleSelect]); }, [id, contextValue]);
if (!id) { if (!id) {
return null; return null;
@@ -263,7 +255,8 @@ const DocIcon = memo(function DocIcon({
id, id,
...props ...props
}: HTMLProps<HTMLDivElement>) { }: HTMLProps<HTMLDivElement>) {
const { showDocIcon } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const showDocIcon = useLiveData(contextValue.showDocIcon$);
if (!showDocIcon) { if (!showDocIcon) {
return null; return null;
} }
@@ -289,7 +282,8 @@ const DocPreview = memo(function DocPreview({
loading, loading,
...props ...props
}: HTMLProps<HTMLDivElement> & { loading?: ReactNode }) { }: HTMLProps<HTMLDivElement> & { loading?: ReactNode }) {
const { showDocPreview } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const showDocPreview = useLiveData(contextValue.showDocPreview$);
if (!id || !showDocPreview) return null; if (!id || !showDocPreview) return null;
@@ -356,7 +350,8 @@ const randomPreviewSkeleton = () => {
})); }));
}; };
export const CardViewDoc = ({ docId }: DocListItemProps) => { export const CardViewDoc = ({ docId }: DocListItemProps) => {
const { selectMode } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const selectMode = useLiveData(contextValue.selectMode$);
const docsService = useService(DocsService); const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(docId)); const doc = useLiveData(docsService.list.doc$(docId));
const [previewSkeleton] = useState(randomPreviewSkeleton); const [previewSkeleton] = useState(randomPreviewSkeleton);
@@ -1,6 +1,7 @@
import { Button, IconButton } from '@affine/component'; import { Button, IconButton } from '@affine/component';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { ToggleRightIcon } from '@blocksuite/icons/rc'; import { ToggleRightIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
type HTMLAttributes, type HTMLAttributes,
@@ -20,14 +21,12 @@ export const DocGroupHeader = ({
groupId: string; groupId: string;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const { const contextValue = useContext(DocExplorerContext);
selectMode,
collapsed, const groups = useLiveData(contextValue.groups$);
groups, const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
selectedDocIds, const collapsedGroups = useLiveData(contextValue.collapsedGroups$);
onToggleCollapse, const selectMode = useLiveData(contextValue.selectMode$);
onSelect,
} = useContext(DocExplorerContext);
const group = groups.find(g => g.key === groupId); const group = groups.find(g => g.key === groupId);
const isGroupAllSelected = group?.items.every(id => const isGroupAllSelected = group?.items.every(id =>
@@ -35,24 +34,30 @@ export const DocGroupHeader = ({
); );
const handleToggleCollapse = useCallback(() => { const handleToggleCollapse = useCallback(() => {
onToggleCollapse(groupId); const prev = contextValue.collapsedGroups$.value;
}, [groupId, onToggleCollapse]); contextValue.collapsedGroups$.next(
prev.includes(groupId)
? prev.filter(id => id !== groupId)
: [...prev, groupId]
);
}, [groupId, contextValue]);
const handleSelectAll = useCallback(() => { const handleSelectAll = useCallback(() => {
const prev = contextValue.selectedDocIds$.value;
if (isGroupAllSelected) { if (isGroupAllSelected) {
onSelect(prev => prev.filter(id => !group?.items.includes(id))); contextValue.selectedDocIds$.next(
prev.filter(id => !group?.items.includes(id))
);
} else { } else {
onSelect(prev => { const newSelected = [...prev];
const newSelected = [...prev]; group?.items.forEach(id => {
group?.items.forEach(id => { if (!newSelected.includes(id)) {
if (!newSelected.includes(id)) { newSelected.push(id);
newSelected.push(id); }
}
});
return newSelected;
}); });
contextValue.selectedDocIds$.next(newSelected);
} }
}, [group?.items, isGroupAllSelected, onSelect]); }, [contextValue, group?.items, isGroupAllSelected]);
const selectedCount = group?.items.filter(id => const selectedCount = group?.items.filter(id =>
selectedDocIds.includes(id) selectedDocIds.includes(id)
@@ -61,7 +66,7 @@ export const DocGroupHeader = ({
return ( return (
<div <div
className={styles.groupHeader} className={styles.groupHeader}
data-collapsed={collapsed.includes(groupId)} data-collapsed={collapsedGroups.includes(groupId)}
> >
<div className={clsx(styles.content, className)} {...props} /> <div className={clsx(styles.content, className)} {...props} />
{selectMode ? ( {selectMode ? (
@@ -28,7 +28,8 @@ const cardInlinePropertyOrder: WorkspacePropertyType[] = [
]; ];
const useProperties = (docId: string, view: 'list' | 'card') => { const useProperties = (docId: string, view: 'list' | 'card') => {
const { displayProperties } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const displayProperties = useLiveData(contextValue.displayProperties$);
const docsService = useService(DocsService); const docsService = useService(DocsService);
const workspacePropertyService = useService(WorkspacePropertyService); const workspacePropertyService = useService(WorkspacePropertyService);
@@ -24,7 +24,8 @@ export const QuickFavorite = memo(function QuickFavorite({
doc, doc,
...iconButtonProps ...iconButtonProps
}: QuickActionProps) { }: QuickActionProps) {
const { quickFavorite } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const quickFavorite = useLiveData(contextValue.quickFavorite$);
const favAdapter = useService(CompatibleFavoriteItemsAdapter); const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favourite = useLiveData(favAdapter.isFavorite$(doc.id, 'doc')); const favourite = useLiveData(favAdapter.isFavorite$(doc.id, 'doc'));
@@ -55,7 +56,8 @@ export const QuickTab = memo(function QuickTab({
doc, doc,
...iconButtonProps ...iconButtonProps
}: QuickActionProps) { }: QuickActionProps) {
const { quickTab } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const quickTab = useLiveData(contextValue.quickTab$);
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const onOpenInNewTab = useCallback( const onOpenInNewTab = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -83,7 +85,8 @@ export const QuickSplit = memo(function QuickSplit({
doc, doc,
...iconButtonProps ...iconButtonProps
}: QuickActionProps) { }: QuickActionProps) {
const { quickSplit } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const quickSplit = useLiveData(contextValue.quickSplit$);
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const onOpenInSplitView = useCallback( const onOpenInSplitView = useCallback(
@@ -115,7 +118,8 @@ export const QuickDelete = memo(function QuickDelete({
}: QuickActionProps) { }: QuickActionProps) {
const t = useI18n(); const t = useI18n();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { quickTrash } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const quickTrash = useLiveData(contextValue.quickTrash$);
const onMoveToTrash = useCallback( const onMoveToTrash = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -162,8 +166,9 @@ export const QuickSelect = memo(function QuickSelect({
doc, doc,
...iconButtonProps ...iconButtonProps
}: QuickActionProps) { }: QuickActionProps) {
const { quickSelect, selectedDocIds, onToggleSelect } = const contextValue = useContext(DocExplorerContext);
useContext(DocExplorerContext); const quickSelect = useLiveData(contextValue.quickSelect$);
const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
const selected = selectedDocIds.includes(doc.id); const selected = selectedDocIds.includes(doc.id);
@@ -171,9 +176,13 @@ export const QuickSelect = memo(function QuickSelect({
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
onToggleSelect(doc.id); contextValue.selectedDocIds$?.next(
selected
? selectedDocIds.filter(id => id !== doc.id)
: [...selectedDocIds, doc.id]
);
}, },
[doc.id, onToggleSelect] [contextValue, doc.id, selected, selectedDocIds]
); );
if (!quickSelect) { if (!quickSelect) {
@@ -10,7 +10,7 @@ import {
} from './docs-view/quick-actions'; } from './docs-view/quick-actions';
import type { ExplorerPreference } from './types'; import type { ExplorerPreference } from './types';
export interface QuickAction { interface QuickActionItem {
name: I18nString; name: I18nString;
Component: React.FC<QuickActionProps>; Component: React.FC<QuickActionProps>;
disabled?: boolean; disabled?: boolean;
@@ -22,7 +22,7 @@ type ExtractPrefixKeys<Obj extends object, Prefix extends string> = {
export type QuickActionKey = ExtractPrefixKeys<ExplorerPreference, 'quick'>; export type QuickActionKey = ExtractPrefixKeys<ExplorerPreference, 'quick'>;
const QUICK_ACTION_MAP: Record<QuickActionKey, QuickAction> = { const QUICK_ACTION_MAP: Record<QuickActionKey, QuickActionItem> = {
quickFavorite: { quickFavorite: {
name: 'com.affine.all-docs.quick-action.favorite', name: 'com.affine.all-docs.quick-action.favorite',
Component: QuickFavorite, Component: QuickFavorite,
@@ -50,3 +50,5 @@ export const quickActions = Object.entries(QUICK_ACTION_MAP).map(
return { key: key as QuickActionKey, ...config }; return { key: key as QuickActionKey, ...config };
} }
); );
export type QuickAction = (typeof quickActions)[number];
@@ -144,6 +144,7 @@ export const CreatedByDocListInlineProperty = ({
type="CreatedBy" type="CreatedBy"
size={20} size={20}
emptyFallback={null} emptyFallback={null}
showName={false}
/> />
); );
}; };
@@ -171,7 +172,7 @@ export const ModifiedByGroupHeader = ({
return ( return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}> <PlainTextDocGroupHeader groupId={groupId} docCount={docCount}>
<div className={styles.userLabelContainer}> <div className={styles.userLabelContainer}>
<PublicUserLabel id={userId} size={20} /> <PublicUserLabel id={userId} size={20} showName={false} />
</div> </div>
</PlainTextDocGroupHeader> </PlainTextDocGroupHeader>
); );
@@ -223,6 +223,7 @@ export const WorkspacePropertyTypes = {
defaultFilter: { method: 'this-week' }, defaultFilter: { method: 'this-week' },
showInDocList: 'inline', showInDocList: 'inline',
docListProperty: UpdatedDateDocListProperty, docListProperty: UpdatedDateDocListProperty,
groupHeader: CreatedGroupHeader,
}, },
createdAt: { createdAt: {
icon: HistoryIcon, icon: HistoryIcon,
@@ -1,10 +1,13 @@
import { type MenuProps, RadioGroup, type RadioItem } from '@affine/component'; import { type MenuProps, RadioGroup, type RadioItem } from '@affine/component';
import { DocExplorerContext } from '@affine/core/components/explorer/context'; import { DocExplorerContext } from '@affine/core/components/explorer/context';
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu'; 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 { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import type { ExplorerPreference } from '@affine/core/components/explorer/types'; import { useLiveData } from '@toeverything/infra';
import { type Dispatch, type SetStateAction, useContext } from 'react'; import { useCallback, useContext } from 'react';
import * as styles from './all-page-header.css'; import * as styles from './all-page-header.css';
@@ -27,7 +30,17 @@ const views = [
] satisfies RadioItem[]; ] satisfies RadioItem[];
const ViewToggle = () => { 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 ( return (
<RadioGroup <RadioGroup
itemHeight={24} itemHeight={24}
@@ -35,7 +48,7 @@ const ViewToggle = () => {
padding={0} padding={0}
items={views} items={views}
value={view} value={view}
onChange={setView} onChange={handleViewChange}
className={styles.viewToggle} className={styles.viewToggle}
borderRadius={4} borderRadius={4}
indicatorClassName={styles.viewToggleIndicator} indicatorClassName={styles.viewToggleIndicator}
@@ -51,24 +64,14 @@ const menuProps: Partial<MenuProps> = {
sideOffset: 8, sideOffset: 8,
}, },
}; };
export const AllDocsHeader = ({ export const AllDocsHeader = () => {
explorerPreference,
setExplorerPreference,
}: {
explorerPreference: ExplorerPreference;
setExplorerPreference: Dispatch<SetStateAction<ExplorerPreference>>;
}) => {
return ( return (
<div className={styles.header}> <div className={styles.header}>
<ExplorerNavigation active="docs" /> <ExplorerNavigation active="docs" />
<div className={styles.actions}> <div className={styles.actions}>
<ViewToggle /> <ViewToggle />
<ExplorerDisplayMenuButton <ExplorerDisplayMenuButton menuProps={menuProps} />
preference={explorerPreference}
onChange={setExplorerPreference}
menuProps={menuProps}
/>
</div> </div>
</div> </div>
); );
@@ -1,13 +1,9 @@
import { Masonry, type MasonryGroup, useConfirmModal } from '@affine/component'; import { Masonry, type MasonryGroup, useConfirmModal } from '@affine/component';
import { import {
createDocExplorerContext,
DocExplorerContext, DocExplorerContext,
type DocExplorerContextType,
} from '@affine/core/components/explorer/context'; } from '@affine/core/components/explorer/context';
import { import { DocListItem } from '@affine/core/components/explorer/docs-view/doc-list-item';
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 { Filters } from '@affine/core/components/filter';
import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar'; import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar';
import { WorkspacePropertyTypes } from '@affine/core/components/workspace-property-types'; import { WorkspacePropertyTypes } from '@affine/core/components/workspace-property-types';
@@ -49,9 +45,10 @@ const GroupHeader = memo(function GroupHeader({
collapsed?: boolean; collapsed?: boolean;
itemCount: number; itemCount: number;
}) { }) {
const { groupBy } = useContext(DocExplorerContext); const contextValue = useContext(DocExplorerContext);
const propertyService = useService(WorkspacePropertyService); const propertyService = useService(WorkspacePropertyService);
const allProperties = useLiveData(propertyService.sortedProperties$); const allProperties = useLiveData(propertyService.sortedProperties$);
const groupBy = useLiveData(contextValue.groupBy$);
const groupType = groupBy?.type; const groupType = groupBy?.type;
const groupKey = groupBy?.key; const groupKey = groupBy?.key;
@@ -103,30 +100,17 @@ const DocListItemComponent = memo(function DocListItemComponent({
export const AllPage = () => { export const AllPage = () => {
const t = useI18n(); const t = useI18n();
const docsService = useService(DocsService); 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] = const [explorerContextValue] = useState(createDocExplorerContext);
useState<ExplorerPreference>({
filters: [
{
type: 'system',
key: 'trash',
value: 'false',
method: 'is',
},
],
displayProperties: [],
showDocIcon: true,
showDocPreview: true,
});
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(); const { openConfirmModal } = useConfirmModal();
@@ -159,14 +143,10 @@ export const AllPage = () => {
const collectionRulesService = useService(CollectionRulesService); const collectionRulesService = useService(CollectionRulesService);
useEffect(() => { useEffect(() => {
const subscription = collectionRulesService const subscription = collectionRulesService
.watch( .watch(filters ?? [], groupBy, orderBy)
explorerPreference.filters ?? [],
explorerPreference.groupBy,
explorerPreference.orderBy
)
.subscribe({ .subscribe({
next: result => { next: result => {
setGroups(result.groups); explorerContextValue.groups$.next(result.groups);
}, },
error: error => { error: error => {
console.error(error); console.error(error);
@@ -177,58 +157,38 @@ export const AllPage = () => {
}; };
}, [ }, [
collectionRulesService, collectionRulesService,
explorerPreference.filters, explorerContextValue.groups$,
explorerPreference.groupBy, filters,
explorerPreference.orderBy, groupBy,
orderBy,
]); ]);
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
setSelectMode(false); explorerContextValue.selectMode$.next(false);
setSelectedDocIds([]); explorerContextValue.selectedDocIds$.next([]);
setPrevCheckAnchorId(null); explorerContextValue.prevCheckAnchorId$.next(null);
} }
}; };
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
return () => { return () => {
document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keydown', onKeyDown);
}; };
}, []); }, [explorerContextValue]);
const handleFilterChange = useCallback((filters: FilterParams[]) => { const handleFilterChange = useCallback(
setExplorerPreference(prev => ({ (filters: FilterParams[]) => {
...prev, explorerContextValue.filters$.next(filters);
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);
}, },
[] [explorerContextValue]
); );
const handleCloseFloatingToolbar = useCallback(() => { const handleCloseFloatingToolbar = useCallback(() => {
setSelectMode(false); explorerContextValue.selectMode$.next(false);
setSelectedDocIds([]); explorerContextValue.selectedDocIds$.next([]);
}, []); }, [explorerContextValue]);
const handleMultiDelete = useCallback(() => { const handleMultiDelete = useCallback(() => {
if (selectedDocIds.length === 0) { if (selectedDocIds.length === 0) {
return; return;
@@ -257,54 +217,18 @@ export const AllPage = () => {
}); });
}, [docsService.list, openConfirmModal, selectedDocIds, t]); }, [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}> <DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={t['All pages']()} /> <ViewTitle title={t['All pages']()} />
<ViewIcon icon="allDocs" /> <ViewIcon icon="allDocs" />
<ViewHeader> <ViewHeader>
<AllDocsHeader <AllDocsHeader />
explorerPreference={explorerPreference}
setExplorerPreference={setExplorerPreference}
/>
</ViewHeader> </ViewHeader>
<ViewBody> <ViewBody>
<div className={styles.body}> <div className={styles.body}>
<div className={styles.filterArea}> <div className={styles.filterArea}>
<MigrationAllDocsDataNotification /> <MigrationAllDocsDataNotification />
<Filters <Filters filters={filters ?? []} onChange={handleFilterChange} />
filters={explorerPreference.filters ?? []}
onChange={handleFilterChange}
/>
</div> </div>
<div className={styles.scrollArea}> <div className={styles.scrollArea}>
<Masonry <Masonry
@@ -22,6 +22,9 @@ export const publicUserLabelRemoved = style([
]); ]);
export const publicUserLabelAvatar = style({ export const publicUserLabelAvatar = style({
marginRight: '0.5em', selectors: {
transform: 'translateY(4px)', '&[data-show-name="true"]': {
marginRight: '0.5em',
},
},
}); });
@@ -56,6 +56,7 @@ export const PublicUserLabel = ({
name={user?.name ?? ''} name={user?.name ?? ''}
size={size} size={size}
className={styles.publicUserLabelAvatar} className={styles.publicUserLabelAvatar}
data-show-name={showName}
/> />
{showName && user?.name} {showName && user?.name}
</span> </span>
@@ -108,7 +108,13 @@ export class DocsStore extends Store {
if (pages instanceof YArray) { if (pages instanceof YArray) {
return pages.map(v => ({ return pages.map(v => ({
id: v.get('id') as string, id: v.get('id') as string,
tags: (v.get('tags')?.toJSON() ?? []) as string[], tags: (() => {
const tags = v.get('tags');
if (tags instanceof YArray) {
return tags.toJSON() as string[];
}
return (tags ?? []) as string[];
})(),
})); }));
} else { } else {
return []; return [];