mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 18:46:19 +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:
@@ -46,6 +46,8 @@ export const Wrapper = styled('div', {
|
||||
'marginLeft',
|
||||
'marginRight',
|
||||
'marginBottom',
|
||||
'flexGrow',
|
||||
'flexShrink',
|
||||
].includes(prop as string);
|
||||
},
|
||||
})<WrapperProps>(({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,7 +29,3 @@ export const viewToggleItem = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
export const viewToggleIndicator = style({
|
||||
backgroundColor: cssVarV2.layer.background.hoverOverlay,
|
||||
boxShadow: 'none',
|
||||
});
|
||||
|
||||
@@ -1,61 +1,10 @@
|
||||
import { type MenuProps, RadioGroup, type RadioItem } from '@affine/component';
|
||||
import { DocExplorerContext } from '@affine/core/components/explorer/context';
|
||||
import { type MenuProps } from '@affine/component';
|
||||
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
|
||||
import {
|
||||
type DocListItemView,
|
||||
DocListViewIcon,
|
||||
} from '@affine/core/components/explorer/docs-view/doc-list-item';
|
||||
import { ViewToggle } from '@affine/core/components/explorer/display-menu/view-toggle';
|
||||
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
|
||||
import { useLiveData } from '@toeverything/infra';
|
||||
import { useCallback, useContext } from 'react';
|
||||
|
||||
import * as styles from './all-page-header.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[];
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const menuProps: Partial<MenuProps> = {
|
||||
contentOptions: {
|
||||
side: 'bottom',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const scrollContainer = style({
|
||||
flex: 1,
|
||||
@@ -38,13 +37,6 @@ export const scrollArea = style({
|
||||
});
|
||||
|
||||
// group
|
||||
export const groupHeader = style({
|
||||
background: cssVarV2.layer.background.primary,
|
||||
});
|
||||
|
||||
export const docItem = style({
|
||||
transition: 'width 0.2s ease-in-out',
|
||||
});
|
||||
|
||||
export const pinnedCollection = style({
|
||||
display: 'flex',
|
||||
|
||||
@@ -1,38 +1,20 @@
|
||||
import {
|
||||
Button,
|
||||
Masonry,
|
||||
type MasonryGroup,
|
||||
useConfirmModal,
|
||||
usePromptModal,
|
||||
} from '@affine/component';
|
||||
import { Button, usePromptModal } from '@affine/component';
|
||||
import {
|
||||
createDocExplorerContext,
|
||||
DocExplorerContext,
|
||||
} from '@affine/core/components/explorer/context';
|
||||
import { DocListItem } from '@affine/core/components/explorer/docs-view/doc-list-item';
|
||||
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
|
||||
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';
|
||||
import {
|
||||
CollectionService,
|
||||
PinnedCollectionService,
|
||||
} from '@affine/core/modules/collection';
|
||||
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
|
||||
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
ViewBody,
|
||||
@@ -47,70 +29,9 @@ import { AllDocsHeader } from './all-page-header';
|
||||
import { MigrationAllDocsDataNotification } from './migration-data';
|
||||
import { PinnedCollections } from './pinned-collections';
|
||||
|
||||
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 {
|
||||
return '// TODO: ' + groupType;
|
||||
}
|
||||
}, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]);
|
||||
|
||||
if (!groupType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return header;
|
||||
});
|
||||
|
||||
const calcCardHeightById = (id: string) => {
|
||||
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 AllPage = () => {
|
||||
const t = useI18n();
|
||||
const docsService = useService(DocsService);
|
||||
|
||||
const collectionService = useService(CollectionService);
|
||||
const pinnedCollectionService = useService(PinnedCollectionService);
|
||||
|
||||
@@ -138,41 +59,10 @@ export const AllPage = () => {
|
||||
|
||||
const [explorerContextValue] = useState(createDocExplorerContext);
|
||||
|
||||
const view = useLiveData(explorerContextValue.view$);
|
||||
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 { openPromptModal } = usePromptModal();
|
||||
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 collectionRulesService = useService(CollectionRulesService);
|
||||
useEffect(() => {
|
||||
@@ -270,39 +160,6 @@ export const AllPage = () => {
|
||||
setTempFilters(filters);
|
||||
}, []);
|
||||
|
||||
const handleCloseFloatingToolbar = useCallback(() => {
|
||||
explorerContextValue.selectMode$.next(false);
|
||||
explorerContextValue.selectedDocIds$.next([]);
|
||||
}, [explorerContextValue]);
|
||||
|
||||
const handleMultiDelete = useCallback(() => {
|
||||
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: () => {
|
||||
for (const docId of selectedDocIds) {
|
||||
const doc = docsService.list.doc$(docId).value;
|
||||
doc?.moveToTrash();
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [docsService.list, openConfirmModal, selectedDocIds, t]);
|
||||
|
||||
const handleSaveFilters = useCallback(() => {
|
||||
openPromptModal({
|
||||
title: t['com.affine.editCollection.saveCollection'](),
|
||||
@@ -383,41 +240,9 @@ export const AllPage = () => {
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.scrollArea}>
|
||||
<Masonry
|
||||
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),
|
||||
[]
|
||||
)}
|
||||
/>
|
||||
<DocsExplorer />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</ViewBody>
|
||||
<AllDocSidebarTabs />
|
||||
</DocExplorerContext.Provider>
|
||||
|
||||
@@ -1,35 +1,17 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { FlexWrapper } from '@affine/component';
|
||||
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
|
||||
import { ViewToggle } from '@affine/core/components/explorer/display-menu/view-toggle';
|
||||
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
|
||||
import { PageDisplayMenu } from '@affine/core/components/page-list';
|
||||
import { Header } from '@affine/core/components/pure/header';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import * as styles from './collection.css';
|
||||
|
||||
export const CollectionDetailHeader = ({
|
||||
showCreateNew,
|
||||
onCreate,
|
||||
}: {
|
||||
showCreateNew: boolean;
|
||||
onCreate: () => void;
|
||||
}) => {
|
||||
export const CollectionDetailHeader = () => {
|
||||
return (
|
||||
<Header
|
||||
right={
|
||||
<>
|
||||
<IconButton
|
||||
size="16"
|
||||
icon={<PlusIcon />}
|
||||
onClick={onCreate}
|
||||
className={clsx(
|
||||
styles.headerCreateNewButton,
|
||||
styles.headerCreateNewCollectionIconButton,
|
||||
!showCreateNew && styles.headerCreateNewButtonHidden
|
||||
)}
|
||||
/>
|
||||
<PageDisplayMenu />
|
||||
</>
|
||||
<FlexWrapper gap={16}>
|
||||
<ViewToggle />
|
||||
<ExplorerDisplayMenuButton />
|
||||
</FlexWrapper>
|
||||
}
|
||||
left={<ExplorerNavigation active="collections" />}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const scrollArea = style({
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
export const collectionHeader = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '24px',
|
||||
});
|
||||
|
||||
export const breadcrumb = style({
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2.text.secondary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const breadcrumbItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
color: cssVarV2.text.primary,
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const breadcrumbLink = style({
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
});
|
||||
export const breadcrumbIcon = style({
|
||||
fontSize: 20,
|
||||
color: cssVarV2.icon.primary,
|
||||
});
|
||||
export const breadcrumbSeparator = style({
|
||||
marginLeft: 4,
|
||||
marginRight: 8,
|
||||
});
|
||||
|
||||
export const headerActions = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
});
|
||||
|
||||
export const newPageButtonText = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
fontWeight: 500,
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { FlexWrapper } from '@affine/component';
|
||||
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty/collection-detail';
|
||||
import { VirtualizedPageList } from '@affine/core/components/page-list';
|
||||
import {
|
||||
createDocExplorerContext,
|
||||
DocExplorerContext,
|
||||
} from '@affine/core/components/explorer/context';
|
||||
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
|
||||
import {
|
||||
type Collection,
|
||||
CollectionService,
|
||||
} from '@affine/core/modules/collection';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
@@ -25,42 +30,82 @@ import {
|
||||
import { PageNotFound } from '../../404';
|
||||
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
|
||||
import { CollectionDetailHeader } from './header';
|
||||
import * as styles from './index.css';
|
||||
import { CollectionListHeader } from './list-header';
|
||||
|
||||
export const CollectionDetail = ({
|
||||
collection,
|
||||
}: {
|
||||
collection: Collection;
|
||||
}) => {
|
||||
const { workspaceDialogService } = useServices({
|
||||
WorkspaceDialogService,
|
||||
});
|
||||
const [explorerContextValue] = useState(createDocExplorerContext);
|
||||
const collectionRulesService = useService(CollectionRulesService);
|
||||
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
|
||||
|
||||
const handleEditCollection = useCallback(() => {
|
||||
workspaceDialogService.open('collection-editor', {
|
||||
collectionId: collection.id,
|
||||
});
|
||||
}, [collection, workspaceDialogService]);
|
||||
const groupBy = useLiveData(explorerContextValue.groupBy$);
|
||||
const orderBy = useLiveData(explorerContextValue.orderBy$);
|
||||
const rules = useLiveData(collection.rules$);
|
||||
const allowList = useLiveData(collection.allowList$);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = collectionRulesService
|
||||
.watch({
|
||||
filters: rules.filters,
|
||||
groupBy,
|
||||
orderBy,
|
||||
extraAllowList: allowList,
|
||||
extraFilters: [
|
||||
{
|
||||
type: 'system',
|
||||
key: 'empty-journal',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
key: 'trash',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
})
|
||||
.subscribe({
|
||||
next: result => {
|
||||
explorerContextValue.groups$.next(result.groups);
|
||||
},
|
||||
error: error => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [
|
||||
allowList,
|
||||
collectionRulesService,
|
||||
explorerContextValue.groups$,
|
||||
groupBy,
|
||||
orderBy,
|
||||
rules.filters,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocExplorerContext.Provider value={explorerContextValue}>
|
||||
<ViewHeader>
|
||||
<CollectionDetailHeader
|
||||
showCreateNew={!hideHeaderCreateNew}
|
||||
onCreate={handleEditCollection}
|
||||
/>
|
||||
<CollectionDetailHeader />
|
||||
</ViewHeader>
|
||||
<ViewBody>
|
||||
<VirtualizedPageList
|
||||
collection={collection}
|
||||
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
|
||||
disableMultiDelete={!isAdmin && !isOwner}
|
||||
/>
|
||||
<FlexWrapper flexDirection="column" alignItems="stretch" width="100%">
|
||||
<CollectionListHeader collection={collection} />
|
||||
<div className={styles.scrollArea}>
|
||||
<DocsExplorer disableMultiDelete={!isAdmin && !isOwner} />
|
||||
</div>
|
||||
</FlexWrapper>
|
||||
</ViewBody>
|
||||
</>
|
||||
</DocExplorerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Button, useConfirmModal } from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
|
||||
import { PageListNewPageButton } from '@affine/core/components/page-list';
|
||||
import {
|
||||
type Collection,
|
||||
CollectionService,
|
||||
} from '@affine/core/modules/collection';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { DocRecord } from '@affine/core/modules/doc';
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { DocMode } from '@blocksuite/affine/model';
|
||||
import { ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const CollectionListHeader = ({
|
||||
collection,
|
||||
}: {
|
||||
collection: Collection;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const { collectionService, workspaceService, workspaceDialogService } =
|
||||
useServices({
|
||||
CollectionService,
|
||||
WorkspaceService,
|
||||
WorkspaceDialogService,
|
||||
});
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
workspaceDialogService.open('collection-editor', {
|
||||
collectionId: collection.id,
|
||||
});
|
||||
}, [collection, workspaceDialogService]);
|
||||
|
||||
const workspace = workspaceService.workspace;
|
||||
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const name = useLiveData(collection.name$);
|
||||
|
||||
const createAndAddDocument = useCallback(
|
||||
(createDocumentFn: () => DocRecord) => {
|
||||
const newDoc = createDocumentFn();
|
||||
collectionService.addDocToCollection(collection.id, newDoc.id);
|
||||
},
|
||||
[collection.id, collectionService]
|
||||
);
|
||||
|
||||
const onConfirmAddDocument = useCallback(
|
||||
(createDocumentFn: () => DocRecord) => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.collection.add-doc.confirm.title'](),
|
||||
description: t['com.affine.collection.add-doc.confirm.description'](),
|
||||
cancelText: t['Cancel'](),
|
||||
confirmText: t['Confirm'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'primary',
|
||||
},
|
||||
onConfirm: () => createAndAddDocument(createDocumentFn),
|
||||
});
|
||||
},
|
||||
[openConfirmModal, t, createAndAddDocument]
|
||||
);
|
||||
|
||||
const createPageModeDoc = useCallback(
|
||||
() => createPage('page' as DocMode),
|
||||
[createPage]
|
||||
);
|
||||
|
||||
const onCreateEdgeless = useCallback(
|
||||
() => onConfirmAddDocument(createEdgeless),
|
||||
[createEdgeless, onConfirmAddDocument]
|
||||
);
|
||||
const onCreatePage = useCallback(() => {
|
||||
onConfirmAddDocument(createPageModeDoc);
|
||||
}, [createPageModeDoc, onConfirmAddDocument]);
|
||||
const onCreateDoc = useCallback(() => {
|
||||
onConfirmAddDocument(createPage);
|
||||
}, [createPage, onConfirmAddDocument]);
|
||||
|
||||
return (
|
||||
<header className={styles.collectionHeader}>
|
||||
<div className={styles.breadcrumb}>
|
||||
<div className={styles.breadcrumbItem}>
|
||||
<WorkbenchLink to="/collection" className={styles.breadcrumbLink}>
|
||||
{t['com.affine.collections.header']()}
|
||||
</WorkbenchLink>
|
||||
</div>
|
||||
<div className={styles.breadcrumbSeparator}>/</div>
|
||||
<div className={styles.breadcrumbItem} data-active={true}>
|
||||
<ViewLayersIcon className={styles.breadcrumbIcon} />
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.headerActions}>
|
||||
<Button onClick={handleEdit}>{t['Edit']()}</Button>
|
||||
<PageListNewPageButton
|
||||
size="small"
|
||||
testId="new-page-button-trigger"
|
||||
onCreateDoc={onCreateDoc}
|
||||
onCreateEdgeless={onCreateEdgeless}
|
||||
onCreatePage={onCreatePage}
|
||||
>
|
||||
<div className={styles.newPageButtonText}>{t['New Page']()}</div>
|
||||
</PageListNewPageButton>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user