mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 23:56:36 +08:00
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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user