feat(core): new doc list for collection detail (#12278)

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

- **New Features**
  - Introduced a new document list view with support for different layouts (list, grid, masonry) and improved multi-selection and batch deletion capabilities.
  - Added a view toggle control for switching between document list layouts across relevant pages.
  - Implemented a new collection list header with breadcrumb navigation and streamlined actions for editing collections and creating new pages.

- **Improvements**
  - Simplified and unified document list rendering by delegating logic to a shared component.
  - Enhanced document selection behavior, allowing toggling of individual items in select mode.
  - Updated styles for document lists, group headers, and collection pages for a more consistent appearance.
  - Refined wrapper component styling to prevent unintended HTML attribute forwarding.

- **Refactor**
  - Replaced local implementations of view toggles and document grouping with shared components for easier maintenance.
  - Removed deprecated and unused styles and props to streamline components and improve code clarity.
  - Refactored collection detail and header components to adopt new context-driven document explorer architecture.

- **Documentation**
  - Added deprecation notice to an outdated collection page list header component.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-19 01:59:38 +00:00
parent 4ecdfb1258
commit 8b669b725b
15 changed files with 575 additions and 295 deletions
@@ -0,0 +1,21 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const viewToggle = style({
backgroundColor: 'transparent',
});
export const viewToggleItem = style({
padding: 0,
fontSize: 16,
width: 24,
color: cssVarV2.icon.primary,
selectors: {
'&[data-state=checked]': {
color: cssVarV2.icon.primary,
},
},
});
export const viewToggleIndicator = style({
backgroundColor: cssVarV2.layer.background.hoverOverlay,
boxShadow: 'none',
});
@@ -0,0 +1,55 @@
import { RadioGroup, type RadioItem } from '@affine/component';
import { useLiveData } from '@toeverything/infra';
import { useCallback, useContext } from 'react';
import { DocExplorerContext } from '../context';
import {
type DocListItemView,
DocListViewIcon,
} from '../docs-view/doc-list-item';
import * as styles from './view-toggle.css';
const views = [
{
label: <DocListViewIcon view="masonry" />,
value: 'masonry',
className: styles.viewToggleItem,
},
{
label: <DocListViewIcon view="grid" />,
value: 'grid',
className: styles.viewToggleItem,
},
{
label: <DocListViewIcon view="list" />,
value: 'list',
className: styles.viewToggleItem,
},
] satisfies RadioItem[];
export const ViewToggle = () => {
const explorerContextValue = useContext(DocExplorerContext);
const view = useLiveData(explorerContextValue.view$);
const handleViewChange = useCallback(
(view: DocListItemView) => {
explorerContextValue.view$?.next(view);
},
[explorerContextValue.view$]
);
return (
<RadioGroup
itemHeight={24}
gap={8}
padding={0}
items={views}
value={view}
onChange={handleViewChange}
className={styles.viewToggle}
borderRadius={4}
indicatorClassName={styles.viewToggleIndicator}
/>
);
};
@@ -124,11 +124,16 @@ export const DocListItem = ({ ...props }: DocListItemProps) => {
// do multi select
handleMultiSelect(prevCheckAnchorId, currCursor);
} else {
contextValue.selectedDocIds$?.next([docId]);
contextValue.selectedDocIds$?.next(
contextValue.selectedDocIds$.value.includes(docId)
? contextValue.selectedDocIds$.value.filter(id => id !== docId)
: [...contextValue.selectedDocIds$.value, docId]
);
contextValue.prevCheckAnchorId$?.next(currCursor);
}
} else {
if (e.shiftKey) {
contextValue.selectMode$?.next(true);
contextValue.selectedDocIds$?.next([docId]);
contextValue.prevCheckAnchorId$?.next(currCursor);
return;
@@ -0,0 +1,10 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const groupHeader = style({
background: cssVarV2.layer.background.primary,
});
export const docItem = style({
transition: 'width 0.2s ease-in-out',
});
@@ -0,0 +1,223 @@
import { Masonry, type MasonryGroup, useConfirmModal } from '@affine/component';
import { DocsService } from '@affine/core/modules/doc';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { memo, useCallback, useContext, useEffect, useMemo } from 'react';
import { ListFloatingToolbar } from '../../page-list/components/list-floating-toolbar';
import { WorkspacePropertyTypes } from '../../workspace-property-types';
import { DocExplorerContext } from '../context';
import { DocListItem } from './doc-list-item';
import * as styles from './docs-list.css';
const GroupHeader = memo(function GroupHeader({
groupId,
collapsed,
itemCount,
}: {
groupId: string;
collapsed?: boolean;
itemCount: number;
}) {
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;
const header = useMemo(() => {
if (groupType === 'property') {
const property = allProperties.find(p => p.id === groupKey);
if (!property) return null;
const config = WorkspacePropertyTypes[property.type];
if (!config?.groupHeader) return null;
return (
<config.groupHeader
groupId={groupId}
docCount={itemCount}
collapsed={!!collapsed}
/>
);
} else {
console.warn('unsupported group type', groupType);
return null;
}
}, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]);
if (!groupType) {
return null;
}
return header;
});
const calcCardHeightById = (id: string) => {
if (!id) {
return 250;
}
const max = 5;
const min = 1;
const code = id.charCodeAt(0);
const value = Math.floor((code % (max - min)) + min);
return 250 + value * 10;
};
const DocListItemComponent = memo(function DocListItemComponent({
itemId,
groupId,
}: {
groupId: string;
itemId: string;
}) {
return <DocListItem docId={itemId} groupId={groupId} />;
});
export const DocsExplorer = ({
className,
disableMultiDelete,
}: {
className?: string;
disableMultiDelete?: boolean;
}) => {
const t = useI18n();
const contextValue = useContext(DocExplorerContext);
const docsService = useService(DocsService);
const groups = useLiveData(contextValue.groups$);
const view = useLiveData(contextValue.view$);
const selectMode = useLiveData(contextValue.selectMode$);
const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
const collapsedGroups = useLiveData(contextValue.collapsedGroups$);
const { openConfirmModal } = useConfirmModal();
const masonryItems = useMemo(() => {
const items = groups.map((group: any) => {
return {
id: group.key,
Component: groups.length > 1 ? GroupHeader : undefined,
height: groups.length > 1 ? 24 : 0,
className: styles.groupHeader,
items: group.items.map((docId: string) => {
return {
id: docId,
Component: DocListItemComponent,
height:
view === 'list'
? 42
: view === 'grid'
? 280
: calcCardHeightById(docId),
'data-view': view,
className: styles.docItem,
};
}),
} satisfies MasonryGroup;
});
return items;
}, [groups, view]);
const handleCloseFloatingToolbar = useCallback(() => {
contextValue.selectMode$?.next(false);
contextValue.selectedDocIds$.next([]);
}, [contextValue]);
const handleMultiDelete = useCallback(() => {
if (disableMultiDelete) {
handleCloseFloatingToolbar();
return;
}
if (selectedDocIds.length === 0) {
return;
}
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title.multiple']({
number: selectedDocIds.length.toString(),
}),
description: t[
'com.affine.moveToTrash.confirmModal.description.multiple'
]({
number: selectedDocIds.length.toString(),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
const selectedDocIds = contextValue.selectedDocIds$.value;
for (const docId of selectedDocIds) {
const doc = docsService.list.doc$(docId).value;
doc?.moveToTrash();
}
},
});
}, [
contextValue.selectedDocIds$,
disableMultiDelete,
docsService.list,
handleCloseFloatingToolbar,
openConfirmModal,
selectedDocIds.length,
t,
]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
contextValue.selectMode$?.next(false);
contextValue.selectedDocIds$.next([]);
contextValue.prevCheckAnchorId$?.next(null);
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [contextValue]);
return (
<>
<Masonry
className={className}
items={masonryItems}
gapY={12}
gapX={12}
groupsGap={12}
groupHeaderGapWithItems={12}
columns={view === 'list' ? 1 : undefined}
itemWidthMin={220}
preloadHeight={100}
itemWidth={'stretch'}
virtualScroll
collapsedGroups={collapsedGroups}
paddingX={useCallback(
(w: number) => (w > 500 ? 24 : w > 393 ? 20 : 16),
[]
)}
/>
<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>
}
/>
</>
);
};
@@ -105,6 +105,9 @@ export const PageListHeader = () => {
</div>
);
};
/**
* @deprecated
*/
export const CollectionPageListHeader = ({
collection,
workspaceId,