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

View File

@@ -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 { ExplorerPreference } from './types';
export type DocExplorerContextType = ExplorerPreference & {
view: DocListItemView;
setView: Dispatch<SetStateAction<DocListItemView>>;
groups: Array<{ key: string; items: string[] }>;
collapsed: string[];
selectMode?: boolean;
selectedDocIds: string[];
prevCheckAnchorId?: string | null;
onToggleCollapse: (groupId: string) => void;
onToggleSelect: (docId: string) => void;
onSelect: Dispatch<SetStateAction<string[]>>;
setPrevCheckAnchorId: Dispatch<SetStateAction<string | null>>;
export type DocExplorerContextType = {
view$: LiveData<DocListItemView>;
groups$: LiveData<Array<{ key: string; items: string[] }>>;
collapsedGroups$: LiveData<string[]>;
selectMode$?: LiveData<boolean>;
selectedDocIds$: LiveData<string[]>;
prevCheckAnchorId$?: LiveData<string | null>;
} & {
[K in keyof ExplorerPreference as `${K}$`]: LiveData<ExplorerPreference[K]>;
};
export const DocExplorerContext = createContext<DocExplorerContextType>({
view: 'list',
setView: () => {},
groups: [],
collapsed: [],
selectedDocIds: [],
prevCheckAnchorId: null,
onToggleSelect: () => {},
onToggleCollapse: () => {},
onSelect: () => {},
setPrevCheckAnchorId: () => {},
});
export const DocExplorerContext = createContext<DocExplorerContextType>(
{} as any
);
export const createDocExplorerContext = () =>
({
view$: new LiveData<DocListItemView>('list'),
groups$: new LiveData<Array<{ key: string; items: string[] }>>([]),
collapsedGroups$: new LiveData<string[]>([]),
selectMode$: new LiveData<boolean>(false),
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;

View File

@@ -11,85 +11,63 @@ import type {
} from '@affine/core/modules/collection-rules/types';
import { useI18n } from '@affine/i18n';
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
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 { OrderByList, OrderByName } from './order';
import { DisplayProperties } from './properties';
import { QuickActionsConfig } from './quick-actions';
import * as styles from './styles.css';
const ExplorerDisplayMenu = ({
preference,
onChange,
}: {
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
}) => {
const ExplorerDisplayMenu = () => {
const t = useI18n();
const explorerContextValue = useContext(DocExplorerContext);
const groupBy = useLiveData(explorerContextValue.groupBy$);
const orderBy = useLiveData(explorerContextValue.orderBy$);
const handleGroupByChange = useCallback(
(groupBy: GroupByParams) => {
onChange?.({
...preference,
groupBy,
});
explorerContextValue.groupBy$?.next(groupBy);
},
[onChange, preference]
[explorerContextValue.groupBy$]
);
const handleOrderByChange = useCallback(
(orderBy: OrderByParams) => {
onChange?.({
...preference,
orderBy,
});
explorerContextValue.orderBy$?.next(orderBy);
},
[onChange, preference]
[explorerContextValue.orderBy$]
);
return (
<div className={styles.displayMenuContainer}>
<MenuSub
items={
<GroupByList
groupBy={preference.groupBy}
onChange={handleGroupByChange}
/>
}
items={<GroupByList groupBy={groupBy} onChange={handleGroupByChange} />}
>
<div className={styles.subMenuSelectorContainer}>
<span>{t['com.affine.explorer.display-menu.grouping']()}</span>
<span className={styles.subMenuSelectorSelected}>
{preference.groupBy ? (
<GroupByName groupBy={preference.groupBy} />
) : null}
{groupBy ? <GroupByName groupBy={groupBy} /> : null}
</span>
</div>
</MenuSub>
<MenuSub
items={
<OrderByList
orderBy={preference.orderBy}
onChange={handleOrderByChange}
/>
}
items={<OrderByList orderBy={orderBy} onChange={handleOrderByChange} />}
>
<div className={styles.subMenuSelectorContainer}>
<span>{t['com.affine.explorer.display-menu.ordering']()}</span>
<span className={styles.subMenuSelectorSelected}>
{preference.orderBy ? (
<OrderByName orderBy={preference.orderBy} />
) : null}
{orderBy ? <OrderByName orderBy={orderBy} /> : null}
</span>
</div>
</MenuSub>
<Divider size="thinner" />
<DisplayProperties preference={preference} onChange={onChange} />
<DisplayProperties />
<Divider size="thinner" />
<QuickActionsConfig preference={preference} onChange={onChange} />
<QuickActionsConfig />
</div>
);
};
@@ -97,24 +75,15 @@ const ExplorerDisplayMenu = ({
export const ExplorerDisplayMenuButton = ({
style,
className,
preference,
menuProps,
onChange,
}: {
style?: React.CSSProperties;
className?: string;
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
menuProps?: Omit<MenuProps, 'items' | 'children'>;
}) => {
const t = useI18n();
return (
<Menu
items={
<ExplorerDisplayMenu preference={preference} onChange={onChange} />
}
{...menuProps}
>
<Menu items={<ExplorerDisplayMenu />} {...menuProps}>
<Button
className={className}
style={style}

View File

@@ -5,11 +5,11 @@ import {
} from '@affine/core/modules/workspace-property';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { useCallback, useContext, useMemo } from 'react';
import { WorkspacePropertyName } from '../../properties';
import { WorkspacePropertyTypes } from '../../workspace-property-types';
import type { ExplorerPreference } from '../types';
import { DocExplorerContext } from '../context';
import * as styles from './properties.css';
export const filterDisplayProperties = <
@@ -29,20 +29,17 @@ export const filterDisplayProperties = <
}));
};
export const DisplayProperties = ({
preference,
onChange,
}: {
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
}) => {
export const DisplayProperties = () => {
const t = useI18n();
const explorerContextValue = useContext(DocExplorerContext);
const workspacePropertyService = useService(WorkspacePropertyService);
const propertyList = useLiveData(workspacePropertyService.properties$);
const displayProperties = preference.displayProperties;
const showIcon = preference.showDocIcon;
const showBody = preference.showDocPreview;
const displayProperties = useLiveData(
explorerContextValue.displayProperties$
);
const showIcon = useLiveData(explorerContextValue.showDocIcon$);
const showBody = useLiveData(explorerContextValue.showDocPreview$);
const propertiesGroups = useMemo(
() => [
@@ -60,12 +57,9 @@ export const DisplayProperties = ({
const handleDisplayPropertiesChange = useCallback(
(displayProperties: string[]) => {
onChange?.({
...preference,
displayProperties,
});
explorerContextValue.displayProperties$?.next(displayProperties);
},
[onChange, preference]
[explorerContextValue.displayProperties$]
);
const handlePropertyClick = useCallback(
@@ -80,18 +74,12 @@ export const DisplayProperties = ({
);
const toggleIcon = useCallback(() => {
onChange?.({
...preference,
showDocIcon: !showIcon,
});
}, [onChange, preference, showIcon]);
explorerContextValue.showDocIcon$?.next(!showIcon);
}, [explorerContextValue.showDocIcon$, showIcon]);
const toggleBody = useCallback(() => {
onChange?.({
...preference,
showDocPreview: !showBody,
});
}, [onChange, preference, showBody]);
explorerContextValue.showDocPreview$?.next(!showBody);
}, [explorerContextValue.showDocPreview$, showBody]);
return (
<div className={styles.root}>

View File

@@ -1,50 +1,46 @@
import { Checkbox, MenuItem, MenuSub } from '@affine/component';
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 type { ExplorerPreference } from '../types';
import { DocExplorerContext } from '../context';
import { type QuickAction, quickActions } from '../quick-actions.constants';
export const QuickActionsConfig = ({
preference,
onChange,
}: {
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
}) => {
export const QuickActionsConfig = () => {
const t = useI18n();
const toggleAction = useCallback(
(key: QuickActionKey) => {
onChange?.({
...preference,
[key]: !preference[key],
});
},
[preference, onChange]
);
return (
<MenuSub
items={quickActions.map(action => {
if (action.disabled) return null;
return (
<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>
);
return <QuickActionItem key={action.key} action={action} />;
})}
>
{t['com.affine.all-docs.quick-actions']()}
</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>
);
};

View File

@@ -66,52 +66,49 @@ class MixId {
}
}
export const DocListItem = ({ ...props }: DocListItemProps) => {
const {
view,
groups,
selectMode,
selectedDocIds,
prevCheckAnchorId,
onSelect,
onToggleSelect,
setPrevCheckAnchorId,
} = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const view = useLiveData(contextValue.view$);
const groups = useLiveData(contextValue.groups$);
const selectMode = useLiveData(contextValue.selectMode$);
const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
const prevCheckAnchorId = useLiveData(contextValue.prevCheckAnchorId$);
const handleMultiSelect = useCallback(
(prevCursor: string, currCursor: string) => {
const flattenList = groups.flatMap(group =>
group.items.map(docId => MixId.create(group.key, docId))
);
const prev = contextValue.selectedDocIds$?.value ?? [];
const prevIndex = flattenList.indexOf(prevCursor);
const currIndex = flattenList.indexOf(currCursor);
onSelect(prev => {
const lowerIndex = Math.min(prevIndex, currIndex);
const upperIndex = Math.max(prevIndex, currIndex);
const lowerIndex = Math.min(prevIndex, currIndex);
const upperIndex = Math.max(prevIndex, currIndex);
const resSet = new Set(prev);
const handledSet = new Set<string>();
for (let i = lowerIndex; i <= upperIndex; i++) {
const mixId = flattenList[i];
const { groupId, docId } = MixId.parse(mixId);
if (groupId === null || docId === null) {
continue;
}
if (handledSet.has(docId) || mixId === prevCursor) {
continue;
}
if (resSet.has(docId)) {
resSet.delete(docId);
} else {
resSet.add(docId);
}
handledSet.add(docId);
const resSet = new Set(prev);
const handledSet = new Set<string>();
for (let i = lowerIndex; i <= upperIndex; i++) {
const mixId = flattenList[i];
const { groupId, docId } = MixId.parse(mixId);
if (groupId === null || docId === null) {
continue;
}
return [...resSet];
});
setPrevCheckAnchorId(currCursor);
if (handledSet.has(docId) || mixId === prevCursor) {
continue;
}
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(
@@ -127,13 +124,13 @@ export const DocListItem = ({ ...props }: DocListItemProps) => {
// do multi select
handleMultiSelect(prevCheckAnchorId, currCursor);
} else {
onToggleSelect(docId);
setPrevCheckAnchorId(currCursor);
contextValue.selectedDocIds$?.next([docId]);
contextValue.prevCheckAnchorId$?.next(currCursor);
}
} else {
if (e.shiftKey) {
onToggleSelect(docId);
setPrevCheckAnchorId(currCursor);
contextValue.selectedDocIds$?.next([docId]);
contextValue.prevCheckAnchorId$?.next(currCursor);
return;
} else {
// as link
@@ -141,14 +138,7 @@ export const DocListItem = ({ ...props }: DocListItemProps) => {
}
}
},
[
handleMultiSelect,
onToggleSelect,
prevCheckAnchorId,
props,
selectMode,
setPrevCheckAnchorId,
]
[contextValue, handleMultiSelect, prevCheckAnchorId, props, selectMode]
);
return (
@@ -196,7 +186,8 @@ const DragHandle = memo(function DragHandle({
preview,
...props
}: HTMLProps<HTMLDivElement> & { preview?: ReactNode }) {
const { selectMode } = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const selectMode = useLiveData(contextValue.selectMode$);
const { dragRef, CustomDragPreview } = useDraggable<AffineDNDData>(
() => ({
@@ -238,12 +229,13 @@ const Select = memo(function Select({
id,
...props
}: HTMLProps<HTMLDivElement>) {
const { selectMode, selectedDocIds, onToggleSelect } =
useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const selectMode = useLiveData(contextValue.selectMode$);
const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
const handleSelectChange = useCallback(() => {
id && onToggleSelect(id);
}, [id, onToggleSelect]);
id && contextValue.selectedDocIds$?.next([id]);
}, [id, contextValue]);
if (!id) {
return null;
@@ -263,7 +255,8 @@ const DocIcon = memo(function DocIcon({
id,
...props
}: HTMLProps<HTMLDivElement>) {
const { showDocIcon } = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const showDocIcon = useLiveData(contextValue.showDocIcon$);
if (!showDocIcon) {
return null;
}
@@ -289,7 +282,8 @@ const DocPreview = memo(function DocPreview({
loading,
...props
}: HTMLProps<HTMLDivElement> & { loading?: ReactNode }) {
const { showDocPreview } = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const showDocPreview = useLiveData(contextValue.showDocPreview$);
if (!id || !showDocPreview) return null;
@@ -356,7 +350,8 @@ const randomPreviewSkeleton = () => {
}));
};
export const CardViewDoc = ({ docId }: DocListItemProps) => {
const { selectMode } = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const selectMode = useLiveData(contextValue.selectMode$);
const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(docId));
const [previewSkeleton] = useState(randomPreviewSkeleton);

View File

@@ -1,6 +1,7 @@
import { Button, IconButton } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { ToggleRightIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import clsx from 'clsx';
import {
type HTMLAttributes,
@@ -20,14 +21,12 @@ export const DocGroupHeader = ({
groupId: string;
}) => {
const t = useI18n();
const {
selectMode,
collapsed,
groups,
selectedDocIds,
onToggleCollapse,
onSelect,
} = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const groups = useLiveData(contextValue.groups$);
const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
const collapsedGroups = useLiveData(contextValue.collapsedGroups$);
const selectMode = useLiveData(contextValue.selectMode$);
const group = groups.find(g => g.key === groupId);
const isGroupAllSelected = group?.items.every(id =>
@@ -35,24 +34,30 @@ export const DocGroupHeader = ({
);
const handleToggleCollapse = useCallback(() => {
onToggleCollapse(groupId);
}, [groupId, onToggleCollapse]);
const prev = contextValue.collapsedGroups$.value;
contextValue.collapsedGroups$.next(
prev.includes(groupId)
? prev.filter(id => id !== groupId)
: [...prev, groupId]
);
}, [groupId, contextValue]);
const handleSelectAll = useCallback(() => {
const prev = contextValue.selectedDocIds$.value;
if (isGroupAllSelected) {
onSelect(prev => prev.filter(id => !group?.items.includes(id)));
contextValue.selectedDocIds$.next(
prev.filter(id => !group?.items.includes(id))
);
} else {
onSelect(prev => {
const newSelected = [...prev];
group?.items.forEach(id => {
if (!newSelected.includes(id)) {
newSelected.push(id);
}
});
return newSelected;
const newSelected = [...prev];
group?.items.forEach(id => {
if (!newSelected.includes(id)) {
newSelected.push(id);
}
});
contextValue.selectedDocIds$.next(newSelected);
}
}, [group?.items, isGroupAllSelected, onSelect]);
}, [contextValue, group?.items, isGroupAllSelected]);
const selectedCount = group?.items.filter(id =>
selectedDocIds.includes(id)
@@ -61,7 +66,7 @@ export const DocGroupHeader = ({
return (
<div
className={styles.groupHeader}
data-collapsed={collapsed.includes(groupId)}
data-collapsed={collapsedGroups.includes(groupId)}
>
<div className={clsx(styles.content, className)} {...props} />
{selectMode ? (

View File

@@ -28,7 +28,8 @@ const cardInlinePropertyOrder: WorkspacePropertyType[] = [
];
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 workspacePropertyService = useService(WorkspacePropertyService);

View File

@@ -24,7 +24,8 @@ export const QuickFavorite = memo(function QuickFavorite({
doc,
...iconButtonProps
}: QuickActionProps) {
const { quickFavorite } = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const quickFavorite = useLiveData(contextValue.quickFavorite$);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favourite = useLiveData(favAdapter.isFavorite$(doc.id, 'doc'));
@@ -55,7 +56,8 @@ export const QuickTab = memo(function QuickTab({
doc,
...iconButtonProps
}: QuickActionProps) {
const { quickTab } = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const quickTab = useLiveData(contextValue.quickTab$);
const workbench = useService(WorkbenchService).workbench;
const onOpenInNewTab = useCallback(
(e: React.MouseEvent) => {
@@ -83,7 +85,8 @@ export const QuickSplit = memo(function QuickSplit({
doc,
...iconButtonProps
}: QuickActionProps) {
const { quickSplit } = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const quickSplit = useLiveData(contextValue.quickSplit$);
const workbench = useService(WorkbenchService).workbench;
const onOpenInSplitView = useCallback(
@@ -115,7 +118,8 @@ export const QuickDelete = memo(function QuickDelete({
}: QuickActionProps) {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const { quickTrash } = useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const quickTrash = useLiveData(contextValue.quickTrash$);
const onMoveToTrash = useCallback(
(e: React.MouseEvent) => {
@@ -162,8 +166,9 @@ export const QuickSelect = memo(function QuickSelect({
doc,
...iconButtonProps
}: QuickActionProps) {
const { quickSelect, selectedDocIds, onToggleSelect } =
useContext(DocExplorerContext);
const contextValue = useContext(DocExplorerContext);
const quickSelect = useLiveData(contextValue.quickSelect$);
const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
const selected = selectedDocIds.includes(doc.id);
@@ -171,9 +176,13 @@ export const QuickSelect = memo(function QuickSelect({
(e: React.MouseEvent) => {
e.stopPropagation();
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) {

View File

@@ -10,7 +10,7 @@ import {
} from './docs-view/quick-actions';
import type { ExplorerPreference } from './types';
export interface QuickAction {
interface QuickActionItem {
name: I18nString;
Component: React.FC<QuickActionProps>;
disabled?: boolean;
@@ -22,7 +22,7 @@ type ExtractPrefixKeys<Obj extends object, Prefix extends string> = {
export type QuickActionKey = ExtractPrefixKeys<ExplorerPreference, 'quick'>;
const QUICK_ACTION_MAP: Record<QuickActionKey, QuickAction> = {
const QUICK_ACTION_MAP: Record<QuickActionKey, QuickActionItem> = {
quickFavorite: {
name: 'com.affine.all-docs.quick-action.favorite',
Component: QuickFavorite,
@@ -50,3 +50,5 @@ export const quickActions = Object.entries(QUICK_ACTION_MAP).map(
return { key: key as QuickActionKey, ...config };
}
);
export type QuickAction = (typeof quickActions)[number];

View File

@@ -144,6 +144,7 @@ export const CreatedByDocListInlineProperty = ({
type="CreatedBy"
size={20}
emptyFallback={null}
showName={false}
/>
);
};
@@ -171,7 +172,7 @@ export const ModifiedByGroupHeader = ({
return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}>
<div className={styles.userLabelContainer}>
<PublicUserLabel id={userId} size={20} />
<PublicUserLabel id={userId} size={20} showName={false} />
</div>
</PlainTextDocGroupHeader>
);

View File

@@ -223,6 +223,7 @@ export const WorkspacePropertyTypes = {
defaultFilter: { method: 'this-week' },
showInDocList: 'inline',
docListProperty: UpdatedDateDocListProperty,
groupHeader: CreatedGroupHeader,
},
createdAt: {
icon: HistoryIcon,

View File

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

View File

@@ -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

View File

@@ -22,6 +22,9 @@ export const publicUserLabelRemoved = style([
]);
export const publicUserLabelAvatar = style({
marginRight: '0.5em',
transform: 'translateY(4px)',
selectors: {
'&[data-show-name="true"]': {
marginRight: '0.5em',
},
},
});

View File

@@ -56,6 +56,7 @@ export const PublicUserLabel = ({
name={user?.name ?? ''}
size={size}
className={styles.publicUserLabelAvatar}
data-show-name={showName}
/>
{showName && user?.name}
</span>

View File

@@ -108,7 +108,13 @@ export class DocsStore extends Store {
if (pages instanceof YArray) {
return pages.map(v => ({
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 {
return [];