mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 14:28:51 +08:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user