feat(core): add collection and tag filters to all pages (#5567)

close TOV-69

Added the `filterMode` parameter to the `/all` route.
Abstracted the `PageList` and associated components into more universal ones.
Added the `useTagMetas` hook to get and update  the workspace tags.

https://github.com/toeverything/AFFiNE/assets/102217452/7595944d-a056-40c2-8d89-d8df9e901a4b
This commit is contained in:
JimmFly 2024-01-26 07:42:47 +00:00
parent b867dcbdeb
commit 18068f4ae2
No known key found for this signature in database
GPG Key ID: 14A6F56854E1BED7
67 changed files with 3303 additions and 998 deletions

View File

@ -106,4 +106,7 @@ export const setPageModeAtom = atom(
export type PageModeOption = 'all' | 'page' | 'edgeless';
export const allPageModeSelectAtom = atom<PageModeOption>('all');
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';
export const allPageFilterSelectAtom = atom<AllPageFilterOption>('docs');
export const openWorkspaceListModalAtom = atom(false);

View File

@ -17,13 +17,11 @@ export function registerAffineNavigationCommands({
store,
workspace,
navigationHelper,
pageListMode,
setPageListMode,
}: {
t: ReturnType<typeof useAFFiNEI18N>;
store: ReturnType<typeof createStore>;
navigationHelper: ReturnType<typeof useNavigateHelper>;
pageListMode: PageModeOption;
setPageListMode: React.Dispatch<React.SetStateAction<PageModeOption>>;
workspace: Workspace;
}) {
@ -43,32 +41,26 @@ export function registerAffineNavigationCommands({
unsubs.push(
registerAffineCommand({
id: 'affine:goto-page-list',
id: 'affine:goto-collection-list',
category: 'affine:navigation',
icon: <ArrowRightBigIcon />,
preconditionStrategy: () => {
return pageListMode !== 'page';
},
label: t['com.affine.cmdk.affine.navigation.goto-page-list'](),
label: 'Go to Collection List',
run() {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
setPageListMode('page');
navigationHelper.jumpToCollections(workspace.id);
setPageListMode('all');
},
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:goto-edgeless-list',
id: 'affine:goto-tag-list',
category: 'affine:navigation',
icon: <ArrowRightBigIcon />,
preconditionStrategy: () => {
return pageListMode !== 'edgeless';
},
label: t['com.affine.cmdk.affine.navigation.goto-edgeless-list'](),
label: 'Go to Tag List',
run() {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
setPageListMode('edgeless');
navigationHelper.jumpToTags(workspace.id);
setPageListMode('all');
},
})
);

View File

@ -0,0 +1,29 @@
import { style } from '@vanilla-extract/css';
export const collectionListHeader = style({
height: 100,
alignItems: 'center',
padding: '48px 16px 20px 24px',
overflow: 'hidden',
display: 'flex',
justifyContent: 'space-between',
background: 'var(--affine-background-primary-color)',
});
export const collectionListHeaderTitle = style({
fontSize: 'var(--affine-font-h-5)',
fontWeight: 500,
color: 'var(--affine-text-secondary-color)',
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const newCollectionButton = style({
padding: '6px 10px',
borderRadius: '8px',
background: 'var(--affine-background-primary-color)',
fontSize: 'var(--affine-font-sm)',
fontWeight: 600,
height: '32px',
});

View File

@ -0,0 +1,29 @@
import { Button } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { ReactElement } from 'react';
import * as styles from './collection-list-header.css';
export const CollectionListHeader = ({
node,
onCreate,
}: {
node: ReactElement | null;
onCreate: () => void;
}) => {
const t = useAFFiNEI18N();
return (
<>
<div className={styles.collectionListHeader}>
<div className={styles.collectionListHeaderTitle}>
{t['com.affine.collections.header']()}
</div>
<Button className={styles.newCollectionButton} onClick={onCreate}>
{t['com.affine.collections.empty.new-collection-button']()}
</Button>
</div>
{node}
</>
);
};

View File

@ -0,0 +1,206 @@
import { Checkbox } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useDraggable } from '@dnd-kit/core';
import { type PropsWithChildren, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import type {
CollectionListItemProps,
DraggableTitleCellData,
PageListItemProps,
} from '../types';
import { ColWrapper, stopPropagation } from '../utils';
import * as styles from './collection-list-item.css';
const ListTitleCell = ({
title,
preview,
}: Pick<PageListItemProps, 'title' | 'preview'>) => {
const t = useAFFiNEI18N();
return (
<div data-testid="page-list-item-title" className={styles.titleCell}>
<div
data-testid="page-list-item-title-text"
className={styles.titleCellMain}
>
{title || t['Untitled']()}
</div>
{preview ? (
<div
data-testid="page-list-item-preview-text"
className={styles.titleCellPreview}
>
{preview}
</div>
) : null}
</div>
);
};
const ListIconCell = ({ icon }: Pick<PageListItemProps, 'icon'>) => {
return (
<div data-testid="page-list-item-icon" className={styles.iconCell}>
{icon}
</div>
);
};
const CollectionSelectionCell = ({
selectable,
onSelectedChange,
selected,
}: Pick<
CollectionListItemProps,
'selectable' | 'onSelectedChange' | 'selected'
>) => {
const onSelectionChange = useCallback(
(_event: React.ChangeEvent<HTMLInputElement>) => {
return onSelectedChange?.();
},
[onSelectedChange]
);
if (!selectable) {
return null;
}
return (
<div className={styles.selectionCell}>
<Checkbox
onClick={stopPropagation}
checked={!!selected}
onChange={onSelectionChange}
/>
</div>
);
};
const CollectionListOperationsCell = ({
operations,
}: Pick<CollectionListItemProps, 'operations'>) => {
return operations ? (
<div onClick={stopPropagation} className={styles.operationsCell}>
{operations}
</div>
) : null;
};
export const CollectionListItem = (props: CollectionListItemProps) => {
const collectionTitleElement = useMemo(() => {
return (
<div className={styles.dragPageItemOverlay}>
<div className={styles.titleIconsWrapper}>
<CollectionSelectionCell
onSelectedChange={props.onSelectedChange}
selectable={props.selectable}
selected={props.selected}
/>
<ListIconCell icon={props.icon} />
</div>
</div>
);
}, [props.icon, props.onSelectedChange, props.selectable, props.selected]);
// TODO: use getDropItemId
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: 'collection-list-item-title-' + props.collectionId,
data: {
pageId: props.collectionId,
pageTitle: collectionTitleElement,
} satisfies DraggableTitleCellData,
disabled: !props.draggable,
});
return (
<CollectionListItemWrapper
onClick={props.onClick}
to={props.to}
collectionId={props.collectionId}
draggable={props.draggable}
isDragging={isDragging}
>
<ColWrapper flex={9}>
<ColWrapper
className={styles.dndCell}
flex={8}
ref={setNodeRef}
{...attributes}
{...listeners}
>
<div className={styles.titleIconsWrapper}>
<CollectionSelectionCell
onSelectedChange={props.onSelectedChange}
selectable={props.selectable}
selected={props.selected}
/>
<ListIconCell icon={props.icon} />
</div>
<ListTitleCell title={props.title} />
</ColWrapper>
<ColWrapper
flex={4}
alignment="end"
style={{ overflow: 'visible' }}
></ColWrapper>
</ColWrapper>
{props.operations ? (
<ColWrapper
className={styles.actionsCellWrapper}
flex={2}
alignment="end"
>
<CollectionListOperationsCell operations={props.operations} />
</ColWrapper>
) : null}
</CollectionListItemWrapper>
);
};
type collectionListWrapperProps = PropsWithChildren<
Pick<
CollectionListItemProps,
'to' | 'collectionId' | 'onClick' | 'draggable'
> & {
isDragging: boolean;
}
>;
function CollectionListItemWrapper({
to,
isDragging,
collectionId,
onClick,
children,
draggable,
}: collectionListWrapperProps) {
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (onClick) {
stopPropagation(e);
onClick();
}
},
[onClick]
);
const commonProps = useMemo(
() => ({
'data-testid': 'collection-list-item',
'data-collection-id': collectionId,
'data-draggable': draggable,
className: styles.root,
'data-clickable': !!onClick || !!to,
'data-dragging': isDragging,
onClick: handleClick,
}),
[collectionId, draggable, isDragging, onClick, to, handleClick]
);
if (to) {
return (
<Link {...commonProps} to={to}>
{children}
</Link>
);
} else {
return <div {...commonProps}>{children}</div>;
}
}

View File

@ -0,0 +1,3 @@
export * from './collection-list-header';
export * from './collection-list-item';
export * from './virtualized-collection-list';

View File

@ -0,0 +1,151 @@
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useAtomValue } from 'jotai';
import {
type ReactElement,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { collectionHeaderColsDef } from '../header-col-def';
import { CollectionOperationCell } from '../operation-cell';
import { CollectionListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import type { CollectionMeta, ItemListHandle, ListItem } from '../types';
import { useCollectionManager } from '../use-collection-manager';
import type { AllPageListConfig } from '../view';
import { VirtualizedList } from '../virtualized-list';
import { CollectionListHeader } from './collection-list-header';
const useCollectionOperationsRenderer = ({
info,
setting,
config,
}: {
info: DeleteCollectionInfo;
config: AllPageListConfig;
setting: ReturnType<typeof useCollectionManager>;
}) => {
const pageOperationsRenderer = useCallback(
(collection: Collection) => {
return (
<CollectionOperationCell
info={info}
collection={collection}
setting={setting}
config={config}
/>
);
},
[config, info, setting]
);
return pageOperationsRenderer;
};
export const VirtualizedCollectionList = ({
collections,
collectionMetas,
setHideHeaderCreateNewCollection,
node,
handleCreateCollection,
config,
}: {
collections: Collection[];
collectionMetas: CollectionMeta[];
config: AllPageListConfig;
node: ReactElement | null;
handleCreateCollection: () => void;
setHideHeaderCreateNewCollection: (hide: boolean) => void;
}) => {
const listRef = useRef<ItemListHandle>(null);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [selectedCollectionIds, setSelectedCollectionIds] = useState<string[]>(
[]
);
const setting = useCollectionManager(collectionsCRUDAtom);
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const info = useDeleteCollectionInfo();
const collectionOperations = useCollectionOperationsRenderer({
info,
setting,
config,
});
const filteredSelectedCollectionIds = useMemo(() => {
const ids = collections.map(collection => collection.id);
return selectedCollectionIds.filter(id => ids.includes(id));
}, [collections, selectedCollectionIds]);
const hideFloatingToolbar = useCallback(() => {
listRef.current?.toggleSelectable();
}, []);
const collectionOperationRenderer = useCallback(
(item: ListItem) => {
const collection = item as CollectionMeta;
return collectionOperations(collection);
},
[collectionOperations]
);
const collectionHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={collectionHeaderColsDef} />;
}, []);
const collectionItemRenderer = useCallback((item: ListItem) => {
return <CollectionListItemRenderer {...item} />;
}, []);
const handleDelete = useCallback(() => {
return setting.deleteCollection(info, ...selectedCollectionIds);
}, [setting, info, selectedCollectionIds]);
return (
<>
<VirtualizedList
ref={listRef}
selectable="toggle"
draggable={false}
groupBy={false}
atTopThreshold={80}
atTopStateChange={setHideHeaderCreateNewCollection}
onSelectionActiveChange={setShowFloatingToolbar}
heading={
<CollectionListHeader node={node} onCreate={handleCreateCollection} />
}
selectedIds={filteredSelectedCollectionIds}
onSelectedIdsChange={setSelectedCollectionIds}
items={collectionMetas}
itemRenderer={collectionItemRenderer}
rowAsLink
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
operationsRenderer={collectionOperationRenderer}
headerRenderer={collectionHeaderRenderer}
/>
<ListFloatingToolbar
open={showFloatingToolbar && selectedCollectionIds.length > 0}
content={
<Trans
i18nKey="com.affine.collection.toolbar.selected"
count={selectedCollectionIds.length}
>
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
{{ count: selectedCollectionIds.length } as any}
</div>
selected
</Trans>
}
onClose={hideFloatingToolbar}
onDelete={handleDelete}
/>
</>
);
};

View File

@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
export const floatingToolbar = style({
position: 'absolute',
bottom: 26,
width: '100%',
zIndex: 1,
});

View File

@ -0,0 +1,31 @@
import { CloseIcon, DeleteIcon } from '@blocksuite/icons';
import type { ReactNode } from 'react';
import { FloatingToolbar } from './floating-toolbar';
import * as styles from './list-floating-toolbar.css';
export const ListFloatingToolbar = ({
content,
onClose,
open,
onDelete,
}: {
open: boolean;
content: ReactNode;
onClose: () => void;
onDelete: () => void;
}) => {
return (
<FloatingToolbar className={styles.floatingToolbar} open={open}>
<FloatingToolbar.Item>{content}</FloatingToolbar.Item>
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
<FloatingToolbar.Separator />
<FloatingToolbar.Button
onClick={onDelete}
icon={<DeleteIcon />}
type="danger"
data-testid="list-toolbar-delete"
/>
</FloatingToolbar>
);
};

View File

@ -0,0 +1,30 @@
import { style } from '@vanilla-extract/css';
export const headerCell = style({
padding: '0 8px',
userSelect: 'none',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
selectors: {
'&[data-sorting], &:hover': {
color: 'var(--affine-text-primary-color)',
},
'&[data-sortable]': {
cursor: 'pointer',
},
'&:not(:last-child)': {
borderRight: '1px solid var(--affine-hover-color-filled)',
},
},
display: 'flex',
alignItems: 'center',
columnGap: '4px',
position: 'relative',
whiteSpace: 'nowrap',
});
export const headerCellSortIcon = style({
display: 'inline-flex',
fontSize: 14,
color: 'var(--affine-icon-color)',
});

View File

@ -0,0 +1,54 @@
import { SortDownIcon, SortUpIcon } from '@blocksuite/icons';
import { useCallback } from 'react';
import type { ColWrapperProps, ListItem } from '../types';
import { ColWrapper } from '../utils';
import * as styles from './list-header-cell.css';
type HeaderCellProps = ColWrapperProps & {
sortKey: keyof ListItem;
sortable?: boolean;
order?: 'asc' | 'desc';
sorting?: boolean;
onSort?: (sortable?: boolean, sortKey?: keyof ListItem) => void;
};
export const ListHeaderCell = ({
sortKey,
sortable,
order,
sorting,
onSort,
alignment,
flex,
style,
hideInSmallContainer,
children,
}: HeaderCellProps) => {
const handleClick = useCallback(() => {
if (sortable) {
onSort?.(sortable, sortKey);
}
}, [sortable, sortKey, onSort]);
return (
<ColWrapper
flex={flex}
alignment={alignment}
onClick={handleClick}
className={styles.headerCell}
data-sortable={sortable ? true : undefined}
data-sorting={sorting ? true : undefined}
style={style}
role="columnheader"
hideInSmallContainer={hideInSmallContainer}
>
{children}
{sorting ? (
<div className={styles.headerCellSortIcon}>
{order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
</div>
) : null}
</ColWrapper>
);
};

View File

@ -0,0 +1,5 @@
export * from './page-list-header';
export * from './page-list-item';
export * from './page-list-new-page-button';
export * from './page-tags';
export * from './virtualized-page-list';

View File

@ -0,0 +1,71 @@
import { style } from '@vanilla-extract/css';
export const docListHeader = style({
height: 100,
alignItems: 'center',
padding: '48px 16px 20px 24px',
overflow: 'hidden',
display: 'flex',
justifyContent: 'space-between',
background: 'var(--affine-background-primary-color)',
});
export const docListHeaderTitle = style({
fontSize: 'var(--affine-font-h-5)',
fontWeight: 500,
color: 'var(--affine-text-secondary-color)',
display: 'flex',
alignItems: 'center',
gap: 8,
height: '28px',
});
export const titleIcon = style({
color: 'var(--affine-icon-color)',
display: 'inline-flex',
alignItems: 'center',
});
export const titleCollectionName = style({
color: 'var(--affine-text-primary-color)',
});
export const addPageButton = style({
padding: '6px 10px',
borderRadius: '8px',
background: 'var(--affine-background-primary-color)',
fontSize: 'var(--affine-font-sm)',
fontWeight: 600,
height: '32px',
});
export const tagSticky = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '1px 8px',
color: 'var(--affine-text-primary-color)',
fontSize: 'var(--affine-font-xs)',
borderRadius: '10px',
columnGap: '4px',
border: '1px solid var(--affine-border-color)',
background: 'var(--affine-background-primary-color)',
maxWidth: '30vw',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: '22px',
lineHeight: '1.67em',
});
export const tagIndicator = style({
width: '8px',
height: '8px',
borderRadius: '50%',
flexShrink: 0,
});
export const tagLabel = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});

View File

@ -0,0 +1,181 @@
import { Button } from '@affine/component';
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import type { Collection, Tag } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ViewLayersIcon } from '@blocksuite/icons';
import { nanoid } from 'nanoid';
import { useCallback, useMemo } from 'react';
import { createTagFilter } from '../filter/utils';
import {
createEmptyCollection,
useCollectionManager,
} from '../use-collection-manager';
import { tagColorMap } from '../utils';
import type { AllPageListConfig } from '../view/edit-collection/edit-collection';
import {
useEditCollection,
useEditCollectionName,
} from '../view/use-edit-collection';
import * as styles from './page-list-header.css';
import { PageListNewPageButton } from './page-list-new-page-button';
export const PageListHeader = ({ workspaceId }: { workspaceId: string }) => {
const t = useAFFiNEI18N();
const setting = useCollectionManager(collectionsCRUDAtom);
const { jumpToCollections } = useNavigateHelper();
const handleJumpToCollections = useCallback(() => {
jumpToCollections(workspaceId);
}, [jumpToCollections, workspaceId]);
const title = useMemo(() => {
if (setting.isDefault) {
return t['com.affine.all-pages.header']();
}
return (
<>
<div style={{ cursor: 'pointer' }} onClick={handleJumpToCollections}>
{t['com.affine.collections.header']()} /
</div>
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>
{setting.currentCollection.name}
</div>
</>
);
}, [
handleJumpToCollections,
setting.currentCollection.name,
setting.isDefault,
t,
]);
return (
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>{title}</div>
<PageListNewPageButton testId="new-page-button-trigger">
{t['New Page']()}
</PageListNewPageButton>
</div>
);
};
export const CollectionPageListHeader = ({
collection,
workspaceId,
config,
}: {
config: AllPageListConfig;
collection: Collection;
workspaceId: string;
}) => {
const t = useAFFiNEI18N();
const setting = useCollectionManager(collectionsCRUDAtom);
const { jumpToCollections } = useNavigateHelper();
const handleJumpToCollections = useCallback(() => {
jumpToCollections(workspaceId);
}, [jumpToCollections, workspaceId]);
const { updateCollection } = useCollectionManager(collectionsCRUDAtom);
const { node, open } = useEditCollection(config);
const handleAddPage = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'page');
updateCollection(ret);
}, [collection, open, updateCollection]);
return (
<>
{node}
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div style={{ cursor: 'pointer' }} onClick={handleJumpToCollections}>
{t['com.affine.collections.header']()} /
</div>
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>
{setting.currentCollection.name}
</div>
</div>
<Button className={styles.addPageButton} onClick={handleAddPage}>
{t['com.affine.collection.addPages']()}
</Button>
</div>
</>
);
};
export const TagPageListHeader = ({
tag,
workspaceId,
}: {
tag: Tag;
workspaceId: string;
}) => {
const t = useAFFiNEI18N();
const { jumpToTags, jumpToCollection } = useNavigateHelper();
const setting = useCollectionManager(collectionsCRUDAtom);
const { open, node } = useEditCollectionName({
title: t['com.affine.editCollection.saveCollection'](),
showTips: true,
});
const handleJumpToTags = useCallback(() => {
jumpToTags(workspaceId);
}, [jumpToTags, workspaceId]);
const saveToCollection = useCallback(
(collection: Collection) => {
setting.createCollection({
...collection,
filterList: [createTagFilter(tag.id)],
});
jumpToCollection(workspaceId, collection.id);
},
[setting, tag.id, jumpToCollection, workspaceId]
);
const handleClick = useCallback(() => {
open('')
.then(name => {
return saveToCollection(createEmptyCollection(nanoid(), { name }));
})
.catch(err => {
console.error(err);
});
}, [open, saveToCollection]);
return (
<>
{node}
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div
style={{ cursor: 'pointer', lineHeight: '1.4em' }}
onClick={handleJumpToTags}
>
{t['Tags']()} /
</div>
<div className={styles.tagSticky}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColorMap(tag.color),
}}
/>
<div className={styles.tagLabel}>{tag.value}</div>
</div>
</div>
<Button className={styles.addPageButton} onClick={handleClick}>
{t['com.affine.editCollection.saveCollection']()}
</Button>
</div>
</>
);
};

View File

@ -0,0 +1,181 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
color: 'var(--affine-text-primary-color)',
height: '54px', // 42 + 12
flexShrink: 0,
width: '100%',
alignItems: 'stretch',
transition: 'background-color 0.2s, opacity 0.2s',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
overflow: 'hidden',
cursor: 'default',
willChange: 'opacity',
selectors: {
'&[data-clickable=true]': {
cursor: 'pointer',
},
},
});
export const dragOverlay = style({
display: 'flex',
alignItems: 'center',
zIndex: 1001,
cursor: 'grabbing',
maxWidth: '360px',
transition: 'transform 0.2s',
willChange: 'transform',
selectors: {
'&[data-over=true]': {
transform: 'scale(0.8)',
},
},
});
export const dragPageItemOverlay = style({
height: '54px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
background: 'var(--affine-hover-color-filled)',
boxShadow: 'var(--affine-menu-shadow)',
maxWidth: '360px',
minWidth: '260px',
});
export const dndCell = style({
position: 'relative',
marginLeft: -8,
height: '100%',
outline: 'none',
paddingLeft: 8,
});
globalStyle(`[data-draggable=true] ${dndCell}:before`, {
content: '""',
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
left: 0,
width: 4,
height: 4,
transition: 'height 0.2s, opacity 0.2s',
backgroundColor: 'var(--affine-placeholder-color)',
borderRadius: '2px',
opacity: 0,
willChange: 'height, opacity',
});
globalStyle(`[data-draggable=true] ${dndCell}:hover:before`, {
height: 12,
opacity: 1,
});
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}`, {
opacity: 0.5,
});
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}:before`, {
height: 32,
width: 2,
opacity: 1,
});
// todo: remove global style
globalStyle(`${root} > :first-child`, {
paddingLeft: '16px',
});
globalStyle(`${root} > :last-child`, {
paddingRight: '8px',
});
export const titleIconsWrapper = style({
padding: '0 5px',
display: 'flex',
alignItems: 'center',
gap: '10px',
});
export const selectionCell = style({
display: 'flex',
alignItems: 'center',
flexShrink: 0,
fontSize: 'var(--affine-font-h-3)',
});
export const titleCell = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
padding: '0 16px',
maxWidth: 'calc(100% - 64px)',
flex: 1,
whiteSpace: 'nowrap',
});
export const titleCellMain = style({
overflow: 'hidden',
fontSize: 'var(--affine-font-sm)',
fontWeight: 600,
whiteSpace: 'nowrap',
flex: 1,
textOverflow: 'ellipsis',
alignSelf: 'stretch',
});
export const titleCellPreview = style({
overflow: 'hidden',
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
flex: 1,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
alignSelf: 'stretch',
});
export const iconCell = style({
display: 'flex',
alignItems: 'center',
fontSize: 'var(--affine-font-h-3)',
color: 'var(--affine-icon-color)',
flexShrink: 0,
});
export const tagsCell = style({
display: 'flex',
alignItems: 'center',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
padding: '0 8px',
height: '60px',
width: '100%',
});
export const dateCell = style({
display: 'flex',
alignItems: 'center',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
flexShrink: 0,
flexWrap: 'nowrap',
padding: '0 8px',
});
export const actionsCellWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
flexShrink: 0,
});
export const operationsCell = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
columnGap: '6px',
flexShrink: 0,
});

View File

@ -4,12 +4,12 @@ import { useDraggable } from '@dnd-kit/core';
import { type PropsWithChildren, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import type { DraggableTitleCellData, PageListItemProps } from '../types';
import { ColWrapper, formatDate, stopPropagation } from '../utils';
import * as styles from './page-list-item.css';
import { PageTags } from './page-tags';
import type { DraggableTitleCellData, PageListItemProps } from './types';
import { ColWrapper, formatDate, stopPropagation } from './utils';
const PageListTitleCell = ({
const ListTitleCell = ({
title,
preview,
}: Pick<PageListItemProps, 'title' | 'preview'>) => {
@ -34,7 +34,7 @@ const PageListTitleCell = ({
);
};
const PageListIconCell = ({ icon }: Pick<PageListItemProps, 'icon'>) => {
const ListIconCell = ({ icon }: Pick<PageListItemProps, 'icon'>) => {
return (
<div data-testid="page-list-item-icon" className={styles.iconCell}>
{icon}
@ -128,9 +128,9 @@ export const PageListItem = (props: PageListItemProps) => {
selectable={props.selectable}
selected={props.selected}
/>
<PageListIconCell icon={props.icon} />
<ListIconCell icon={props.icon} />
</div>
<PageListTitleCell title={props.title} preview={props.preview} />
<ListTitleCell title={props.title} preview={props.preview} />
</div>
);
}, [
@ -174,9 +174,9 @@ export const PageListItem = (props: PageListItemProps) => {
selectable={props.selectable}
selected={props.selected}
/>
<PageListIconCell icon={props.icon} />
<ListIconCell icon={props.icon} />
</div>
<PageListTitleCell title={props.title} preview={props.preview} />
<ListTitleCell title={props.title} preview={props.preview} />
</ColWrapper>
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
<PageTagsCell tags={props.tags} />

View File

@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const newPageButtonLabel = style({
display: 'flex',
alignItems: 'center',
});

View File

@ -0,0 +1,35 @@
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { useAtomValue } from 'jotai';
import type { PropsWithChildren } from 'react';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import { NewPageButton } from '../components/new-page-button';
import * as styles from './page-list-new-page-button.css';
export const PageListNewPageButton = ({
className,
children,
size,
testId,
}: PropsWithChildren<{
className?: string;
size?: 'small' | 'default';
testId?: string;
}>) => {
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const { importFile, createEdgeless, createPage } = usePageHelper(
currentWorkspace.blockSuiteWorkspace
);
return (
<div className={className} data-testid={testId}>
<NewPageButton
size={size}
importFile={importFile}
createNewEdgeless={createEdgeless}
createNewPage={createPage}
>
<div className={styles.newPageButtonLabel}>{children}</div>
</NewPageButton>
</div>
);
};

View File

@ -5,8 +5,8 @@ import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useMemo } from 'react';
import { stopPropagation, tagColorMap } from '../utils';
import * as styles from './page-tags.css';
import { stopPropagation } from './utils';
export interface PageTagsProps {
tags: Tag[];
@ -22,24 +22,7 @@ interface TagItemProps {
style?: React.CSSProperties;
}
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
const tagColorMap = (color: string) => {
const mapping: Record<string, string> = {
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
};
return mapping[color] || color;
};
const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
export const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
return (
<div
data-testid="page-tag"

View File

@ -0,0 +1,210 @@
import { toast } from '@affine/component';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import type { Collection } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageMeta, Tag } from '@blocksuite/store';
import { useAtomValue } from 'jotai';
import { useCallback, useMemo, useRef, useState } from 'react';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { pageHeaderColsDef } from '../header-col-def';
import { PageOperationCell } from '../operation-cell';
import { PageListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import type { ItemListHandle, ListItem } from '../types';
import { useFilteredPageMetas } from '../use-filtered-page-metas';
import type { AllPageListConfig } from '../view/edit-collection/edit-collection';
import { VirtualizedList } from '../virtualized-list';
import {
CollectionPageListHeader,
PageListHeader,
TagPageListHeader,
} from './page-list-header';
const usePageOperationsRenderer = () => {
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const { setTrashModal } = useTrashModalHelper(
currentWorkspace.blockSuiteWorkspace
);
const { toggleFavorite } = useBlockSuiteMetaHelper(
currentWorkspace.blockSuiteWorkspace
);
const t = useAFFiNEI18N();
const pageOperationsRenderer = useCallback(
(page: PageMeta) => {
const onDisablePublicSharing = () => {
toast('Successfully disabled', {
portal: document.body,
});
};
return (
<PageOperationCell
favorite={!!page.favorite}
isPublic={!!page.isPublic}
onDisablePublicSharing={onDisablePublicSharing}
link={`/workspace/${currentWorkspace.id}/${page.id}`}
onRemoveToTrash={() =>
setTrashModal({
open: true,
pageIds: [page.id],
pageTitles: [page.title],
})
}
onToggleFavoritePage={() => {
const status = page.favorite;
toggleFavorite(page.id);
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}}
/>
);
},
[currentWorkspace.id, setTrashModal, t, toggleFavorite]
);
return pageOperationsRenderer;
};
export const VirtualizedPageList = ({
tag,
collection,
config,
listItem,
setHideHeaderCreateNewPage,
}: {
tag?: Tag;
collection?: Collection;
config?: AllPageListConfig;
listItem?: PageMeta[];
setHideHeaderCreateNewPage: (hide: boolean) => void;
}) => {
const listRef = useRef<ItemListHandle>(null);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const pageOperations = usePageOperationsRenderer();
const { isPreferredEdgeless } = usePageHelper(
currentWorkspace.blockSuiteWorkspace
);
const filteredPageMetas = useFilteredPageMetas(
'all',
pageMetas,
currentWorkspace.blockSuiteWorkspace
);
const pageMetasToRender = useMemo(() => {
if (listItem) {
return listItem;
}
return filteredPageMetas;
}, [filteredPageMetas, listItem]);
const filteredSelectedPageIds = useMemo(() => {
const ids = pageMetasToRender.map(page => page.id);
return selectedPageIds.filter(id => ids.includes(id));
}, [pageMetasToRender, selectedPageIds]);
const hideFloatingToolbar = useCallback(() => {
listRef.current?.toggleSelectable();
}, []);
const pageOperationRenderer = useCallback(
(item: ListItem) => {
const page = item as PageMeta;
return pageOperations(page);
},
[pageOperations]
);
const pageHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={pageHeaderColsDef} />;
}, []);
const pageItemRenderer = useCallback((item: ListItem) => {
return <PageListItemRenderer {...item} />;
}, []);
const heading = useMemo(() => {
if (tag) {
return <TagPageListHeader workspaceId={currentWorkspace.id} tag={tag} />;
}
if (collection && config) {
return (
<CollectionPageListHeader
workspaceId={currentWorkspace.id}
collection={collection}
config={config}
/>
);
}
return <PageListHeader workspaceId={currentWorkspace.id} />;
}, [collection, config, currentWorkspace.id, tag]);
const { setTrashModal } = useTrashModalHelper(
currentWorkspace.blockSuiteWorkspace
);
const handleMultiDelete = useCallback(() => {
const pageNameMapping = Object.fromEntries(
pageMetas.map(meta => [meta.id, meta.title])
);
const pageNames = filteredSelectedPageIds.map(
id => pageNameMapping[id] ?? ''
);
setTrashModal({
open: true,
pageIds: filteredSelectedPageIds,
pageTitles: pageNames,
});
hideFloatingToolbar();
}, [filteredSelectedPageIds, hideFloatingToolbar, pageMetas, setTrashModal]);
return (
<>
<VirtualizedList
ref={listRef}
selectable="toggle"
draggable
atTopThreshold={80}
atTopStateChange={setHideHeaderCreateNewPage}
onSelectionActiveChange={setShowFloatingToolbar}
heading={heading}
selectedIds={filteredSelectedPageIds}
onSelectedIdsChange={setSelectedPageIds}
items={pageMetasToRender}
rowAsLink
isPreferredEdgeless={isPreferredEdgeless}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
operationsRenderer={pageOperationRenderer}
itemRenderer={pageItemRenderer}
headerRenderer={pageHeaderRenderer}
/>
<ListFloatingToolbar
open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
onDelete={handleMultiDelete}
onClose={hideFloatingToolbar}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={filteredSelectedPageIds.length}
>
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
{{ count: filteredSelectedPageIds.length } as any}
</div>
selected
</Trans>
}
/>
</>
);
};

View File

@ -0,0 +1,68 @@
import { Trans } from '@affine/i18n';
import { ListHeaderTitleCell } from './page-header';
import type { HeaderColDef } from './types';
export const pageHeaderColsDef: HeaderColDef[] = [
{
key: 'title',
content: <ListHeaderTitleCell />,
flex: 6,
alignment: 'start',
sortable: true,
},
{
key: 'tags',
content: <Trans i18nKey="Tags" />,
flex: 3,
alignment: 'end',
},
{
key: 'createDate',
content: <Trans i18nKey="Created" />,
flex: 1,
sortable: true,
alignment: 'end',
hideInSmallContainer: true,
},
{
key: 'updatedDate',
content: <Trans i18nKey="Updated" />,
flex: 1,
sortable: true,
alignment: 'end',
hideInSmallContainer: true,
},
{
key: 'actions',
content: '',
flex: 1,
alignment: 'end',
},
];
export const collectionHeaderColsDef: HeaderColDef[] = [
{
key: 'title',
content: <ListHeaderTitleCell />,
flex: 9,
alignment: 'start',
sortable: true,
},
];
export const tagHeaderColsDef: HeaderColDef[] = [
{
key: 'title',
content: <ListHeaderTitleCell />,
flex: 8,
alignment: 'start',
sortable: true,
},
{
key: 'actions',
content: '',
flex: 1,
alignment: 'end',
},
];

View File

@ -1,14 +1,22 @@
export * from './collections';
export * from './components/favorite-tag';
export * from './components/floating-toobar';
export * from './components/new-page-buttton';
export * from './components/floating-toolbar';
export * from './components/new-page-button';
export * from './docs';
export * from './docs/page-list-item';
export * from './docs/page-tags';
export * from './filter';
export * from './header-col-def';
export * from './list';
export * from './operation-cell';
export * from './operation-menu-items';
export * from './page-list';
export * from './page-list-item';
export * from './page-tags';
export * from './page-group';
export * from './page-header';
export * from './tags';
export * from './types';
export * from './use-collection-manager';
export * from './use-filtered-page-metas';
export * from './use-tag-metas';
export * from './utils';
export * from './view';
export * from './virtualized-page-list';
export * from './virtualized-list';

View File

@ -1,72 +1,77 @@
import { Trans } from '@affine/i18n';
import type { PageMeta } from '@blocksuite/store';
import type { PageGroupDefinition, PageGroupProps } from './types';
import type { ItemGroupDefinition, ItemGroupProps, ListItem } from './types';
import { type DateKey } from './types';
import { betweenDaysAgo, withinDaysAgo } from './utils';
// todo: optimize date matchers
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
const getDateGroupDefinitions = <T extends ListItem>(
key: DateKey
): ItemGroupDefinition<T>[] => [
{
id: 'today',
label: <Trans i18nKey="com.affine.today" />,
match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1),
match: item =>
withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1),
},
{
id: 'yesterday',
label: <Trans i18nKey="com.affine.yesterday" />,
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2),
match: item =>
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 1, 2),
},
{
id: 'last7Days',
label: <Trans i18nKey="com.affine.last7Days" />,
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7),
match: item =>
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 2, 7),
},
{
id: 'last30Days',
label: <Trans i18nKey="com.affine.last30Days" />,
match: item =>
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
betweenDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 7, 30),
},
{
id: 'moreThan30Days',
label: <Trans i18nKey="com.affine.moreThan30Days" />,
match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30),
match: item =>
!withinDaysAgo(new Date(item[key] ?? item.createDate ?? ''), 30),
},
];
const pageGroupDefinitions = {
const itemGroupDefinitions = {
createDate: getDateGroupDefinitions('createDate'),
updatedDate: getDateGroupDefinitions('updatedDate'),
// add more here later
// todo: some page group definitions maybe dynamic
};
export function pagesToPageGroups(
pages: PageMeta[],
export function itemsToItemGroups<T extends ListItem>(
items: T[],
key?: DateKey
): PageGroupProps[] {
): ItemGroupProps<T>[] {
if (!key) {
return [
{
id: 'all',
items: pages,
allItems: pages,
items: items,
allItems: items,
},
];
}
// assume pages are already sorted, we will use the page order to determine the group order
const groupDefs = pageGroupDefinitions[key];
const groups: PageGroupProps[] = [];
const groupDefs = itemGroupDefinitions[key];
const groups: ItemGroupProps<T>[] = [];
for (const page of pages) {
for (const item of items) {
// for a single page, there could be multiple groups that it belongs to
const matchedGroups = groupDefs.filter(def => def.match(page));
const matchedGroups = groupDefs.filter(def => def.match(item));
for (const groupDef of matchedGroups) {
const group = groups.find(g => g.id === groupDef.id);
if (group) {
group.items.push(page);
group.items.push(item);
} else {
const label =
typeof groupDef.label === 'function'
@ -75,8 +80,8 @@ export function pagesToPageGroups(
groups.push({
id: groupDef.id,
label: label,
items: [page],
allItems: pages,
items: [item],
allItems: items,
});
}
}

View File

@ -0,0 +1,69 @@
import { createContainer, style } from '@vanilla-extract/css';
import * as itemStyles from './docs/page-list-item.css';
export const listRootContainer = createContainer('list-root-container');
export const pageListScrollContainer = style({
width: '100%',
flex: 1,
});
export const root = style({
width: '100%',
maxWidth: '100%',
containerName: listRootContainer,
containerType: 'inline-size',
background: 'var(--affine-background-primary-color)',
});
export const groupsContainer = style({
display: 'flex',
flexDirection: 'column',
rowGap: '16px',
});
export const heading = style({});
export const colWrapper = style({
display: 'flex',
alignItems: 'center',
flexShrink: 0,
overflow: 'hidden',
});
export const hideInSmallContainer = style({
'@container': {
[`${listRootContainer} (max-width: 800px)`]: {
selectors: {
'&[data-hide-item="true"]': {
display: 'none',
},
},
},
},
});
export const favoriteCell = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
flexShrink: 0,
opacity: 0,
selectors: {
[`&[data-favorite], ${itemStyles.root}:hover &`]: {
opacity: 1,
},
},
});
export const clearLinkStyle = style({
color: 'inherit',
textDecoration: 'none',
':visited': {
color: 'inherit',
},
':active': {
color: 'inherit',
},
});

View File

@ -12,41 +12,42 @@ import {
useRef,
} from 'react';
import { PageGroup } from './page-group';
import { PageListTableHeader } from './page-header';
import * as styles from './page-list.css';
import { pageHeaderColsDef } from './header-col-def';
import * as styles from './list.css';
import { ItemGroup } from './page-group';
import { ListTableHeader } from './page-header';
import {
pageGroupsAtom,
pageListPropsAtom,
PageListProvider,
groupsAtom,
listPropsAtom,
ListProvider,
selectionStateAtom,
useAtom,
useAtomValue,
useSetAtom,
} from './scoped-atoms';
import type { PageListHandle, PageListProps } from './types';
import type { ItemListHandle, ListItem, ListProps } from './types';
/**
* Given a list of pages, render a list of pages
*/
export const PageList = forwardRef<PageListHandle, PageListProps>(
function PageList(props, ref) {
export const List = forwardRef<ItemListHandle, ListProps<ListItem>>(
function List(props, ref) {
return (
// push pageListProps to the atom so that downstream components can consume it
// this makes sure pageListPropsAtom is always populated
// @ts-expect-error fix type issues later
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
<PageListInnerWrapper {...props} handleRef={ref}>
<PageListInner {...props} />
</PageListInnerWrapper>
</PageListProvider>
<ListProvider initialValues={[[listPropsAtom, props]]}>
<ListInnerWrapper {...props} handleRef={ref}>
<ListInner {...props} />
</ListInnerWrapper>
</ListProvider>
);
}
);
// when pressing ESC or double clicking outside of the page list, close the selection mode
// todo: use jotai-effect instead but it seems it does not work with jotai-scope?
const usePageSelectionStateEffect = () => {
const useItemSelectionStateEffect = () => {
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
useEffect(() => {
if (
@ -96,23 +97,22 @@ const usePageSelectionStateEffect = () => {
]);
};
export const PageListInnerWrapper = memo(
export const ListInnerWrapper = memo(
({
handleRef,
children,
onSelectionActiveChange,
...props
}: PropsWithChildren<
PageListProps & { handleRef: ForwardedRef<PageListHandle> }
ListProps<ListItem> & { handleRef: ForwardedRef<ItemListHandle> }
>) => {
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
const [selectionState, setPageListSelectionState] =
useAtom(selectionStateAtom);
usePageSelectionStateEffect();
const setListPropsAtom = useSetAtom(listPropsAtom);
const [selectionState, setListSelectionState] = useAtom(selectionStateAtom);
useItemSelectionStateEffect();
useEffect(() => {
setPageListPropsAtom(props);
}, [props, setPageListPropsAtom]);
setListPropsAtom(props);
}, [props, setListPropsAtom]);
useEffect(() => {
onSelectionActiveChange?.(!!selectionState.selectionActive);
@ -123,41 +123,42 @@ export const PageListInnerWrapper = memo(
() => {
return {
toggleSelectable: () => {
setPageListSelectionState(false);
setListSelectionState(false);
},
};
},
[setPageListSelectionState]
[setListSelectionState]
);
return children;
}
);
PageListInnerWrapper.displayName = 'PageListInnerWrapper';
ListInnerWrapper.displayName = 'ListInnerWrapper';
const ListInner = (props: ListProps<ListItem>) => {
const groups = useAtomValue(groupsAtom);
const PageListInner = (props: PageListProps) => {
const groups = useAtomValue(pageGroupsAtom);
const hideHeader = props.hideHeader;
return (
<div className={clsx(props.className, styles.root)}>
{!hideHeader ? <PageListTableHeader /> : null}
{!hideHeader ? <ListTableHeader headerCols={pageHeaderColsDef} /> : null}
<div className={styles.groupsContainer}>
{groups.map(group => (
<PageGroup key={group.id} {...group} />
<ItemGroup key={group.id} {...group} />
))}
</div>
</div>
);
};
interface PageListScrollContainerProps {
interface ListScrollContainerProps {
className?: string;
style?: React.CSSProperties;
}
export const PageListScrollContainer = forwardRef<
export const ListScrollContainer = forwardRef<
HTMLDivElement,
PropsWithChildren<PageListScrollContainerProps>
PropsWithChildren<ListScrollContainerProps>
>(({ className, children, style }, ref) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const hasScrollTop = useHasScrollTop(containerRef);
@ -188,4 +189,4 @@ export const PageListScrollContainer = forwardRef<
);
});
PageListScrollContainer.displayName = 'PageListScrollContainer';
ListScrollContainer.displayName = 'ListScrollContainer';

View File

@ -6,24 +6,34 @@ import {
MenuItem,
Tooltip,
} from '@affine/component';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
DeleteIcon,
DeletePermanentlyIcon,
EditIcon,
FavoritedIcon,
FavoriteIcon,
FilterIcon,
MoreVerticalIcon,
OpenInNewIcon,
ResetIcon,
} from '@blocksuite/icons';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
import { FavoriteTag } from './components/favorite-tag';
import * as styles from './list.css';
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
import * as styles from './page-list.css';
import type { useCollectionManager } from './use-collection-manager';
import { ColWrapper, stopPropagationWithoutPrevent } from './utils';
import {
type AllPageListConfig,
useEditCollection,
useEditCollectionName,
} from './view';
export interface OperationCellProps {
export interface PageOperationCellProps {
favorite: boolean;
isPublic: boolean;
link: string;
@ -32,14 +42,14 @@ export interface OperationCellProps {
onDisablePublicSharing: () => void;
}
export const OperationCell = ({
export const PageOperationCell = ({
favorite,
isPublic,
link,
onToggleFavoritePage,
onRemoveToTrash,
onDisablePublicSharing,
}: OperationCellProps) => {
}: PageOperationCellProps) => {
const t = useAFFiNEI18N();
const [openDisableShared, setOpenDisableShared] = useState(false);
const OperationMenu = (
@ -178,3 +188,94 @@ export const TrashOperationCell = ({
</ColWrapper>
);
};
export interface CollectionOperationCellProps {
collection: Collection;
info: DeleteCollectionInfo;
config: AllPageListConfig;
setting: ReturnType<typeof useCollectionManager>;
}
export const CollectionOperationCell = ({
collection,
config,
setting,
info,
}: CollectionOperationCellProps) => {
const t = useAFFiNEI18N();
const { open: openEditCollectionModal, node: editModal } =
useEditCollection(config);
const { open: openEditCollectionNameModal, node: editNameModal } =
useEditCollectionName({
title: t['com.affine.editCollection.renameCollection'](),
});
const handleEditName = useCallback(() => {
// use openRenameModal if it is in the sidebar collection list
openEditCollectionNameModal(collection.name)
.then(name => {
return setting.updateCollection({ ...collection, name });
})
.catch(err => {
console.error(err);
});
}, [collection, openEditCollectionNameModal, setting]);
const handleEdit = useCallback(() => {
openEditCollectionModal(collection)
.then(collection => {
return setting.updateCollection(collection);
})
.catch(err => {
console.error(err);
});
}, [setting, collection, openEditCollectionModal]);
const handleDelete = useCallback(() => {
return setting.deleteCollection(info, collection.id);
}, [setting, info, collection]);
return (
<>
{editModal}
{editNameModal}
<Tooltip content={t['com.affine.collection.menu.rename']()} side="top">
<IconButton onClick={handleEditName}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip content={t['com.affine.collection.menu.edit']()} side="top">
<IconButton onClick={handleEdit}>
<FilterIcon />
</IconButton>
</Tooltip>
<ColWrapper alignment="start">
<Menu
items={
<MenuItem
onClick={handleDelete}
preFix={
<MenuIcon>
<DeleteIcon />
</MenuIcon>
}
type="danger"
>
{t['Delete']()}
</MenuItem>
}
contentOptions={{
align: 'end',
}}
>
<IconButton type="plain">
<MoreVerticalIcon />
</IconButton>
</Menu>
</ColWrapper>
</>
);
};

View File

@ -8,6 +8,7 @@ import {
PageIcon,
TodayIcon,
ToggleCollapseIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import type { PageMeta, Workspace } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible';
@ -15,21 +16,36 @@ import clsx from 'clsx';
import { selectAtom } from 'jotai/utils';
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
import { CollectionListItem } from './collections/collection-list-item';
import { PageListItem } from './docs/page-list-item';
import { PagePreview } from './page-content-preview';
import * as styles from './page-group.css';
import { PageListItem } from './page-list-item';
import {
pageGroupCollapseStateAtom,
pageListPropsAtom,
groupCollapseStateAtom,
listPropsAtom,
selectionStateAtom,
useAtom,
useAtomValue,
} from './scoped-atoms';
import type { PageGroupProps, PageListItemProps, PageListProps } from './types';
import { TagListItem } from './tags/tag-list-item';
import type {
CollectionListItemProps,
CollectionMeta,
ItemGroupProps,
ListItem,
ListProps,
PageListItemProps,
TagListItemProps,
TagMeta,
} from './types';
import { shallowEqual } from './utils';
export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom);
export const ItemGroupHeader = <T extends ListItem>({
id,
items,
label,
}: ItemGroupProps<T>) => {
const [collapseState, setCollapseState] = useAtom(groupCollapseStateAtom);
const collapsed = collapseState[id];
const onExpandedClicked: MouseEventHandler = useCallback(
e => {
@ -42,9 +58,9 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
const selectedItems = useMemo(() => {
const selectedPageIds = selectionState.selectedPageIds ?? [];
return items.filter(item => selectedPageIds.includes(item.id));
}, [items, selectionState.selectedPageIds]);
const selectedIds = selectionState.selectedIds ?? [];
return items.filter(item => selectedIds.includes(item.id));
}, [items, selectionState.selectedIds]);
const allSelected = selectedItems.length === items.length;
@ -53,7 +69,7 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
setSelectionActive(true);
const nonCurrentGroupIds =
selectionState.selectedPageIds?.filter(
selectionState.selectedIds?.filter(
id => !items.map(item => item.id).includes(id)
) ?? [];
@ -61,7 +77,7 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
? nonCurrentGroupIds
: [...nonCurrentGroupIds, ...items.map(item => item.id)];
selectionState.onSelectedPageIdsChange?.(newSelectedPageIds);
selectionState.onSelectedIdsChange?.(newSelectedPageIds);
}, [setSelectionActive, selectionState, allSelected, items]);
const t = useAFFiNEI18N();
@ -103,7 +119,11 @@ export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
) : null;
};
export const PageGroup = ({ id, items, label }: PageGroupProps) => {
export const ItemGroup = <T extends ListItem>({
id,
items,
label,
}: ItemGroupProps<T>) => {
const [collapsed, setCollapsed] = useState(false);
const onExpandedClicked: MouseEventHandler = useCallback(e => {
e.stopPropagation();
@ -112,16 +132,16 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
}, []);
const selectionState = useAtomValue(selectionStateAtom);
const selectedItems = useMemo(() => {
const selectedPageIds = selectionState.selectedPageIds ?? [];
return items.filter(item => selectedPageIds.includes(item.id));
}, [items, selectionState.selectedPageIds]);
const selectedIds = selectionState.selectedIds ?? [];
return items.filter(item => selectedIds.includes(item.id));
}, [items, selectionState.selectedIds]);
const onSelectAll = useCallback(() => {
const nonCurrentGroupIds =
selectionState.selectedPageIds?.filter(
selectionState.selectedIds?.filter(
id => !items.map(item => item.id).includes(id)
) ?? [];
selectionState.onSelectedPageIdsChange?.([
selectionState.onSelectedIdsChange?.([
...nonCurrentGroupIds,
...items.map(item => item.id),
]);
@ -164,7 +184,7 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
<Collapsible.Content className={styles.collapsibleContent}>
<div className={styles.collapsibleContentInner}>
{items.map(item => (
<PageMetaListItemRenderer key={item.id} {...item} />
<PageListItemRenderer key={item.id} {...item} />
))}
</div>
</Collapsible.Content>
@ -177,32 +197,64 @@ const requiredPropNames = [
'blockSuiteWorkspace',
'rowAsLink',
'isPreferredEdgeless',
'pageOperationsRenderer',
'selectedPageIds',
'onSelectedPageIdsChange',
'operationsRenderer',
'selectedIds',
'onSelectedIdsChange',
'draggable',
] as const;
type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
type RequiredProps<T> = Pick<
ListProps<T>,
(typeof requiredPropNames)[number]
> & {
selectable: boolean;
};
const listPropsAtom = selectAtom(
pageListPropsAtom,
const listsPropsAtom = selectAtom(
listPropsAtom,
props => {
return Object.fromEntries(
requiredPropNames.map(name => [name, props[name]])
) as RequiredProps;
) as RequiredProps<ListItem>;
},
shallowEqual
);
export const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
const props = useAtomValue(listPropsAtom);
export const PageListItemRenderer = (item: ListItem) => {
const props = useAtomValue(listsPropsAtom);
const { selectionActive } = useAtomValue(selectionStateAtom);
const page = item as PageMeta;
return (
<PageListItem
{...pageMetaToPageItemProp(pageMeta, {
{...pageMetaToListItemProp(page, {
...props,
selectable: !!selectionActive,
})}
/>
);
};
export const CollectionListItemRenderer = (item: ListItem) => {
const props = useAtomValue(listsPropsAtom);
const { selectionActive } = useAtomValue(selectionStateAtom);
const collection = item as CollectionMeta;
return (
<CollectionListItem
{...collectionMetaToListItemProp(collection, {
...props,
selectable: !!selectionActive,
})}
/>
);
};
export const TagListItemRenderer = (item: ListItem) => {
const props = useAtomValue(listsPropsAtom);
const { selectionActive } = useAtomValue(selectionStateAtom);
const tag = item as TagMeta;
return (
<TagListItem
{...tagMetaToListItemProp(tag, {
...props,
selectable: !!selectionActive,
})}
@ -231,9 +283,9 @@ const UnifiedPageIcon = ({
}: {
id: string;
workspace: Workspace;
isPreferredEdgeless: (id: string) => boolean;
isPreferredEdgeless?: (id: string) => boolean;
}) => {
const isEdgeless = isPreferredEdgeless(id);
const isEdgeless = isPreferredEdgeless ? isPreferredEdgeless(id) : false;
const { isJournal } = useJournalInfoHelper(workspace, id);
if (isJournal) {
return <TodayIcon />;
@ -241,61 +293,132 @@ const UnifiedPageIcon = ({
return isEdgeless ? <EdgelessIcon /> : <PageIcon />;
};
function pageMetaToPageItemProp(
pageMeta: PageMeta,
props: RequiredProps
function pageMetaToListItemProp(
item: PageMeta,
props: RequiredProps<PageMeta>
): PageListItemProps {
const toggleSelection = props.onSelectedPageIdsChange
const toggleSelection = props.onSelectedIdsChange
? () => {
assertExists(props.selectedPageIds);
const prevSelected = props.selectedPageIds.includes(pageMeta.id);
assertExists(props.selectedIds);
const prevSelected = props.selectedIds.includes(item.id);
const shouldAdd = !prevSelected;
const shouldRemove = prevSelected;
if (shouldAdd) {
props.onSelectedPageIdsChange?.([
...props.selectedPageIds,
pageMeta.id,
]);
props.onSelectedIdsChange?.([...props.selectedIds, item.id]);
} else if (shouldRemove) {
props.onSelectedPageIdsChange?.(
props.selectedPageIds.filter(id => id !== pageMeta.id)
props.onSelectedIdsChange?.(
props.selectedIds.filter(id => id !== item.id)
);
}
}
: undefined;
const itemProps: PageListItemProps = {
pageId: pageMeta.id,
title: <PageTitle id={pageMeta.id} workspace={props.blockSuiteWorkspace} />,
pageId: item.id,
title: <PageTitle id={item.id} workspace={props.blockSuiteWorkspace} />,
preview: (
<PagePreview workspace={props.blockSuiteWorkspace} pageId={pageMeta.id} />
<PagePreview workspace={props.blockSuiteWorkspace} pageId={item.id} />
),
createDate: new Date(pageMeta.createDate),
updatedDate: pageMeta.updatedDate
? new Date(pageMeta.updatedDate)
: undefined,
createDate: new Date(item.createDate),
updatedDate: item.updatedDate ? new Date(item.updatedDate) : undefined,
to:
props.rowAsLink && !props.selectable
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
? `/workspace/${props.blockSuiteWorkspace.id}/${item.id}`
: undefined,
onClick: props.selectable ? toggleSelection : undefined,
icon: (
<UnifiedPageIcon
id={pageMeta.id}
id={item.id}
workspace={props.blockSuiteWorkspace}
isPreferredEdgeless={props.isPreferredEdgeless}
/>
),
tags:
pageMeta.tags
item.tags
?.map(id => tagIdToTagOption(id, props.blockSuiteWorkspace))
.filter((v): v is Tag => v != null) ?? [],
operations: props.pageOperationsRenderer?.(pageMeta),
operations: props.operationsRenderer?.(item),
selectable: props.selectable,
selected: props.selectedPageIds?.includes(pageMeta.id),
selected: props.selectedIds?.includes(item.id),
onSelectedChange: toggleSelection,
draggable: props.draggable,
isPublicPage: !!item.isPublic,
};
return itemProps;
}
function collectionMetaToListItemProp(
item: CollectionMeta,
props: RequiredProps<CollectionMeta>
): CollectionListItemProps {
const toggleSelection = props.onSelectedIdsChange
? () => {
assertExists(props.selectedIds);
const prevSelected = props.selectedIds.includes(item.id);
const shouldAdd = !prevSelected;
const shouldRemove = prevSelected;
if (shouldAdd) {
props.onSelectedIdsChange?.([...props.selectedIds, item.id]);
} else if (shouldRemove) {
props.onSelectedIdsChange?.(
props.selectedIds.filter(id => id !== item.id)
);
}
}
: undefined;
const itemProps: CollectionListItemProps = {
collectionId: item.id,
title: item.title,
to:
props.rowAsLink && !props.selectable
? `/workspace/${props.blockSuiteWorkspace.id}/collection/${item.id}`
: undefined,
onClick: props.selectable ? toggleSelection : undefined,
icon: <ViewLayersIcon />,
operations: props.operationsRenderer?.(item),
selectable: props.selectable,
selected: props.selectedIds?.includes(item.id),
onSelectedChange: toggleSelection,
draggable: props.draggable,
};
return itemProps;
}
function tagMetaToListItemProp(
item: TagMeta,
props: RequiredProps<TagMeta>
): TagListItemProps {
const toggleSelection = props.onSelectedIdsChange
? () => {
assertExists(props.selectedIds);
const prevSelected = props.selectedIds.includes(item.id);
const shouldAdd = !prevSelected;
const shouldRemove = prevSelected;
if (shouldAdd) {
props.onSelectedIdsChange?.([...props.selectedIds, item.id]);
} else if (shouldRemove) {
props.onSelectedIdsChange?.(
props.selectedIds.filter(id => id !== item.id)
);
}
}
: undefined;
const itemProps: TagListItemProps = {
tagId: item.id,
title: item.title,
to:
props.rowAsLink && !props.selectable
? `/workspace/${props.blockSuiteWorkspace.id}/tag/${item.id}`
: undefined,
onClick: props.selectable ? toggleSelection : undefined,
color: item.color,
pageCount: item.pageCount,
operations: props.operationsRenderer?.(item),
selectable: props.selectable,
selected: props.selectedIds?.includes(item.id),
onSelectedChange: toggleSelection,
draggable: props.draggable,
isPublicPage: !!pageMeta.isPublic,
};
return itemProps;
}

View File

@ -0,0 +1,40 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const headerTitleCell = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
});
export const tableHeader = style({
display: 'flex',
alignItems: 'center',
padding: '10px 6px 10px 16px',
position: 'sticky',
overflow: 'hidden',
zIndex: 1,
top: 0,
left: 0,
background: 'var(--affine-background-primary-color)',
transition: 'box-shadow 0.2s ease-in-out',
transform: 'translateY(-0.5px)', // fix sticky look through issue
});
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
boxShadow: '0 1px var(--affine-border-color)',
});
export const headerTitleSelectionIconWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
fontSize: '16px',
selectors: {
[`${tableHeader}[data-selectable=toggle] &`]: {
width: 32,
},
[`${tableHeader}[data-selection-active=true] &`]: {
width: 24,
},
},
});

View File

@ -1,84 +1,31 @@
import { Checkbox, type CheckboxProps } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { MultiSelectIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { selectAtom } from 'jotai/utils';
import {
type MouseEventHandler,
type ReactNode,
useCallback,
useMemo,
} from 'react';
import { type MouseEventHandler, useCallback } from 'react';
import * as styles from './page-list.css';
import { ListHeaderCell } from './components/list-header-cell';
import * as styles from './page-header.css';
import {
pageListHandlersAtom,
pageListPropsAtom,
pagesAtom,
itemsAtom,
listHandlersAtom,
listPropsAtom,
selectionStateAtom,
showOperationsAtom,
sorterAtom,
useAtom,
useAtomValue,
} from './scoped-atoms';
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
export const PageListHeaderCell = (props: HeaderCellProps) => {
const [sorter, setSorter] = useAtom(sorterAtom);
const onClick: MouseEventHandler = useCallback(() => {
if (props.sortable && props.sortKey) {
setSorter({
newSortKey: props.sortKey,
});
}
}, [props.sortKey, props.sortable, setSorter]);
const sorting = sorter.key === props.sortKey;
return (
<ColWrapper
flex={props.flex}
alignment={props.alignment}
onClick={onClick}
className={styles.headerCell}
data-sortable={props.sortable ? true : undefined}
data-sorting={sorting ? true : undefined}
style={props.style}
role="columnheader"
hideInSmallContainer={props.hideInSmallContainer}
>
{props.children}
{sorting ? (
<div className={styles.headerCellSortIcon}>
{sorter.order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
</div>
) : null}
</ColWrapper>
);
};
type HeaderColDef = {
key: string;
content: ReactNode;
flex: ColWrapperProps['flex'];
alignment?: ColWrapperProps['alignment'];
sortable?: boolean;
hideInSmallContainer?: boolean;
};
type HeaderCellProps = ColWrapperProps & {
sortKey: keyof PageMeta;
sortable?: boolean;
};
import type { HeaderColDef, ListItem } from './types';
import { stopPropagation } from './utils';
// the checkbox on the header has three states:
// when list selectable = true, the checkbox will be presented
// when internal selection state is not enabled, it is a clickable <ListIcon /> that enables the selection state
// when internal selection state is enabled, it is a checkbox that reflects the selection state
const PageListHeaderCheckbox = () => {
const ListHeaderCheckbox = () => {
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
const pages = useAtomValue(pagesAtom);
const items = useAtomValue(itemsAtom);
const onActivateSelection: MouseEventHandler = useCallback(
e => {
stopPropagation(e);
@ -86,13 +33,13 @@ const PageListHeaderCheckbox = () => {
},
[setSelectionState]
);
const handlers = useAtomValue(pageListHandlersAtom);
const handlers = useAtomValue(listHandlersAtom);
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
(e, checked) => {
stopPropagation(e);
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
handlers.onSelectedIdsChange?.(checked ? items.map(i => i.id) : []);
},
[handlers, pages]
[handlers, items]
);
if (!selectionState.selectable) {
@ -109,11 +56,11 @@ const PageListHeaderCheckbox = () => {
<MultiSelectIcon />
) : (
<Checkbox
checked={selectionState.selectedPageIds?.length === pages.length}
checked={selectionState.selectedIds?.length === items.length}
indeterminate={
selectionState.selectedPageIds &&
selectionState.selectedPageIds.length > 0 &&
selectionState.selectedPageIds.length < pages.length
selectionState.selectedIds &&
selectionState.selectedIds.length > 0 &&
selectionState.selectedIds.length < items.length
}
onChange={onChange}
/>
@ -122,64 +69,37 @@ const PageListHeaderCheckbox = () => {
);
};
const PageListHeaderTitleCell = () => {
export const ListHeaderTitleCell = () => {
const t = useAFFiNEI18N();
return (
<div className={styles.headerTitleCell}>
<PageListHeaderCheckbox />
<ListHeaderCheckbox />
{t['Title']()}
</div>
);
};
const hideHeaderAtom = selectAtom(pageListPropsAtom, props => props.hideHeader);
const hideHeaderAtom = selectAtom(listPropsAtom, props => props.hideHeader);
// the table header for page list
export const PageListTableHeader = () => {
const t = useAFFiNEI18N();
const showOperations = useAtomValue(showOperationsAtom);
export const ListTableHeader = ({
headerCols,
}: {
headerCols: HeaderColDef[];
}) => {
const [sorter, setSorter] = useAtom(sorterAtom);
const hideHeader = useAtomValue(hideHeaderAtom);
const selectionState = useAtomValue(selectionStateAtom);
const headerCols = useMemo(() => {
const cols: (HeaderColDef | boolean)[] = [
{
key: 'title',
content: <PageListHeaderTitleCell />,
flex: 6,
alignment: 'start',
sortable: true,
},
{
key: 'tags',
content: t['Tags'](),
flex: 3,
alignment: 'end',
},
{
key: 'createDate',
content: t['Created'](),
flex: 1,
sortable: true,
alignment: 'end',
hideInSmallContainer: true,
},
{
key: 'updatedDate',
content: t['Updated'](),
flex: 1,
sortable: true,
alignment: 'end',
hideInSmallContainer: true,
},
showOperations && {
key: 'actions',
content: '',
flex: 1,
alignment: 'end',
},
];
return cols.filter((def): def is HeaderColDef => !!def);
}, [t, showOperations]);
const onSort = useCallback(
(sortable?: boolean, sortKey?: keyof ListItem) => {
if (sortable && sortKey) {
setSorter({
newSortKey: sortKey,
});
}
},
[setSorter]
);
if (hideHeader) {
return false;
@ -193,17 +113,20 @@ export const PageListTableHeader = () => {
>
{headerCols.map(col => {
return (
<PageListHeaderCell
<ListHeaderCell
flex={col.flex}
alignment={col.alignment}
key={col.key}
sortKey={col.key as keyof PageMeta}
sortKey={col.key as keyof ListItem}
sortable={col.sortable}
sorting={sorter.key === col.key}
order={sorter.order}
onSort={onSort}
style={{ overflow: 'visible' }}
hideInSmallContainer={col.hideInSmallContainer}
>
{col.content}
</PageListHeaderCell>
</ListHeaderCell>
);
})}
</div>

View File

@ -1,133 +0,0 @@
import { createContainer, globalStyle, style } from '@vanilla-extract/css';
import * as itemStyles from './page-list-item.css';
export const listRootContainer = createContainer('list-root-container');
export const pageListScrollContainer = style({
width: '100%',
flex: 1,
});
export const root = style({
width: '100%',
maxWidth: '100%',
containerName: listRootContainer,
containerType: 'inline-size',
background: 'var(--affine-background-primary-color)',
});
export const groupsContainer = style({
display: 'flex',
flexDirection: 'column',
rowGap: '16px',
});
export const heading = style({});
export const tableHeader = style({
display: 'flex',
alignItems: 'center',
padding: '10px 6px 10px 16px',
position: 'sticky',
overflow: 'hidden',
zIndex: 1,
top: 0,
left: 0,
background: 'var(--affine-background-primary-color)',
transition: 'box-shadow 0.2s ease-in-out',
transform: 'translateY(-0.5px)', // fix sticky look through issue
});
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
boxShadow: '0 1px var(--affine-border-color)',
});
export const headerCell = style({
padding: '0 8px',
userSelect: 'none',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
selectors: {
'&[data-sorting], &:hover': {
color: 'var(--affine-text-primary-color)',
},
'&[data-sortable]': {
cursor: 'pointer',
},
'&:not(:last-child)': {
borderRight: '1px solid var(--affine-hover-color-filled)',
},
},
display: 'flex',
alignItems: 'center',
columnGap: '4px',
position: 'relative',
whiteSpace: 'nowrap',
});
export const headerTitleCell = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
});
export const headerTitleSelectionIconWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
fontSize: '16px',
selectors: {
[`${tableHeader}[data-selectable=toggle] &`]: {
width: 32,
},
[`${tableHeader}[data-selection-active=true] &`]: {
width: 24,
},
},
});
export const headerCellSortIcon = style({
display: 'inline-flex',
fontSize: 14,
color: 'var(--affine-icon-color)',
});
export const colWrapper = style({
display: 'flex',
alignItems: 'center',
flexShrink: 0,
overflow: 'hidden',
});
export const hideInSmallContainer = style({
'@container': {
[`${listRootContainer} (max-width: 800px)`]: {
display: 'none',
},
},
});
export const favoriteCell = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
flexShrink: 0,
opacity: 0,
selectors: {
[`&[data-favorite], ${itemStyles.root}:hover &`]: {
opacity: 1,
},
},
});
export const clearLinkStyle = style({
color: 'inherit',
textDecoration: 'none',
':visited': {
color: 'inherit',
},
':active': {
color: 'inherit',
},
});

View File

@ -1,22 +1,22 @@
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
import type { PageMeta } from '@blocksuite/store';
import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { createIsolation } from 'jotai-scope';
import { pagesToPageGroups } from './pages-to-page-group';
import { itemsToItemGroups } from './items-to-item-group';
import type {
PageListProps,
PageMetaRecord,
VirtualizedPageListProps,
ListItem,
ListProps,
MetaRecord,
VirtualizedListProps,
} from './types';
import { shallowEqual } from './utils';
// for ease of use in the component tree
// note: must use selectAtom to access this atom for efficiency
// @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms
export const pageListPropsAtom = atom<
PageListProps & Partial<VirtualizedPageListProps>
export const listPropsAtom = atom<
ListProps<ListItem> & Partial<VirtualizedListProps<ListItem>>
>();
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
@ -25,13 +25,13 @@ const selectionActiveAtom = atom(false);
export const selectionStateAtom = atom(
get => {
const baseAtom = selectAtom(
pageListPropsAtom,
listPropsAtom,
props => {
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
const { selectable, selectedIds, onSelectedIdsChange } = props;
return {
selectable,
selectedPageIds,
onSelectedPageIdsChange,
selectedIds,
onSelectedIdsChange,
};
},
shallowEqual
@ -53,50 +53,49 @@ export const selectionStateAtom = atom(
// id -> isCollapsed
// maybe reset on page on unmount?
export const pageGroupCollapseStateAtom = atom<Record<string, boolean>>({});
export const groupCollapseStateAtom = atom<Record<string, boolean>>({});
// get handlers from pageListPropsAtom
export const pageListHandlersAtom = selectAtom(
pageListPropsAtom,
export const listHandlersAtom = selectAtom(
listPropsAtom,
props => {
const { onSelectedPageIdsChange } = props;
const { onSelectedIdsChange } = props;
return {
onSelectedPageIdsChange,
onSelectedIdsChange,
};
},
shallowEqual
);
export const pagesAtom = selectAtom(
pageListPropsAtom,
props => props.pages,
export const itemsAtom = selectAtom(
listPropsAtom,
props => props.items,
shallowEqual
);
export const showOperationsAtom = selectAtom(
pageListPropsAtom,
props => !!props.pageOperationsRenderer
listPropsAtom,
props => !!props.operationsRenderer
);
type SortingContext<T extends string | number | symbol> = {
key: T;
type SortingContext<KeyType extends string | number | symbol> = {
key: KeyType;
order: 'asc' | 'desc';
fallbackKey?: T;
fallbackKey?: KeyType;
};
type SorterConfig<T extends Record<string, unknown> = Record<string, unknown>> =
{
key?: keyof T;
order: 'asc' | 'desc';
sortingFn: (ctx: SortingContext<keyof T>, a: T, b: T) => number;
};
type SorterConfig<T> = {
key?: keyof T;
order: 'asc' | 'desc';
sortingFn: (ctx: SortingContext<keyof T>, a: T, b: T) => number;
};
const defaultSortingFn: SorterConfig<PageMetaRecord>['sortingFn'] = (
const defaultSortingFn: SorterConfig<MetaRecord<ListItem>>['sortingFn'] = (
ctx,
a,
b
) => {
const val = (obj: PageMetaRecord) => {
const val = (obj: MetaRecord<ListItem>) => {
let v = obj[ctx.key];
if (v === undefined && ctx.fallbackKey) {
v = obj[ctx.fallbackKey];
@ -134,7 +133,14 @@ const defaultSortingFn: SorterConfig<PageMetaRecord>['sortingFn'] = (
return 0;
};
const sorterStateAtom = atom<SorterConfig<PageMetaRecord>>({
const validKeys: Array<keyof MetaRecord<ListItem>> = [
'id',
'title',
'createDate',
'updatedDate',
];
const sorterStateAtom = atom<SorterConfig<MetaRecord<ListItem>>>({
key: DEFAULT_SORT_KEY,
order: 'desc',
sortingFn: defaultSortingFn,
@ -142,47 +148,45 @@ const sorterStateAtom = atom<SorterConfig<PageMetaRecord>>({
export const sorterAtom = atom(
get => {
let pages = get(pagesAtom);
let items = get(itemsAtom);
const sorterState = get(sorterStateAtom);
const sortCtx: SortingContext<keyof PageMetaRecord> | null = sorterState.key
? {
key: sorterState.key,
order: sorterState.order,
}
: null;
const sortCtx: SortingContext<keyof MetaRecord<ListItem>> | null =
sorterState.key
? {
key: sorterState.key,
order: sorterState.order,
}
: null;
if (sortCtx) {
if (sorterState.key === 'updatedDate') {
sortCtx.fallbackKey = 'createDate';
}
const compareFn = (a: PageMetaRecord, b: PageMetaRecord) =>
const compareFn = (a: MetaRecord<ListItem>, b: MetaRecord<ListItem>) =>
sorterState.sortingFn(sortCtx, a, b);
pages = [...pages].sort(compareFn);
items = [...items].sort(compareFn);
}
return {
pages,
items,
...sortCtx,
};
},
(_get, set, { newSortKey }: { newSortKey: keyof PageMeta }) => {
(_get, set, { newSortKey }: { newSortKey: keyof MetaRecord<ListItem> }) => {
set(sorterStateAtom, sorterState => {
if (sorterState.key === newSortKey) {
if (validKeys.includes(newSortKey)) {
return {
...sorterState,
order: sorterState.order === 'asc' ? 'desc' : 'asc',
};
} else {
return {
key: newSortKey,
order: 'desc',
order: sorterState.order === 'asc' ? 'desc' : 'asc',
sortingFn: sorterState.sortingFn,
};
}
return sorterState;
});
}
);
export const pageGroupsAtom = atom(get => {
let groupBy = get(selectAtom(pageListPropsAtom, props => props.groupBy));
export const groupsAtom = atom(get => {
let groupBy = get(selectAtom(listPropsAtom, props => props.groupBy));
const sorter = get(sorterAtom);
if (groupBy === false) {
@ -196,11 +200,11 @@ export const pageGroupsAtom = atom(get => {
? DEFAULT_SORT_KEY
: undefined;
}
return pagesToPageGroups(sorter.pages, groupBy);
return itemsToItemGroups<ListItem>(sorter.items, groupBy);
});
export const {
Provider: PageListProvider,
Provider: ListProvider,
useAtom,
useAtomValue,
useSetAtom,

View File

@ -0,0 +1,3 @@
export * from './tag-list-header';
export * from './tag-list-item';
export * from './virtualized-tag-list';

View File

@ -0,0 +1,29 @@
import { style } from '@vanilla-extract/css';
export const tagListHeader = style({
height: 100,
alignItems: 'center',
padding: '48px 16px 20px 24px',
overflow: 'hidden',
display: 'flex',
justifyContent: 'space-between',
background: 'var(--affine-background-primary-color)',
});
export const tagListHeaderTitle = style({
fontSize: 'var(--affine-font-h-5)',
fontWeight: 500,
color: 'var(--affine-text-secondary-color)',
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const newTagButton = style({
padding: '6px 10px',
borderRadius: '8px',
background: 'var(--affine-background-primary-color)',
fontSize: 'var(--affine-font-sm)',
fontWeight: 600,
height: '32px',
});

View File

@ -0,0 +1,12 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import * as styles from './tag-list-header.css';
export const TagListHeader = () => {
const t = useAFFiNEI18N();
return (
<div className={styles.tagListHeader}>
<div className={styles.tagListHeaderTitle}>{t['Tags']()}</div>
</div>
);
};

View File

@ -0,0 +1,187 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
color: 'var(--affine-text-primary-color)',
height: '54px', // 42 + 12
flexShrink: 0,
width: '100%',
alignItems: 'stretch',
transition: 'background-color 0.2s, opacity 0.2s',
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
overflow: 'hidden',
cursor: 'default',
willChange: 'opacity',
selectors: {
'&[data-clickable=true]': {
cursor: 'pointer',
},
},
});
export const dragOverlay = style({
display: 'flex',
alignItems: 'center',
zIndex: 1001,
cursor: 'grabbing',
maxWidth: '360px',
transition: 'transform 0.2s',
willChange: 'transform',
selectors: {
'&[data-over=true]': {
transform: 'scale(0.8)',
},
},
});
export const dragPageItemOverlay = style({
height: '54px',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
background: 'var(--affine-hover-color-filled)',
boxShadow: 'var(--affine-menu-shadow)',
maxWidth: '360px',
minWidth: '260px',
});
export const dndCell = style({
position: 'relative',
marginLeft: -8,
height: '100%',
outline: 'none',
paddingLeft: 8,
});
globalStyle(`[data-draggable=true] ${dndCell}:before`, {
content: '""',
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
left: 0,
width: 4,
height: 4,
transition: 'height 0.2s, opacity 0.2s',
backgroundColor: 'var(--affine-placeholder-color)',
borderRadius: '2px',
opacity: 0,
willChange: 'height, opacity',
});
globalStyle(`[data-draggable=true] ${dndCell}:hover:before`, {
height: 12,
opacity: 1,
});
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}`, {
opacity: 0.5,
});
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}:before`, {
height: 32,
width: 2,
opacity: 1,
});
// todo: remove global style
globalStyle(`${root} > :first-child`, {
paddingLeft: '16px',
});
globalStyle(`${root} > :last-child`, {
paddingRight: '8px',
});
export const titleIconsWrapper = style({
padding: '0 5px',
display: 'flex',
alignItems: 'center',
gap: '10px',
});
export const selectionCell = style({
display: 'flex',
alignItems: 'center',
flexShrink: 0,
fontSize: 'var(--affine-font-h-3)',
});
export const titleCell = style({
display: 'flex',
alignItems: 'flex-start',
padding: '0 16px',
maxWidth: 'calc(100% - 64px)',
flex: 1,
whiteSpace: 'nowrap',
});
export const titleCellMain = style({
overflow: 'hidden',
fontSize: 'var(--affine-font-sm)',
fontWeight: 600,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
alignSelf: 'stretch',
paddingRight: '4px',
});
export const titleCellPreview = style({
overflow: 'hidden',
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-base)',
flex: 1,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
alignSelf: 'stretch',
});
export const iconCell = style({
display: 'flex',
alignItems: 'center',
fontSize: 'var(--affine-font-h-3)',
color: 'var(--affine-icon-color)',
flexShrink: 0,
});
export const tagsCell = style({
display: 'flex',
alignItems: 'center',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
padding: '0 8px',
height: '60px',
width: '100%',
});
export const dateCell = style({
display: 'flex',
alignItems: 'center',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
flexShrink: 0,
flexWrap: 'nowrap',
padding: '0 8px',
});
export const actionsCellWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
flexShrink: 0,
});
export const operationsCell = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
columnGap: '6px',
flexShrink: 0,
});
export const tagIndicator = style({
width: '8px',
height: '8px',
borderRadius: '50%',
flexShrink: 0,
});

View File

@ -0,0 +1,197 @@
import { Checkbox } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useDraggable } from '@dnd-kit/core';
import { type PropsWithChildren, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import type { DraggableTitleCellData, TagListItemProps } from '../types';
import { ColWrapper, stopPropagation, tagColorMap } from '../utils';
import * as styles from './tag-list-item.css';
const TagListTitleCell = ({
title,
pageCount,
}: Pick<TagListItemProps, 'title' | 'pageCount'>) => {
const t = useAFFiNEI18N();
return (
<div data-testid="page-list-item-title" className={styles.titleCell}>
<div
data-testid="page-list-item-title-text"
className={styles.titleCellMain}
>
{title || t['Untitled']()}
</div>
<div
data-testid="page-list-item-preview-text"
className={styles.titleCellPreview}
>
{`· ${pageCount} doc(s)`}
</div>
</div>
);
};
const ListIconCell = ({ color }: Pick<TagListItemProps, 'color'>) => {
return (
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColorMap(color),
}}
/>
);
};
const TagSelectionCell = ({
selectable,
onSelectedChange,
selected,
}: Pick<TagListItemProps, 'selectable' | 'onSelectedChange' | 'selected'>) => {
const onSelectionChange = useCallback(
(_event: React.ChangeEvent<HTMLInputElement>) => {
return onSelectedChange?.();
},
[onSelectedChange]
);
if (!selectable) {
return null;
}
return (
<div className={styles.selectionCell}>
<Checkbox
onClick={stopPropagation}
checked={!!selected}
onChange={onSelectionChange}
/>
</div>
);
};
const TagListOperationsCell = ({
operations,
}: Pick<TagListItemProps, 'operations'>) => {
return operations ? (
<div onClick={stopPropagation} className={styles.operationsCell}>
{operations}
</div>
) : null;
};
export const TagListItem = (props: TagListItemProps) => {
const tagTitleElement = useMemo(() => {
return (
<div className={styles.dragPageItemOverlay}>
<div className={styles.titleIconsWrapper}>
<TagSelectionCell
onSelectedChange={props.onSelectedChange}
selectable={props.selectable}
selected={props.selected}
/>
<ListIconCell color={props.color} />
</div>
</div>
);
}, [props.color, props.onSelectedChange, props.selectable, props.selected]);
// TODO: use getDropItemId
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: 'tag-list-item-title-' + props.tagId,
data: {
pageId: props.tagId,
pageTitle: tagTitleElement,
} satisfies DraggableTitleCellData,
disabled: !props.draggable,
});
return (
<TagListItemWrapper
onClick={props.onClick}
to={props.to}
tagId={props.tagId}
draggable={props.draggable}
isDragging={isDragging}
>
<ColWrapper flex={9}>
<ColWrapper
className={styles.dndCell}
flex={8}
ref={setNodeRef}
{...attributes}
{...listeners}
>
<div className={styles.titleIconsWrapper}>
<TagSelectionCell
onSelectedChange={props.onSelectedChange}
selectable={props.selectable}
selected={props.selected}
/>
<ListIconCell color={props.color} />
</div>
<TagListTitleCell title={props.title} pageCount={props.pageCount} />
</ColWrapper>
<ColWrapper
flex={4}
alignment="end"
style={{ overflow: 'visible' }}
></ColWrapper>
</ColWrapper>
{props.operations ? (
<ColWrapper
className={styles.actionsCellWrapper}
flex={1}
alignment="end"
>
<TagListOperationsCell operations={props.operations} />
</ColWrapper>
) : null}
</TagListItemWrapper>
);
};
type TagListWrapperProps = PropsWithChildren<
Pick<TagListItemProps, 'to' | 'tagId' | 'onClick' | 'draggable'> & {
isDragging: boolean;
}
>;
function TagListItemWrapper({
to,
isDragging,
tagId,
onClick,
children,
draggable,
}: TagListWrapperProps) {
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (onClick) {
stopPropagation(e);
onClick();
}
},
[onClick]
);
const commonProps = useMemo(
() => ({
'data-testid': 'tag-list-item',
'data-tag-id': tagId,
'data-draggable': draggable,
className: styles.root,
'data-clickable': !!onClick || !!to,
'data-dragging': isDragging,
onClick: handleClick,
}),
[tagId, draggable, isDragging, onClick, to, handleClick]
);
if (to) {
return (
<Link {...commonProps} to={to}>
{children}
</Link>
);
} else {
return <div {...commonProps}>{children}</div>;
}
}

View File

@ -0,0 +1,96 @@
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { Trans } from '@affine/i18n';
import type { Tag } from '@blocksuite/store';
import { useAtomValue } from 'jotai';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
import { tagHeaderColsDef } from '../header-col-def';
import { TagListItemRenderer } from '../page-group';
import { ListTableHeader } from '../page-header';
import type { ItemListHandle, ListItem, TagMeta } from '../types';
import { VirtualizedList } from '../virtualized-list';
import { TagListHeader } from './tag-list-header';
export const VirtualizedTagList = ({
tags,
tagMetas,
setHideHeaderCreateNewTag,
onTagDelete,
}: {
tags: Tag[];
tagMetas: TagMeta[];
setHideHeaderCreateNewTag: (hide: boolean) => void;
onTagDelete: (tagIds: string[]) => void;
}) => {
const listRef = useRef<ItemListHandle>(null);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const filteredSelectedTagIds = useMemo(() => {
const ids = tags.map(tag => tag.id);
return selectedTagIds.filter(id => ids.includes(id));
}, [selectedTagIds, tags]);
const hideFloatingToolbar = useCallback(() => {
listRef.current?.toggleSelectable();
}, []);
const tagOperationRenderer = useCallback(() => {
return null;
}, []);
const tagHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={tagHeaderColsDef} />;
}, []);
const tagItemRenderer = useCallback((item: ListItem) => {
return <TagListItemRenderer {...item} />;
}, []);
const handleDelete = useCallback(() => {
onTagDelete(selectedTagIds);
hideFloatingToolbar();
return;
}, [hideFloatingToolbar, onTagDelete, selectedTagIds]);
return (
<>
<VirtualizedList
ref={listRef}
selectable={false}
draggable={false}
groupBy={false}
atTopThreshold={80}
atTopStateChange={setHideHeaderCreateNewTag}
onSelectionActiveChange={setShowFloatingToolbar}
heading={<TagListHeader />}
selectedIds={filteredSelectedTagIds}
onSelectedIdsChange={setSelectedTagIds}
items={tagMetas}
itemRenderer={tagItemRenderer}
rowAsLink
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
operationsRenderer={tagOperationRenderer}
headerRenderer={tagHeaderRenderer}
/>
<ListFloatingToolbar
open={showFloatingToolbar && selectedTagIds.length > 0}
content={
<Trans
i18nKey="com.affine.tag.toolbar.selected"
count={selectedTagIds.length}
>
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
{{ count: selectedTagIds.length } as any}
</div>
selected
</Trans>
}
onClose={hideFloatingToolbar}
onDelete={handleDelete}
/>
</>
);
};

View File

@ -1,8 +1,24 @@
import type { Tag } from '@affine/env/filter';
import type { Collection, Tag } from '@affine/env/filter';
import type { PageMeta, Workspace } from '@blocksuite/store';
import type { ReactNode } from 'react';
import type { PropsWithChildren, ReactNode } from 'react';
import type { To } from 'react-router-dom';
export type ListItem = PageMeta | CollectionMeta | TagMeta;
export interface CollectionMeta extends Collection {
title: string;
createDate?: Date;
updatedDate?: Date;
}
export type TagMeta = {
id: string;
title: string;
color: string;
pageCount?: number;
createDate?: Date;
updatedDate?: Date;
};
// TODO: consider reducing the number of props here
// using type instead of interface to make it Record compatible
export type PageListItemProps = {
@ -23,10 +39,41 @@ export type PageListItemProps = {
onSelectedChange?: () => void;
};
export interface PageListHeaderProps {}
export type CollectionListItemProps = {
collectionId: string;
icon: JSX.Element;
title: ReactNode; // using ReactNode to allow for rich content rendering
createDate?: Date;
updatedDate?: Date;
to?: To; // whether or not to render this item as a Link
draggable?: boolean; // whether or not to allow dragging this item
selectable?: boolean; // show selection checkbox
selected?: boolean;
operations?: ReactNode; // operations to show on the right side of the item
onClick?: () => void;
onSelectedChange?: () => void;
};
export type TagListItemProps = {
tagId: string;
color: string;
title: ReactNode; // using ReactNode to allow for rich content rendering
pageCount?: number;
createDate?: Date;
updatedDate?: Date;
to?: To; // whether or not to render this item as a Link
draggable?: boolean; // whether or not to allow dragging this item
selectable?: boolean; // show selection checkbox
selected?: boolean;
operations?: ReactNode; // operations to show on the right side of the item
onClick?: () => void;
onSelectedChange?: () => void;
};
export interface ItemListHeaderProps {}
// todo: a temporary solution. may need to be refactored later
export type PagesGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later
export type ItemGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later
// todo: a temporary solution. may need to be refactored later
export interface SortBy {
@ -36,56 +83,75 @@ export interface SortBy {
export type DateKey = 'createDate' | 'updatedDate';
export interface PageListProps {
export interface ListProps<T> {
// required data:
pages: PageMeta[];
items: T[];
blockSuiteWorkspace: Workspace;
className?: string;
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
groupBy?: PagesGroupByType | false;
isPreferredEdgeless: (pageId: string) => boolean; // determines the icon used for each row
groupBy?: ItemGroupByType | false;
isPreferredEdgeless?: (pageId: string) => boolean; // determines the icon used for each row
rowAsLink?: boolean;
selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item
selectedPageIds?: string[]; // selected page ids
onSelectedPageIdsChange?: (selected: string[]) => void;
selectedIds?: string[]; // selected page ids
onSelectedIdsChange?: (selected: string[]) => void;
onSelectionActiveChange?: (active: boolean) => void;
draggable?: boolean; // whether or not to allow dragging this page item
// we also need the following to make sure the page list functions properly
// maybe we could also give a function to render PageListItem?
pageOperationsRenderer?: (page: PageMeta) => ReactNode;
operationsRenderer?: (item: T) => ReactNode;
}
export interface VirtualizedPageListProps extends PageListProps {
export interface VirtualizedListProps<T> extends ListProps<T> {
heading?: ReactNode; // the user provided heading part (non sticky, above the original header)
headerRenderer?: () => ReactNode; // the user provided header renderer
itemRenderer?: (item: T) => ReactNode; // the user provided item renderer
atTopThreshold?: number; // the threshold to determine whether or not the user has scrolled to the top. default is 0
atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not
}
export interface PageListHandle {
export interface ItemListHandle {
toggleSelectable: () => void;
}
export interface PageGroupDefinition {
export interface ItemGroupDefinition<T> {
id: string;
// using a function to render custom group header
label: (() => ReactNode) | ReactNode;
match: (item: PageMeta) => boolean;
match: (item: T) => boolean;
}
export interface PageGroupProps {
export interface ItemGroupProps<T> {
id: string;
label?: ReactNode; // if there is no label, it is a default group (without header)
items: PageMeta[];
allItems: PageMeta[];
items: T[];
allItems: T[];
}
type MakeRecord<T> = {
[P in keyof T]: T[P];
};
export type PageMetaRecord = MakeRecord<PageMeta>;
export type MetaRecord<T> = MakeRecord<T>;
export type DraggableTitleCellData = {
pageId: string;
pageTitle: ReactNode;
};
export type HeaderColDef = {
key: string;
content: ReactNode;
flex: ColWrapperProps['flex'];
alignment?: ColWrapperProps['alignment'];
sortable?: boolean;
hideInSmallContainer?: boolean;
};
export type ColWrapperProps = PropsWithChildren<{
flex?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
alignment?: 'start' | 'center' | 'end';
styles?: React.CSSProperties;
hideInSmallContainer?: boolean;
}> &
React.HTMLAttributes<Element>;

View File

@ -1,16 +1,16 @@
import {
filterPage,
filterPageByRules,
useCollectionManager,
} from '@affine/core/components/page-list';
import { allPageModeSelectAtom } from '@affine/core/atoms';
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import type { BlockSuiteWorkspace } from '@affine/core/shared';
import type { PageMeta } from '@blocksuite/store';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { allPageModeSelectAtom } from '../../atoms';
import { collectionsCRUDAtom } from '../../atoms/collections';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import type { BlockSuiteWorkspace } from '../../shared';
import {
filterPage,
filterPageByRules,
useCollectionManager,
} from './use-collection-manager';
export const useFilteredPageMetas = (
route: 'all' | 'trash',

View File

@ -0,0 +1,100 @@
import type { PageMeta, Tag, Workspace } from '@blocksuite/store';
import { useCallback, useMemo } from 'react';
interface TagUsageCounts {
[key: string]: number;
}
export function useTagMetas(
currentWorkspace: Workspace,
pageMetas: PageMeta[]
) {
const tags = useMemo(() => {
return currentWorkspace.meta.properties.tags?.options || [];
}, [currentWorkspace]);
const [tagMetas, tagUsageCounts] = useMemo(() => {
const tagUsageCounts: TagUsageCounts = {};
tags.forEach(tag => {
tagUsageCounts[tag.id] = 0;
});
pageMetas.forEach(page => {
if (!page.tags) {
return;
}
page.tags.forEach(tagId => {
if (Object.prototype.hasOwnProperty.call(tagUsageCounts, tagId)) {
tagUsageCounts[tagId]++;
}
});
});
const tagsList = tags.map(tag => {
return {
...tag,
title: tag.value,
color: tag.color,
pageCount: tagUsageCounts[tag.id] || 0,
};
});
return [tagsList, tagUsageCounts];
}, [tags, pageMetas]);
const filterPageMetaByTag = useCallback(
(tagId: string) => {
return pageMetas.filter(page => {
return page.tags.includes(tagId);
});
},
[pageMetas]
);
const addNewTag = useCallback(
(tag: Tag) => {
const newTags = [...tags, tag];
currentWorkspace.meta.setProperties({
tags: { options: newTags },
});
},
[currentWorkspace.meta, tags]
);
const updateTag = useCallback(
(tag: Tag) => {
const newTags = tags.map(t => {
if (t.id === tag.id) {
return tag;
}
return t;
});
currentWorkspace.meta.setProperties({
tags: { options: newTags },
});
},
[currentWorkspace.meta, tags]
);
const deleteTags = useCallback(
(tagIds: string[]) => {
const newTags = tags.filter(tag => {
return !tagIds.includes(tag.id);
});
currentWorkspace.meta.setProperties({
tags: { options: newTags },
});
},
[currentWorkspace.meta, tags]
);
return {
tags,
tagMetas,
tagUsageCounts,
filterPageMetaByTag,
addNewTag,
updateTag,
deleteTags,
};
}

View File

@ -1,11 +1,8 @@
import clsx from 'clsx';
import {
type BaseSyntheticEvent,
forwardRef,
type PropsWithChildren,
} from 'react';
import { type BaseSyntheticEvent, forwardRef } from 'react';
import * as styles from './page-list.css';
import * as styles from './list.css';
import type { ColWrapperProps } from './types';
export function isToday(date: Date): boolean {
const today = new Date();
@ -71,14 +68,6 @@ export const formatDate = (date: Date): string => {
return `${month}-${day} ${hours}:${minutes}`;
};
export type ColWrapperProps = PropsWithChildren<{
flex?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
alignment?: 'start' | 'center' | 'end';
styles?: React.CSSProperties;
hideInSmallContainer?: boolean;
}> &
React.HTMLAttributes<Element>;
export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
function ColWrapper(
{
@ -103,11 +92,10 @@ export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
flexBasis: flex ? `${(flex / 12) * 100}%` : 'auto',
justifyContent: alignment,
}}
className={clsx(
className,
styles.colWrapper,
hideInSmallContainer ? styles.hideInSmallContainer : null
)}
data-hide-item={hideInSmallContainer ? true : undefined}
className={clsx(className, styles.colWrapper, {
[styles.hideInSmallContainer]: hideInSmallContainer,
})}
>
{children}
</div>
@ -173,3 +161,20 @@ export function shallowEqual(objA: any, objB: any) {
return true;
}
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
export const tagColorMap = (color: string) => {
const mapping: Record<string, string> = {
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
};
return mapping[color] || color;
};

View File

@ -38,4 +38,9 @@ export const filterMenuTrigger = style({
':hover': {
backgroundColor: 'var(--affine-hover-color)',
},
selectors: {
[`&[data-is-hidden="true"]`]: {
display: 'none',
},
},
});

View File

@ -25,11 +25,13 @@ export const CollectionList = ({
propertiesMeta,
allPageListConfig,
userInfo,
disable,
}: {
setting: ReturnType<typeof useCollectionManager>;
propertiesMeta: PropertiesMeta;
allPageListConfig: AllPageListConfig;
userInfo: DeleteCollectionInfo;
disable?: boolean;
}) => {
const t = useAFFiNEI18N();
const [collection, setCollection] = useState<Collection>();
@ -72,6 +74,7 @@ export const CollectionList = ({
className={styles.filterMenuTrigger}
type="default"
icon={<FilterIcon />}
data-is-hidden={disable}
data-testid="create-first-filter"
>
{t['com.affine.filter']()}

View File

@ -8,7 +8,11 @@ import { type ReactNode, useCallback } from 'react';
import { FilterList } from '../../filter/filter-list';
import { VariableSelect } from '../../filter/vars';
import { VirtualizedPageList } from '../../virtualized-page-list';
import { pageHeaderColsDef } from '../../header-col-def';
import { PageListItemRenderer } from '../../page-group';
import { ListTableHeader } from '../../page-header';
import type { ListItem } from '../../types';
import { VirtualizedList } from '../../virtualized-list';
import type { AllPageListConfig } from './edit-collection';
import * as styles from './edit-collection.css';
import { EmptyList } from './select-page';
@ -46,9 +50,19 @@ export const PagesMode = ({
});
}, [collection, updateCollection]);
const pageOperationsRenderer = useCallback(
(page: PageMeta) => allPageListConfig.favoriteRender(page),
(item: ListItem) => {
const page = item as PageMeta;
return allPageListConfig.favoriteRender(page);
},
[allPageListConfig]
);
const pageItemRenderer = useCallback((item: ListItem) => {
return <PageListItemRenderer {...item} />;
}, []);
const pageHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={pageHeaderColsDef} />;
}, []);
return (
<>
<input
@ -102,22 +116,24 @@ export const PagesMode = ({
</div>
) : null}
{searchedList.length ? (
<VirtualizedPageList
<VirtualizedList
className={styles.pageList}
pages={searchedList}
items={searchedList}
groupBy={false}
blockSuiteWorkspace={allPageListConfig.workspace}
selectable
onSelectedPageIdsChange={ids => {
onSelectedIdsChange={ids => {
updateCollection({
...collection,
allowList: ids,
});
}}
pageOperationsRenderer={pageOperationsRenderer}
selectedPageIds={collection.allowList}
itemRenderer={pageItemRenderer}
operationsRenderer={pageOperationsRenderer}
headerRenderer={pageHeaderRenderer}
selectedIds={collection.allowList}
isPreferredEdgeless={allPageListConfig.isEdgeless}
></VirtualizedPageList>
/>
) : (
<EmptyList search={searchText} />
)}

View File

@ -13,7 +13,8 @@ import clsx from 'clsx';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import { FilterList } from '../../filter';
import { PageList, PageListScrollContainer } from '../../page-list';
import { List, ListScrollContainer } from '../../list';
import type { ListItem } from '../../types';
import { filterPageByRules } from '../../use-collection-manager';
import { AffineShapeIcon } from '../affine-shape';
import type { AllPageListConfig } from './edit-collection';
@ -78,6 +79,13 @@ export const RulesMode = ({
const [expandInclude, setExpandInclude] = useState(
collection.allowList.length > 0
);
const operationsRenderer = useCallback(
(item: ListItem) => {
const page = item as PageMeta;
return allPageListConfig.favoriteRender(page);
},
[allPageListConfig]
);
return (
<>
{/*prevents modal autofocus to the first input*/}
@ -241,22 +249,22 @@ export const RulesMode = ({
) : null}
</div>
</div>
<PageListScrollContainer
<ListScrollContainer
className={styles.rulesContainerRight}
style={{
display: showPreview ? 'flex' : 'none',
}}
>
{rulesPages.length > 0 ? (
<PageList
<List
hideHeader
className={styles.resultPages}
pages={rulesPages}
items={rulesPages}
groupBy={false}
blockSuiteWorkspace={allPageListConfig.workspace}
isPreferredEdgeless={allPageListConfig.isEdgeless}
pageOperationsRenderer={allPageListConfig.favoriteRender}
></PageList>
operationsRenderer={operationsRenderer}
></List>
) : (
<RulesEmpty
noRules={collection.filterList.length === 0}
@ -268,18 +276,18 @@ export const RulesMode = ({
<div className={styles.includeListTitle}>
{t['com.affine.editCollection.rules.include.title']()}
</div>
<PageList
<List
hideHeader
className={styles.resultPages}
pages={allowListPages}
items={allowListPages}
groupBy={false}
blockSuiteWorkspace={allPageListConfig.workspace}
isPreferredEdgeless={allPageListConfig.isEdgeless}
pageOperationsRenderer={allPageListConfig.favoriteRender}
></PageList>
operationsRenderer={operationsRenderer}
></List>
</div>
) : null}
</PageListScrollContainer>
</ListScrollContainer>
</div>
<div className={styles.rulesBottom}>
<div className={styles.bottomLeft}>

View File

@ -2,12 +2,14 @@ import { Button, Menu } from '@affine/component';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilterIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import { FilterList } from '../../filter';
import { VariableSelect } from '../../filter/vars';
import { VirtualizedPageList } from '../../virtualized-page-list';
import type { ListItem } from '../../types';
import { VirtualizedList } from '../../virtualized-list';
import { AffineShapeIcon } from '../affine-shape';
import type { AllPageListConfig } from './edit-collection';
import * as styles from './edit-collection.css';
@ -43,6 +45,15 @@ export const SelectPage = ({
} = useFilter(allPageListConfig.allPages);
const { searchText, updateSearchText, searchedList } =
useSearch(filteredList);
const operationsRenderer = useCallback(
(item: ListItem) => {
const page = item as PageMeta;
return allPageListConfig.favoriteRender(page);
},
[allPageListConfig]
);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<input
@ -94,16 +105,16 @@ export const SelectPage = ({
</div>
) : null}
{searchedList.length ? (
<VirtualizedPageList
<VirtualizedList
className={styles.pageList}
pages={searchedList}
items={searchedList}
blockSuiteWorkspace={allPageListConfig.workspace}
selectable
groupBy={false}
onSelectedPageIdsChange={onChange}
selectedPageIds={value}
onSelectedIdsChange={onChange}
selectedIds={value}
isPreferredEdgeless={allPageListConfig.isEdgeless}
pageOperationsRenderer={allPageListConfig.favoriteRender}
operationsRenderer={operationsRenderer}
/>
) : (
<EmptyList search={searchText} />

View File

@ -1,5 +1,4 @@
import { Scrollable } from '@affine/component';
import type { PageMeta } from '@blocksuite/store';
import clsx from 'clsx';
import { selectAtom } from 'jotai/utils';
import {
@ -12,29 +11,29 @@ import {
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { PageGroupHeader, PageMetaListItemRenderer } from './page-group';
import { PageListTableHeader } from './page-header';
import { PageListInnerWrapper } from './page-list';
import * as styles from './page-list.css';
import { ListInnerWrapper } from './list';
import * as styles from './list.css';
import { ItemGroupHeader } from './page-group';
import {
pageGroupCollapseStateAtom,
pageGroupsAtom,
pageListPropsAtom,
PageListProvider,
groupCollapseStateAtom,
groupsAtom,
listPropsAtom,
ListProvider,
useAtomValue,
} from './scoped-atoms';
import type {
PageGroupProps,
PageListHandle,
VirtualizedPageListProps,
ItemGroupProps,
ItemListHandle,
ListItem,
VirtualizedListProps,
} from './types';
// we have three item types for rendering rows in Virtuoso
type VirtuosoItemType =
| 'sticky-header'
| 'page-group-header'
| 'page-item'
| 'page-item-spacer';
| 'group-header'
| 'item'
| 'item-spacer';
interface BaseVirtuosoItem {
type: VirtuosoItemType;
@ -44,62 +43,62 @@ interface VirtuosoItemStickyHeader extends BaseVirtuosoItem {
type: 'sticky-header';
}
interface VirtuosoItemPageItem extends BaseVirtuosoItem {
type: 'page-item';
data: PageMeta;
interface VirtuosoItemItem<T> extends BaseVirtuosoItem {
type: 'item';
data: T;
}
interface VirtuosoItemPageGroupHeader extends BaseVirtuosoItem {
type: 'page-group-header';
data: PageGroupProps;
interface VirtuosoItemGroupHeader<T> extends BaseVirtuosoItem {
type: 'group-header';
data: ItemGroupProps<T>;
}
interface VirtuosoPageItemSpacer extends BaseVirtuosoItem {
type: 'page-item-spacer';
type: 'item-spacer';
data: {
height: number;
};
}
type VirtuosoItem =
type VirtuosoItem<T> =
| VirtuosoItemStickyHeader
| VirtuosoItemPageItem
| VirtuosoItemPageGroupHeader
| VirtuosoItemItem<T>
| VirtuosoItemGroupHeader<T>
| VirtuosoPageItemSpacer;
/**
* Given a list of pages, render a list of pages
* Similar to normal PageList, but uses react-virtuoso to render the list (virtual rendering)
*/
export const VirtualizedPageList = forwardRef<
PageListHandle,
VirtualizedPageListProps
>(function VirtualizedPageList(props, ref) {
export const VirtualizedList = forwardRef<
ItemListHandle,
VirtualizedListProps<ListItem>
>(function VirtualizedList(props, ref) {
return (
// push pageListProps to the atom so that downstream components can consume it
// this makes sure pageListPropsAtom is always populated
// @ts-expect-error fix type issues later
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
<PageListInnerWrapper {...props} handleRef={ref}>
<PageListInner {...props} />
</PageListInnerWrapper>
</PageListProvider>
<ListProvider initialValues={[[listPropsAtom, props]]}>
<ListInnerWrapper {...props} handleRef={ref}>
<ListInner {...props} />
</ListInnerWrapper>
</ListProvider>
);
});
const headingAtom = selectAtom(pageListPropsAtom, props => props.heading);
const headingAtom = selectAtom(listPropsAtom, props => props.heading);
const PageListHeading = () => {
const heading = useAtomValue(headingAtom);
return <div className={styles.heading}>{heading}</div>;
};
const useVirtuosoItems = () => {
const groups = useAtomValue(pageGroupsAtom);
const groupCollapsedState = useAtomValue(pageGroupCollapseStateAtom);
const useVirtuosoItems = <T extends ListItem>() => {
const groups = useAtomValue(groupsAtom);
const groupCollapsedState = useAtomValue(groupCollapseStateAtom);
return useMemo(() => {
const items: VirtuosoItem[] = [];
const items: VirtuosoItem<T>[] = [];
// 1.
// always put sticky header at the top
@ -114,21 +113,21 @@ const useVirtuosoItems = () => {
// skip empty group header since it will cause issue in virtuoso ("Zero-sized element")
if (group.label) {
items.push({
type: 'page-group-header',
data: group,
type: 'group-header',
data: group as ItemGroupProps<T>,
});
}
// do not render items if the group is collapsed
if (!groupCollapsedState[group.id]) {
for (const item of group.items) {
items.push({
type: 'page-item',
data: item,
type: 'item',
data: item as T,
});
// add a spacer between items (4px), unless it's the last item
if (item !== group.items[group.items.length - 1]) {
items.push({
type: 'page-item-spacer',
type: 'item-spacer',
data: {
height: 4,
},
@ -139,7 +138,7 @@ const useVirtuosoItems = () => {
// add a spacer between groups (16px)
items.push({
type: 'page-item-spacer',
type: 'item-spacer',
data: {
height: 16,
},
@ -149,19 +148,6 @@ const useVirtuosoItems = () => {
}, [groupCollapsedState, groups]);
};
const itemContentRenderer = (_index: number, data: VirtuosoItem) => {
switch (data.type) {
case 'sticky-header':
return <PageListTableHeader />;
case 'page-group-header':
return <PageGroupHeader {...data.data} />;
case 'page-item':
return <PageMetaListItemRenderer {...data.data} />;
case 'page-item-spacer':
return <div style={{ height: data.data.height }} />;
}
};
const Scroller = forwardRef<
HTMLDivElement,
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
@ -178,12 +164,12 @@ const Scroller = forwardRef<
Scroller.displayName = 'Scroller';
const PageListInner = ({
const ListInner = ({
atTopStateChange,
atTopThreshold,
...props
}: VirtualizedPageListProps) => {
const virtuosoItems = useVirtuosoItems();
}: VirtualizedListProps<ListItem>) => {
const virtuosoItems = useVirtuosoItems<ListItem>();
const [atTop, setAtTop] = useState(false);
const handleAtTopStateChange = useCallback(
(atTop: boolean) => {
@ -198,15 +184,30 @@ const PageListInner = ({
Scroller: Scroller,
};
}, [props.heading]);
const itemContentRenderer = useCallback(
(_index: number, data: VirtuosoItem<ListItem>) => {
switch (data.type) {
case 'sticky-header':
return props.headerRenderer?.();
case 'group-header':
return <ItemGroupHeader {...data.data} />;
case 'item':
return props.itemRenderer?.(data.data);
case 'item-spacer':
return <div style={{ height: data.data.height }} />;
}
},
[props]
);
return (
<Virtuoso<VirtuosoItem>
<Virtuoso<VirtuosoItem<ListItem>>
data-has-scroll-top={!atTop}
atTopThreshold={atTopThreshold ?? 0}
atTopStateChange={handleAtTopStateChange}
components={components}
data={virtuosoItems}
data-testid="virtualized-page-list"
data-total-count={props.pages.length} // for testing, since we do not know the total count in test
data-total-count={props.items.length} // for testing, since we do not know the total count in test
topItemCount={1} // sticky header
totalCount={virtuosoItems.length}
itemContent={itemContentRenderer}

View File

@ -1,30 +1,59 @@
import { RadioButton, RadioButtonGroup } from '@affine/component';
import type { AllPageFilterOption } from '@affine/core/atoms';
import { allPageFilterSelectAtom } from '@affine/core/atoms';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { WorkspaceSubPath } from '@affine/core/shared';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import { allPageModeSelectAtom } from '../../../atoms';
import * as styles from './index.css';
export const WorkspaceModeFilterTab = () => {
export const WorkspaceModeFilterTab = ({
workspaceId,
activeFilter,
}: {
workspaceId: string;
activeFilter: AllPageFilterOption;
}) => {
const t = useAFFiNEI18N();
const [value, setMode] = useAtom(allPageModeSelectAtom);
const handleValueChange = (value: string) => {
if (value !== 'all' && value !== 'page' && value !== 'edgeless') {
throw new Error('Invalid value for page mode option');
const [value, setValue] = useState(activeFilter);
const [filterMode, setFilterMode] = useAtom(allPageFilterSelectAtom);
const { jumpToCollections, jumpToTags, jumpToSubPath } = useNavigateHelper();
const handleValueChange = useCallback(
(value: AllPageFilterOption) => {
switch (value) {
case 'collections':
jumpToCollections(workspaceId);
break;
case 'tags':
jumpToTags(workspaceId);
break;
case 'docs':
jumpToSubPath(workspaceId, WorkspaceSubPath.ALL);
break;
}
},
[jumpToCollections, jumpToSubPath, jumpToTags, workspaceId]
);
useEffect(() => {
if (value !== activeFilter) {
setValue(activeFilter);
setFilterMode(activeFilter);
}
setMode(value);
};
}, [activeFilter, filterMode, setFilterMode, value]);
return (
<RadioButtonGroup value={value} onValueChange={handleValueChange}>
<RadioButton value="all" spanStyle={styles.filterTab}>
{t['com.affine.pageMode.all']()}
<RadioButton spanStyle={styles.filterTab} value="docs">
{t['com.affine.docs.header']()}
</RadioButton>
<RadioButton spanStyle={styles.filterTab} value="page">
{t['com.affine.pageMode.page']()}
<RadioButton spanStyle={styles.filterTab} value="collections">
{t['com.affine.collections.header']()}
</RadioButton>
<RadioButton spanStyle={styles.filterTab} value="edgeless">
{t['com.affine.pageMode.edgeless']()}
<RadioButton spanStyle={styles.filterTab} value="tags">
{t['Tags']()}
</RadioButton>
</RadioButtonGroup>
);

View File

@ -69,15 +69,12 @@ export type RootAppSidebarProps = {
const RouteMenuLinkItem = forwardRef<
HTMLDivElement,
{
currentPath: string; // todo: pass through useRouter?
path: string;
icon: ReactElement;
active?: boolean;
children?: ReactElement;
isDraggedOver?: boolean;
} & HTMLAttributes<HTMLDivElement>
>(({ currentPath, path, icon, children, isDraggedOver, ...props }, ref) => {
// Force active style when a page is dragged over
const active = isDraggedOver || currentPath === path;
>(({ path, icon, active, children, ...props }, ref) => {
return (
<MenuLinkItem
ref={ref}
@ -197,6 +194,22 @@ export const RootAppSidebar = ({
});
}, [blockSuiteWorkspace.id, navigateHelper, open, setting]);
const allPageActive = useMemo(() => {
if (
currentPath.startsWith(`/workspace/${currentWorkspaceId}/collection/`) ||
currentPath.startsWith(`/workspace/${currentWorkspaceId}/tag/`)
) {
return true;
}
return currentPath === paths.all(currentWorkspaceId);
}, [currentPath, currentWorkspaceId, paths]);
const trashActive = useMemo(() => {
return (
currentPath === paths.trash(currentWorkspaceId) || trashDroppable.isOver
);
}, [currentPath, currentWorkspaceId, paths, trashDroppable.isOver]);
return (
<AppSidebar
router={router}
@ -247,7 +260,7 @@ export const RootAppSidebar = ({
/>
<RouteMenuLinkItem
icon={<FolderIcon />}
currentPath={currentPath}
active={allPageActive}
path={paths.all(currentWorkspaceId)}
onClick={backToAll}
>
@ -289,9 +302,8 @@ export const RootAppSidebar = ({
<div style={{ height: '4px' }} />
<RouteMenuLinkItem
ref={trashDroppable.setNodeRef}
isDraggedOver={trashDroppable.isOver}
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
currentPath={currentPath}
active={trashActive}
path={paths.trash(currentWorkspaceId)}
>
<span data-testid="trash-page">

View File

@ -42,6 +42,22 @@ export function useNavigateHelper() {
},
[navigate]
);
const jumpToCollections = useCallback(
(workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => {
return navigate(`/workspace/${workspaceId}/all?filterMode=collections`, {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToTags = useCallback(
(workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => {
return navigate(`/workspace/${workspaceId}/all?filterMode=tags`, {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToCollection = useCallback(
(
workspaceId: string,
@ -144,6 +160,8 @@ export function useNavigateHelper() {
jumpToExpired,
jumpToSignIn,
jumpToCollection,
jumpToCollections,
jumpToTags,
}),
[
jumpToPage,
@ -156,6 +174,8 @@ export function useNavigateHelper() {
jumpToExpired,
jumpToSignIn,
jumpToCollection,
jumpToCollections,
jumpToTags,
]
);
}

View File

@ -1,6 +1,6 @@
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom, useAtomValue, useStore } from 'jotai';
import { useAtomValue, useSetAtom, useStore } from 'jotai';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
@ -26,7 +26,7 @@ export function useRegisterWorkspaceCommands() {
const languageHelper = useLanguageHelper();
const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace);
const navigationHelper = useNavigateHelper();
const [pageListMode, setPageListMode] = useAtom(allPageModeSelectAtom);
const setPageListMode = useSetAtom(allPageModeSelectAtom);
const [editor] = useActiveBlocksuiteEditor();
// register AffineUpdatesCommands
@ -48,7 +48,6 @@ export function useRegisterWorkspaceCommands() {
t,
workspace: currentWorkspace.blockSuiteWorkspace,
navigationHelper,
pageListMode,
setPageListMode,
});
@ -60,7 +59,6 @@ export function useRegisterWorkspaceCommands() {
t,
currentWorkspace.blockSuiteWorkspace,
navigationHelper,
pageListMode,
setPageListMode,
]);

View File

@ -0,0 +1,110 @@
import { IconButton } from '@affine/component';
import type { AllPageFilterOption } from '@affine/core/atoms';
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
import {
CollectionList,
PageListNewPageButton,
useCollectionManager,
} from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config';
import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info';
import { PlusIcon } from '@blocksuite/icons';
import type { Workspace } from '@blocksuite/store';
import clsx from 'clsx';
import { useMemo } from 'react';
import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter';
export const AllPageHeader = ({
workspace,
showCreateNew,
isDefaultFilter,
activeFilter,
onCreateCollection,
}: {
workspace: Workspace;
showCreateNew: boolean;
isDefaultFilter: boolean;
activeFilter: AllPageFilterOption;
onCreateCollection?: () => void;
}) => {
const setting = useCollectionManager(collectionsCRUDAtom);
const config = useAllPageListConfig();
const userInfo = useDeleteCollectionInfo();
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const disableFilterButton = useMemo(() => {
return activeFilter !== 'docs' && isDefaultFilter;
}, [activeFilter, isDefaultFilter]);
const renderRightItem = useMemo(() => {
if (activeFilter === 'tags') {
return null;
}
if (
activeFilter === 'collections' &&
isDefaultFilter &&
onCreateCollection
) {
return (
<IconButton
type="default"
icon={<PlusIcon fontSize={16} />}
onClick={onCreateCollection}
className={clsx(
styles.headerCreateNewButton,
styles.headerCreateNewCollectionIconButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
/>
);
}
return (
<PageListNewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
>
<PlusIcon />
</PageListNewPageButton>
);
}, [activeFilter, isDefaultFilter, onCreateCollection, showCreateNew]);
return (
<>
<Header
left={
<CollectionList
userInfo={userInfo}
allPageListConfig={config}
setting={setting}
propertiesMeta={workspace.meta.properties}
disable={disableFilterButton}
/>
}
right={
<div
className={styles.headerRightWindows}
data-is-windows-desktop={isWindowsDesktop}
>
{renderRightItem}
{isWindowsDesktop ? <WindowsAppControls /> : null}
</div>
}
center={
<WorkspaceModeFilterTab
workspaceId={workspace.id}
activeFilter={activeFilter}
/>
}
/>
<FilterContainer />
</>
);
};

View File

@ -14,53 +14,15 @@ export const scrollContainer = style({
paddingBottom: '32px',
});
export const allPagesHeader = style({
height: 100,
alignItems: 'center',
padding: '48px 16px 20px 24px',
overflow: 'hidden',
display: 'flex',
justifyContent: 'space-between',
background: 'var(--affine-background-primary-color)',
});
export const allPagesHeaderTitle = style({
fontSize: 'var(--affine-font-h-5)',
fontWeight: 500,
color: 'var(--affine-text-secondary-color)',
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const titleIcon = style({
color: 'var(--affine-icon-color)',
display: 'inline-flex',
alignItems: 'center',
});
export const titleCollectionName = style({
color: 'var(--affine-text-primary-color)',
});
export const floatingToolbar = style({
position: 'absolute',
bottom: 26,
width: '100%',
zIndex: 1,
});
export const toolbarSelectedNumber = style({
color: 'var(--affine-text-secondary-color)',
});
export const headerCreateNewButton = style({
transition: 'opacity 0.1s ease-in-out',
});
export const newPageButtonLabel = style({
display: 'flex',
alignItems: 'center',
export const headerCreateNewCollectionIconButton = style({
padding: '4px 8px',
fontSize: '16px',
width: '32px',
height: '28px',
borderRadius: '8px',
});
export const headerCreateNewButtonHidden = style({
@ -72,5 +34,9 @@ export const headerRightWindows = style({
display: 'flex',
alignItems: 'center',
gap: 8,
transform: 'translateX(16px)',
selectors: {
'&[data-is-windows-desktop="true"]': {
transform: 'translateX(16px)',
},
},
});

View File

@ -1,322 +1,209 @@
import { toast } from '@affine/component';
import type { AllPageFilterOption } from '@affine/core/atoms';
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
import { HubIsland } from '@affine/core/components/affine/hub-island';
import {
CollectionList,
CollectionListHeader,
type CollectionMeta,
createEmptyCollection,
currentCollectionAtom,
FloatingToolbar,
NewPageButton as PureNewPageButton,
OperationCell,
type PageListHandle,
PageListHeader,
useCollectionManager,
useEditCollectionName,
useFilteredPageMetas,
useSavedCollections,
useTagMetas,
VirtualizedCollectionList,
VirtualizedPageList,
} from '@affine/core/components/page-list';
import {
TagListHeader,
VirtualizedTagList,
} from '@affine/core/components/page-list/tags';
import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config';
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { Trans } from '@affine/i18n';
import { performanceRenderLogger } from '@affine/core/shared';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
CloseIcon,
DeleteIcon,
PlusIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import type { PageMeta, Workspace } from '@blocksuite/store';
import clsx from 'clsx';
import { useAtomValue, useSetAtom } from 'jotai';
import {
type PropsWithChildren,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { NIL } from 'uuid';
import { collectionsCRUDAtom } from '../../../atoms/collections';
import { HubIsland } from '../../../components/affine/hub-island';
import { usePageHelper } from '../../../components/blocksuite/block-suite-page-list/utils';
import { Header } from '../../../components/pure/header';
import { WindowsAppControls } from '../../../components/pure/header/windows-app-controls';
import { WorkspaceModeFilterTab } from '../../../components/pure/workspace-mode-filter-tab';
import { useAllPageListConfig } from '../../../hooks/affine/use-all-page-list-config';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useDeleteCollectionInfo } from '../../../hooks/affine/use-delete-collection-info';
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { performanceRenderLogger } from '../../../shared';
import { EmptyPageList } from '../page-list-empty';
import { useFilteredPageMetas } from '../pages';
import {
EmptyCollectionList,
EmptyPageList,
EmptyTagList,
} from '../page-list-empty';
import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter';
const PageListHeader = () => {
const t = useAFFiNEI18N();
const setting = useCollectionManager(collectionsCRUDAtom);
const title = useMemo(() => {
if (setting.isDefault) {
return t['com.affine.all-pages.header']();
}
return (
<>
{t['com.affine.collections.header']()} /
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>
{setting.currentCollection.name}
</div>
</>
);
}, [setting.currentCollection.name, setting.isDefault, t]);
return (
<div className={styles.allPagesHeader}>
<div className={styles.allPagesHeaderTitle}>{title}</div>
<NewPageButton testId="new-page-button-trigger">
{t['New Page']()}
</NewPageButton>
</div>
);
};
const usePageOperationsRenderer = () => {
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const { setTrashModal } = useTrashModalHelper(
currentWorkspace.blockSuiteWorkspace
);
const { toggleFavorite } = useBlockSuiteMetaHelper(
currentWorkspace.blockSuiteWorkspace
);
const t = useAFFiNEI18N();
const pageOperationsRenderer = useCallback(
(page: PageMeta) => {
const onDisablePublicSharing = () => {
toast('Successfully disabled', {
portal: document.body,
});
};
return (
<OperationCell
favorite={!!page.favorite}
isPublic={!!page.isPublic}
onDisablePublicSharing={onDisablePublicSharing}
link={`/workspace/${currentWorkspace.id}/${page.id}`}
onRemoveToTrash={() =>
setTrashModal({
open: true,
pageIds: [page.id],
pageTitles: [page.title],
})
}
onToggleFavoritePage={() => {
const status = page.favorite;
toggleFavorite(page.id);
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}}
/>
);
},
[currentWorkspace.id, setTrashModal, t, toggleFavorite]
);
return pageOperationsRenderer;
};
const PageListFloatingToolbar = ({
selectedIds,
onClose,
open,
}: {
open: boolean;
selectedIds: string[];
onClose: () => void;
}) => {
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const { setTrashModal } = useTrashModalHelper(
currentWorkspace.blockSuiteWorkspace
);
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const handleMultiDelete = useCallback(() => {
const pageNameMapping = Object.fromEntries(
pageMetas.map(meta => [meta.id, meta.title])
);
const pageNames = selectedIds.map(id => pageNameMapping[id] ?? '');
setTrashModal({
open: true,
pageIds: selectedIds,
pageTitles: pageNames,
});
}, [pageMetas, selectedIds, setTrashModal]);
return (
<FloatingToolbar className={styles.floatingToolbar} open={open}>
<FloatingToolbar.Item>
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={selectedIds.length}
>
<div className={styles.toolbarSelectedNumber}>
{{ count: selectedIds.length } as any}
</div>
selected
</Trans>
</FloatingToolbar.Item>
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
<FloatingToolbar.Separator />
<FloatingToolbar.Button
onClick={handleMultiDelete}
icon={<DeleteIcon />}
type="danger"
data-testid="page-list-toolbar-delete"
/>
</FloatingToolbar>
);
};
const NewPageButton = ({
className,
children,
size,
testId,
}: PropsWithChildren<{
className?: string;
size?: 'small' | 'default';
testId?: string;
}>) => {
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const { importFile, createEdgeless, createPage } = usePageHelper(
currentWorkspace.blockSuiteWorkspace
);
return (
<div className={className} data-testid={testId}>
<PureNewPageButton
size={size}
importFile={importFile}
createNewEdgeless={createEdgeless}
createNewPage={createPage}
>
<div className={styles.newPageButtonLabel}>{children}</div>
</PureNewPageButton>
</div>
);
};
const AllPageHeader = ({
workspace,
showCreateNew,
}: {
workspace: Workspace;
showCreateNew: boolean;
}) => {
const setting = useCollectionManager(collectionsCRUDAtom);
const config = useAllPageListConfig();
const userInfo = useDeleteCollectionInfo();
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
return (
<>
<Header
left={
<CollectionList
userInfo={userInfo}
allPageListConfig={config}
setting={setting}
propertiesMeta={workspace.meta.properties}
/>
}
right={
<div className={clsx(isWindowsDesktop && styles.headerRightWindows)}>
<NewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
>
<PlusIcon />
</NewPageButton>
{isWindowsDesktop ? <WindowsAppControls /> : null}
</div>
}
center={<WorkspaceModeFilterTab />}
/>
<FilterContainer />
</>
);
};
import { AllPageHeader } from './all-page-header';
// even though it is called all page, it is also being used for collection route as well
export const AllPage = () => {
export const AllPage = ({
activeFilter,
}: {
activeFilter: AllPageFilterOption;
}) => {
const t = useAFFiNEI18N();
const params = useParams();
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const { isPreferredEdgeless } = usePageHelper(
currentWorkspace.blockSuiteWorkspace
);
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const pageOperationsRenderer = usePageOperationsRenderer();
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const setting = useCollectionManager(collectionsCRUDAtom);
const config = useAllPageListConfig();
const { collections } = useSavedCollections(collectionsCRUDAtom);
const { tags, tagMetas, filterPageMetaByTag, deleteTags } = useTagMetas(
currentWorkspace.blockSuiteWorkspace,
pageMetas
);
const filteredPageMetas = useFilteredPageMetas(
'all',
pageMetas,
currentWorkspace.blockSuiteWorkspace
);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const pageListRef = useRef<PageListHandle>(null);
const tagPageMetas = useMemo(() => {
if (params.tagId) {
return filterPageMetaByTag(params.tagId);
}
return [];
}, [filterPageMetaByTag, params.tagId]);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const collectionMetas = useMemo(() => {
const collectionsList: CollectionMeta[] = collections.map(collection => {
return {
...collection,
title: collection.name,
};
});
return collectionsList;
}, [collections]);
const hideFloatingToolbar = useCallback(() => {
pageListRef.current?.toggleSelectable();
}, []);
const navigateHelper = useNavigateHelper();
const { open, node } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
// make sure selected id is in the filtered list
const filteredSelectedPageIds = useMemo(() => {
const ids = filteredPageMetas.map(page => page.id);
return selectedPageIds.filter(id => ids.includes(id));
}, [filteredPageMetas, selectedPageIds]);
const handleCreateCollection = useCallback(() => {
open('')
.then(name => {
const id = nanoid();
setting.createCollection(createEmptyCollection(id, { name }));
navigateHelper.jumpToCollection(currentWorkspace.id, id);
})
.catch(err => {
console.error(err);
});
}, [currentWorkspace.id, navigateHelper, open, setting]);
const [hideHeaderCreateNewPage, setHideHeaderCreateNewPage] = useState(true);
const currentTag = useMemo(() => {
if (params.tagId) {
return tags.find(tag => tag.id === params.tagId);
}
return;
}, [params.tagId, tags]);
const content = useMemo(() => {
if (filteredPageMetas.length > 0 && activeFilter === 'docs') {
return (
<VirtualizedPageList
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
/>
);
} else if (activeFilter === 'collections' && !setting.isDefault) {
return (
<VirtualizedPageList
collection={setting.currentCollection}
config={config}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
/>
);
} else if (activeFilter === 'collections' && setting.isDefault) {
return collectionMetas.length > 0 ? (
<VirtualizedCollectionList
collections={collections}
collectionMetas={collectionMetas}
setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
node={node}
config={config}
handleCreateCollection={handleCreateCollection}
/>
) : (
<EmptyCollectionList
heading={
<CollectionListHeader
node={node}
onCreate={handleCreateCollection}
/>
}
/>
);
} else if (activeFilter === 'tags') {
if (params.tagId) {
return tagPageMetas.length > 0 ? (
<VirtualizedPageList
tag={currentTag}
listItem={tagPageMetas}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
/>
) : (
<EmptyPageList
type="all"
heading={<PageListHeader workspaceId={currentWorkspace.id} />}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
);
}
return tags.length > 0 ? (
<VirtualizedTagList
tags={tags}
tagMetas={tagMetas}
setHideHeaderCreateNewTag={setHideHeaderCreateNew}
onTagDelete={deleteTags}
/>
) : (
<EmptyTagList heading={<TagListHeader />} />
);
}
return (
<EmptyPageList
type="all"
heading={<PageListHeader workspaceId={currentWorkspace.id} />}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
);
}, [
activeFilter,
collectionMetas,
collections,
config,
currentTag,
currentWorkspace.blockSuiteWorkspace,
currentWorkspace.id,
deleteTags,
filteredPageMetas.length,
handleCreateCollection,
node,
params.tagId,
setting.currentCollection,
setting.isDefault,
tagMetas,
tagPageMetas,
tags,
]);
return (
<div className={styles.root}>
<AllPageHeader
workspace={currentWorkspace.blockSuiteWorkspace}
showCreateNew={!hideHeaderCreateNewPage}
showCreateNew={!hideHeaderCreateNew}
isDefaultFilter={setting.isDefault}
activeFilter={activeFilter}
onCreateCollection={handleCreateCollection}
/>
{filteredPageMetas.length > 0 ? (
<>
<VirtualizedPageList
ref={pageListRef}
selectable="toggle"
draggable
atTopThreshold={80}
atTopStateChange={setHideHeaderCreateNewPage}
onSelectionActiveChange={setShowFloatingToolbar}
heading={<PageListHeader />}
selectedPageIds={filteredSelectedPageIds}
onSelectedPageIdsChange={setSelectedPageIds}
pages={filteredPageMetas}
rowAsLink
isPreferredEdgeless={isPreferredEdgeless}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
pageOperationsRenderer={pageOperationsRenderer}
/>
<PageListFloatingToolbar
open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
selectedIds={filteredSelectedPageIds}
onClose={hideFloatingToolbar}
/>
</>
) : (
<EmptyPageList
type="all"
heading={<PageListHeader />}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
{content}
<HubIsland />
</div>
);
@ -329,6 +216,18 @@ export const Component = () => {
const currentCollection = useSetAtom(currentCollectionAtom);
const navigateHelper = useNavigateHelper();
const location = useLocation();
const activeFilter = useMemo(() => {
const query = new URLSearchParams(location.search);
const filterMode = query.get('filterMode');
if (filterMode === 'collections') {
return 'collections';
} else if (filterMode === 'tags') {
return 'tags';
}
return 'docs';
}, [location.search]);
useEffect(() => {
function checkJumpOnce() {
for (const [pageId] of currentWorkspace.blockSuiteWorkspace.pages) {
@ -355,5 +254,5 @@ export const Component = () => {
currentCollection(NIL);
}, [currentCollection]);
return <AllPage />;
return <AllPage activeFilter={activeFilter} />;
};

View File

@ -10,6 +10,7 @@ import {
useEditCollection,
} from '@affine/core/components/page-list';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { useAllPageListConfig } from '@affine/core/hooks/affine/use-all-page-list-config';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import type { Collection } from '@affine/env/filter';
@ -31,7 +32,6 @@ import {
collectionsCRUDAtom,
pageCollectionBaseAtom,
} from '../../atoms/collections';
import { useAllPageListConfig } from '../../hooks/affine/use-all-page-list-config';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { WorkspaceSubPath } from '../../shared';
import { getWorkspaceSetting } from '../../utils/workspace-setting';
@ -89,17 +89,24 @@ export const Component = function CollectionPage() {
return null;
}
return isEmpty(collection) ? (
<Placeholder collection={collection} />
<Placeholder collection={collection} workspaceId={workspace.id} />
) : (
<AllPage />
<AllPage activeFilter="collections" />
);
};
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
const Placeholder = ({ collection }: { collection: Collection }) => {
const Placeholder = ({
collection,
workspaceId,
}: {
collection: Collection;
workspaceId: string;
}) => {
const { updateCollection } = useCollectionManager(collectionsCRUDAtom);
const { node, open } = useEditCollection(useAllPageListConfig());
const { jumpToCollections } = useNavigateHelper();
const openPageEdit = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'page');
updateCollection(ret);
@ -118,6 +125,11 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
}, []);
const t = useAFFiNEI18N();
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
const handleJumpToCollections = useCallback(() => {
jumpToCollections(workspaceId);
}, [jumpToCollections, workspaceId]);
return (
<div
style={{
@ -143,9 +155,11 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
color: 'var(--affine-text-secondary-color)',
['WebkitAppRegion' as string]: 'no-drag',
}}
onClick={handleJumpToCollections}
>
<ViewLayersIcon
style={{ color: 'var(--affine-icon-color)' }}

View File

@ -66,3 +66,22 @@ export const EmptyPageList = ({
</div>
);
};
export const EmptyCollectionList = ({ heading }: { heading: ReactNode }) => {
const t = useAFFiNEI18N();
return (
<div className={styles.pageListEmptyStyle}>
{heading && <div>{heading}</div>}
<Empty title={t['com.affine.emptyDesc.collection']()} />
</div>
);
};
export const EmptyTagList = ({ heading }: { heading: ReactNode }) => {
const t = useAFFiNEI18N();
return (
<div className={styles.pageListEmptyStyle}>
{heading && <div>{heading}</div>}
<Empty title={t['com.affine.emptyDesc.tag']()} />
</div>
);
};

View File

@ -0,0 +1,52 @@
import { TagListHeader, useTagMetas } from '@affine/core/components/page-list';
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { type LoaderFunction, redirect, useParams } from 'react-router-dom';
import { AllPage } from './all-page/all-page';
import { AllPageHeader } from './all-page/all-page-header';
import { EmptyPageList } from './page-list-empty';
export const loader: LoaderFunction = async args => {
if (!args.params.tagId) {
return redirect('/404');
}
return null;
};
export const Component = function TagPage() {
const params = useParams();
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const { tagUsageCounts } = useTagMetas(
currentWorkspace.blockSuiteWorkspace,
pageMetas
);
const isEmpty = useMemo(() => {
if (params.tagId) {
return tagUsageCounts[params.tagId] === 0;
}
return true;
}, [params.tagId, tagUsageCounts]);
return isEmpty ? (
<>
<AllPageHeader
workspace={currentWorkspace.blockSuiteWorkspace}
showCreateNew={false}
isDefaultFilter={true}
activeFilter={'tags'}
/>
<EmptyPageList
type="all"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
heading={<TagListHeader />}
/>
</>
) : (
<AllPage activeFilter="tags" />
);
};

View File

@ -1,9 +1,18 @@
import { toast } from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import {
currentCollectionAtom,
type ListItem,
ListTableHeader,
PageListItemRenderer,
TrashOperationCell,
VirtualizedPageList,
useFilteredPageMetas,
VirtualizedList,
} from '@affine/core/components/page-list';
import { pageHeaderColsDef } from '@affine/core/components/page-list/header-col-def';
import { Header } from '@affine/core/components/pure/header';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@ -16,12 +25,7 @@ import { useCallback } from 'react';
import { type LoaderFunction } from 'react-router-dom';
import { NIL } from 'uuid';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { Header } from '../../components/pure/header';
import { WindowsAppControls } from '../../components/pure/header/windows-app-controls';
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
import { EmptyPageList } from './page-list-empty';
import { useFilteredPageMetas } from './pages';
import * as styles from './trash-page.css';
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
@ -74,7 +78,8 @@ export const TrashPage = () => {
const t = useAFFiNEI18N();
const pageOperationsRenderer = useCallback(
(page: PageMeta) => {
(item: ListItem) => {
const page = item as PageMeta;
const onRestorePage = () => {
restoreFromTrash(page.id);
toast(
@ -94,20 +99,28 @@ export const TrashPage = () => {
/>
);
},
[permanentlyDeletePage, restoreFromTrash, t]
);
const pageItemRenderer = useCallback((item: ListItem) => {
return <PageListItemRenderer {...item} />;
}, []);
const pageHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={pageHeaderColsDef} />;
}, []);
return (
<div className={styles.root}>
<TrashHeader />
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
pages={filteredPageMetas}
<VirtualizedList
items={filteredPageMetas}
rowAsLink
groupBy={false}
isPreferredEdgeless={isPreferredEdgeless}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
pageOperationsRenderer={pageOperationsRenderer}
operationsRenderer={pageOperationsRenderer}
itemRenderer={pageItemRenderer}
headerRenderer={pageHeaderRenderer}
/>
) : (
<EmptyPageList

View File

@ -19,6 +19,10 @@ export const routes = [
path: 'collection/:collectionId',
lazy: () => import('./pages/workspace/collection'),
},
{
path: 'tag/:tagId',
lazy: () => import('./pages/workspace/tag'),
},
{
path: 'trash',
lazy: () => import('./pages/workspace/trash-page'),

View File

@ -554,9 +554,10 @@
"com.affine.collection.menu.edit": "Edit Collection",
"com.affine.collection.menu.rename": "Rename",
"com.affine.collectionBar.backToAll": "Back to all",
"com.affine.docs.header": "Docs",
"com.affine.collections.header": "Collections",
"com.affine.collections.empty.message": "No collections",
"com.affine.collections.empty.new-collection-button": "New collection",
"com.affine.collections.empty.new-collection-button": "New Collection",
"com.affine.confirmModal.button.cancel": "Cancel",
"com.affine.currentYear": "Current Year",
"com.affine.deleteLeaveWorkspace.description": "Delete workspace from this device and optionally delete all data.",
@ -601,6 +602,8 @@
"com.affine.editCollectionName.name.placeholder": "Collection Name",
"com.affine.editorModeSwitch.tooltip": "Switch",
"com.affine.emptyDesc": "There's no page here yet",
"com.affine.emptyDesc.collection": "There's no collection here yet",
"com.affine.emptyDesc.tag": "There's no tag here yet",
"com.affine.enableAffineCloudModal.button.cancel": "Cancel",
"com.affine.expired.page.subtitle": "Please request a new reset password link.",
"com.affine.expired.page.title": "This link has expired...",
@ -714,6 +717,12 @@
"com.affine.page.toolbar.selected": "<0>{{count}}</0> selected",
"com.affine.page.toolbar.selected_one": "<0>{{count}}</0> page selected",
"com.affine.page.toolbar.selected_others": "<0>{{count}}</0> page(s) selected",
"com.affine.collection.toolbar.selected": "<0>{{count}}</0> selected",
"com.affine.collection.toolbar.selected_one": "<0>{{count}}</0> collection selected",
"com.affine.collection.toolbar.selected_others": "<0>{{count}}</0> collection(s) selected",
"com.affine.tag.toolbar.selected": "<0>{{count}}</0> selected",
"com.affine.tag.toolbar.selected_one": "<0>{{count}}</0> tag selected",
"com.affine.tag.toolbar.selected_others": "<0>{{count}}</0> tag(s) selected",
"com.affine.pageMode": "Page Mode",
"com.affine.pageMode.all": "all",
"com.affine.pageMode.edgeless": "Edgeless",

View File

@ -212,7 +212,7 @@ test('select two pages and delete', async ({ page }) => {
);
// click delete button
await page.locator('[data-testid="page-list-toolbar-delete"]').click();
await page.locator('[data-testid="list-toolbar-delete"]').click();
// the confirm dialog should appear
await expect(page.getByText('Delete 2 pages?')).toBeVisible();

View File

@ -18,7 +18,7 @@ const removeOnboardingPages = async (page: Page) => {
await page.getByTestId('page-list-header-selection-checkbox').click();
// click again to select all
await page.getByTestId('page-list-header-selection-checkbox').click();
await page.getByTestId('page-list-toolbar-delete').click();
await page.getByTestId('list-toolbar-delete').click();
// confirm delete
await page.getByTestId('confirm-delete-page').click();
};

View File

@ -1,14 +1,15 @@
import { toast } from '@affine/component';
import {
FloatingToolbar,
List,
type ListItem,
type ListProps,
ListScrollContainer,
NewPageButton,
OperationCell,
type OperationCellProps,
PageList,
PageListItem,
type PageListItemProps,
type PageListProps,
PageListScrollContainer,
PageOperationCell,
type PageOperationCellProps,
PageTags,
type PageTagsProps,
} from '@affine/core/components/page-list';
@ -29,9 +30,9 @@ export default {
},
} satisfies Meta;
export const AffineOperationCell: StoryFn<OperationCellProps> = ({
export const AffineOperationCell: StoryFn<PageOperationCellProps> = ({
...props
}) => <OperationCell {...props} />;
}) => <PageOperationCell {...props} />;
AffineOperationCell.args = {
favorite: false,
@ -159,11 +160,11 @@ const testTags = [
},
];
export const ListItem: StoryFn<PageListItemProps> = props => (
export const PageListItemComponent: StoryFn<PageListItemProps> = props => (
<PageListItem {...props}></PageListItem>
);
ListItem.args = {
PageListItemComponent.args = {
pageId: 'test-page-id',
title: 'Test Page Title',
preview:
@ -178,7 +179,7 @@ ListItem.args = {
selected: true,
};
ListItem.decorators = [withRouter];
PageListItemComponent.decorators = [withRouter];
export const ListItemTags: StoryFn<PageTagsProps> = props => (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
@ -195,15 +196,18 @@ ListItemTags.args = {
maxItems: 5,
};
export const PageListStory: StoryFn<PageListProps> = (props, { loaded }) => {
export const PageListStory: StoryFn<ListProps<ListItem>> = (
props,
{ loaded }
) => {
return (
<PageListScrollContainer
<ListScrollContainer
style={{
height: '100vh',
}}
>
<PageList {...props} {...loaded}></PageList>
</PageListScrollContainer>
<List {...props} {...loaded}></List>
</ListScrollContainer>
);
};