feat(core): new doc list for editing collection docs and rules (#12320)

close AF-2626

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

- **New Features**
  - Added support for debounced input changes in input fields, improving performance for rapid typing scenarios.
  - Enhanced document explorer with dynamic visibility controls for drag handles and "more" menu options.
  - Introduced a new filter for searching documents by title, enabling more precise filtering in collections.
  - Added a direct search method for document titles to improve search accuracy and speed.

- **Bug Fixes**
  - Improved layout and centering of icons in document list items.
  - Updated border styles across collection editor components for a more consistent appearance.

- **Refactor**
  - Simplified page selection and rule-matching logic in collection and selector components by consolidating state management and leveraging context-driven rendering.
  - Removed deprecated and redundant hooks for page list configuration.

- **Chores**
  - Updated code to use new theme variables for border colors, ensuring visual consistency with the latest design standards.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-22 09:42:33 +00:00
parent 4b9428e6f4
commit 6d662b8a54
16 changed files with 334 additions and 345 deletions
@@ -75,6 +75,9 @@ export const listIcon = style({
height: 24,
fontSize: 24,
color: cssVarV2.icon.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const listContent = style({
width: 0,
@@ -193,6 +193,7 @@ const DragHandle = memo(function DragHandle({
}: HTMLProps<HTMLDivElement> & { preview?: ReactNode }) {
const contextValue = useContext(DocExplorerContext);
const selectMode = useLiveData(contextValue.selectMode$);
const showDragHandle = useLiveData(contextValue.showDragHandle$);
const { dragRef, CustomDragPreview } = useDraggable<AffineDNDData>(
() => ({
@@ -210,7 +211,7 @@ const DragHandle = memo(function DragHandle({
[id]
);
if (selectMode || !id) {
if (selectMode || !id || !showDragHandle) {
return null;
}
@@ -80,7 +80,7 @@ const calcCardHeightById = (id: string) => {
return 250 + value * 10;
};
const DocListItemComponent = memo(function DocListItemComponent({
export const DocListItemComponent = memo(function DocListItemComponent({
itemId,
groupId,
}: {
@@ -216,22 +216,24 @@ export const DocsExplorer = ({
[]
)}
/>
<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>
}
/>
{!disableMultiDelete ? (
<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>
}
/>
) : null}
</>
);
};
@@ -21,10 +21,11 @@ import {
SplitViewIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { useCallback, useContext } from 'react';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { IsFavoriteIcon } from '../../pure/icons';
import { DocExplorerContext } from '../context';
interface DocOperationProps {
docId: string;
@@ -203,6 +204,13 @@ export const MoreMenuButton = ({
docId: string;
iconProps?: IconButtonProps;
}) => {
const contextValue = useContext(DocExplorerContext);
const showMoreOperation = useLiveData(contextValue.showMoreOperation$);
if (!showMoreOperation) {
return null;
}
return (
<MoreMenu docId={docId} {...menuProps}>
<IconButton icon={<MoreVerticalIcon />} {...iconProps} />
@@ -14,6 +14,8 @@ export interface ExplorerDisplayPreference {
displayProperties?: string[];
showDocIcon?: boolean;
showDocPreview?: boolean;
showMoreOperation?: boolean;
showDragHandle?: boolean;
quickFavorite?: boolean;
quickTrash?: boolean;
quickSplit?: boolean;
@@ -1,99 +0,0 @@
import { toast } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { FavoriteTag } from '@affine/core/components/page-list/components/favorite-tag';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { PublicDocMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import type { DocMeta, Workspace } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import { type ReactNode, useCallback, useEffect, useMemo } from 'react';
export type AllPageListConfig = {
allPages: DocMeta[];
docCollection: Workspace;
/**
* Return `undefined` if the page is not public
*/
getPublicMode: (id: string) => undefined | 'page' | 'edgeless';
getPage: (id: string) => DocMeta | undefined;
favoriteRender: (page: DocMeta) => ReactNode;
};
/**
* @deprecated very poor performance
*/
export const useAllPageListConfig = () => {
const currentWorkspace = useService(WorkspaceService).workspace;
const shareDocsListService = useService(ShareDocsListService);
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
useEffect(() => {
// TODO(@eyhn): loading & error UI
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService]);
const workspace = currentWorkspace.docCollection;
const pageMetas = useBlockSuiteDocMeta(workspace);
const pageMap = useMemo(
() => Object.fromEntries(pageMetas.map(page => [page.id, page])),
[pageMetas]
);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const t = useI18n();
const favoriteItems = useLiveData(favAdapter.favorites$);
const isActive = useCallback(
(page: DocMeta) => {
return favoriteItems.some(fav => fav.id === page.id);
},
[favoriteItems]
);
const onToggleFavoritePage = useCallback(
(page: DocMeta) => {
const status = isActive(page);
favAdapter.toggle(page.id, 'doc');
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
},
[favAdapter, isActive, t]
);
return useMemo<AllPageListConfig>(() => {
return {
allPages: pageMetas,
getPublicMode(id) {
const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode;
if (mode === PublicDocMode.Edgeless) {
return 'edgeless';
} else if (mode === PublicDocMode.Page) {
return 'page';
} else {
return undefined;
}
},
docCollection: currentWorkspace.docCollection,
getPage: id => pageMap[id],
favoriteRender: page => {
return (
<FavoriteTag
style={{ marginRight: 8 }}
onClick={() => onToggleFavoritePage(page)}
active={isActive(page)}
/>
);
},
};
}, [
pageMetas,
currentWorkspace.docCollection,
shareDocs,
pageMap,
isActive,
onToggleFavoritePage,
]);
};
@@ -1,37 +1,26 @@
import { IconButton, Menu, toast } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { IconButton, Menu } from '@affine/component';
import {
CollectionRulesService,
type FilterParams,
} from '@affine/core/modules/collection-rules';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import { FilterIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { memo, type ReactNode, useCallback, useEffect, useState } from 'react';
import {
createDocExplorerContext,
DocExplorerContext,
} from '../../explorer/context';
import { DocsExplorer } from '../../explorer/docs-view/docs-list';
import { Filters } from '../../filter';
import { AddFilterMenu } from '../../filter/add-filter';
import { AffineShapeIcon, FavoriteTag } from '..';
import { usePageHeaderColsDef } from '../header-col-def';
import { PageListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import { AffineShapeIcon } from '..';
import { SelectorLayout } from '../selector/selector-layout';
import type { ListItem } from '../types';
import { VirtualizedList } from '../virtualized-list';
import * as styles from './select-page.css';
import { useSearch } from './use-search';
export const SelectPage = ({
export const SelectPage = memo(function SelectPage({
init = [],
onConfirm,
onCancel,
@@ -46,87 +35,89 @@ export const SelectPage = ({
init?: string[];
onConfirm?: (data: string[]) => void;
onCancel?: () => void;
}) => {
}) {
const t = useI18n();
const [value, setValue] = useState(init);
const onChange = useCallback(
(value: string[]) => {
propsOnChange?.(value);
setValue(value);
},
[propsOnChange]
);
const confirm = useCallback(() => {
onConfirm?.(value);
}, [value, onConfirm]);
const clearSelected = useCallback(() => {
onChange([]);
}, [onChange]);
const {
workspaceService,
compatibleFavoriteItemsAdapter,
shareDocsListService,
collectionRulesService,
} = useServices({
const [searchText, setSearchText] = useState('');
const { shareDocsListService, collectionRulesService } = useServices({
ShareDocsListService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
CollectionRulesService,
});
const workspace = workspaceService.workspace;
const docCollection = workspace.docCollection;
const pageMetas = useBlockSuiteDocMeta(docCollection);
const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$);
const [docExplorerContextValue] = useState(() => {
return createDocExplorerContext({
displayProperties: ['createdAt', 'updatedAt', 'tags'],
quickFavorite: true,
showMoreOperation: false,
showDragHandle: false,
});
});
// init context value
useEffect(() => {
docExplorerContextValue.selectMode$.next(true);
docExplorerContextValue.selectedDocIds$.next(init);
}, [
docExplorerContextValue.selectMode$,
docExplorerContextValue.selectedDocIds$,
init,
]);
const groups = useLiveData(docExplorerContextValue.groups$);
const selectedDocIds = useLiveData(docExplorerContextValue.selectedDocIds$);
const isEmpty =
groups.length === 0 ||
(groups.length && groups.every(group => group.items.length === 0));
const confirm = useCallback(() => {
onConfirm?.(docExplorerContextValue.selectedDocIds$.value);
}, [onConfirm, docExplorerContextValue.selectedDocIds$]);
const clearSelected = useCallback(() => {
docExplorerContextValue.selectedDocIds$.next([]);
}, [docExplorerContextValue.selectedDocIds$]);
useEffect(() => {
const ob = docExplorerContextValue.selectedDocIds$.subscribe(value => {
propsOnChange?.(value);
});
return () => {
ob.unsubscribe();
};
}, [propsOnChange, docExplorerContextValue.selectedDocIds$]);
useEffect(() => {
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService.shareDocs]);
const isFavorite = useCallback(
(meta: DocMeta) => favourites.some(fav => fav.id === meta.id),
[favourites]
);
const onToggleFavoritePage = useCallback(
(page: DocMeta) => {
const status = isFavorite(page);
compatibleFavoriteItemsAdapter.toggle(page.id, 'doc');
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
},
[compatibleFavoriteItemsAdapter, isFavorite, t]
);
const pageHeaderColsDef = usePageHeaderColsDef();
const [filters, setFilters] = useState<FilterParams[]>([]);
const [filteredDocIds, setFilteredDocIds] = useState<string[]>([]);
const filteredPageMetas = useMemo(() => {
const idSet = new Set(filteredDocIds);
return pageMetas.filter(page => idSet.has(page.id));
}, [pageMetas, filteredDocIds]);
const { searchText, updateSearchText, searchedList } =
useSearch(filteredPageMetas);
useEffect(() => {
const searchFilter = searchText
? {
type: 'system',
key: 'title',
method: 'match',
value: searchText,
}
: null;
const watchFilters: FilterParams[] =
filters.length > 0
? filters
: [
// if no filters are present, match all non-trash documents
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
];
if (searchFilter) {
watchFilters.push(searchFilter);
}
const subscription = collectionRulesService
.watch({
filters:
filters.length > 0
? filters
: [
// if no filters are present, match all non-trash documents
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
],
filters: watchFilters,
extraFilters: [
{
type: 'system',
@@ -143,40 +134,23 @@ export const SelectPage = ({
],
})
.subscribe(result => {
setFilteredDocIds(result.groups.flatMap(group => group.items));
docExplorerContextValue.groups$.next(result.groups);
});
return () => {
subscription.unsubscribe();
};
}, [collectionRulesService, filters]);
const operationsRenderer = useCallback(
(item: ListItem) => {
const page = item as DocMeta;
return (
<FavoriteTag
style={{ marginRight: 8 }}
onClick={() => onToggleFavoritePage(page)}
active={isFavorite(page)}
/>
);
},
[isFavorite, onToggleFavoritePage]
);
const pageHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={pageHeaderColsDef} />;
}, [pageHeaderColsDef]);
const pageItemRenderer = useCallback((item: ListItem) => {
return <PageListItemRenderer {...item} />;
}, []);
}, [
collectionRulesService,
docExplorerContextValue.groups$,
filters,
searchText,
]);
return (
<SelectorLayout
searchPlaceholder={t['com.affine.editCollection.search.placeholder']()}
selectedCount={value.length}
onSearch={updateSearchText}
selectedCount={selectedDocIds.length}
onSearch={setSearchText}
onClear={clearSelected}
onCancel={onCancel}
onConfirm={confirm}
@@ -206,25 +180,17 @@ export const SelectPage = ({
<Filters filters={filters} onChange={setFilters} />
</div>
) : null}
{searchedList.length ? (
<VirtualizedList
className={styles.pageList}
items={searchedList}
docCollection={docCollection}
selectable
onSelectedIdsChange={onChange}
selectedIds={value}
operationsRenderer={operationsRenderer}
itemRenderer={pageItemRenderer}
headerRenderer={pageHeaderRenderer}
/>
{!isEmpty ? (
<DocExplorerContext.Provider value={docExplorerContextValue}>
<DocsExplorer disableMultiDelete />
</DocExplorerContext.Provider>
) : (
<EmptyList search={searchText} />
)}
</div>
</SelectorLayout>
);
};
});
export const EmptyList = ({ search }: { search?: string }) => {
const t = useI18n();
return (
@@ -51,6 +51,7 @@ export const SelectorLayout = ({
className={styles.search}
placeholder={searchPlaceholder}
onChange={onSearchChange}
debounce={200}
/>
</header>