refactor(component): virtual rendering page list (#4775)

Co-authored-by: Joooye_34 <Joooye1991@gmail.com>
This commit is contained in:
Peng Xiao 2023-11-02 22:21:01 +08:00 committed by GitHub
parent a3906bf92b
commit 65321e39cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 997 additions and 589 deletions

View File

@ -48,6 +48,8 @@
"dayjs": "^1.11.10",
"foxact": "^0.2.20",
"jotai": "^2.4.3",
"jotai-effect": "^0.2.2",
"jotai-scope": "^0.4.0",
"lit": "^2.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
@ -62,6 +64,7 @@
"react-is": "^18.2.0",
"react-paginate": "^8.2.0",
"react-router-dom": "^6.16.0",
"react-virtuoso": "^4.6.2",
"rxjs": "^7.8.1",
"uuid": "^9.0.1"
},

View File

@ -6,8 +6,6 @@ import {
type MouseEventHandler,
type PropsWithChildren,
type ReactNode,
useEffect,
useRef,
} from 'react';
import * as styles from './floating-toolbar.css';
@ -16,8 +14,6 @@ interface FloatingToolbarProps {
className?: string;
style?: CSSProperties;
open?: boolean;
// if dbclick outside of the panel, close the toolbar
onOpenChange?: (open: boolean) => void;
}
interface FloatingToolbarButtonProps {
@ -36,49 +32,7 @@ export function FloatingToolbar({
style,
className,
open,
onOpenChange,
}: PropsWithChildren<FloatingToolbarProps>) {
const contentRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false);
// todo: move dbclick / esc to close to page list instead
useEffect(() => {
animatingRef.current = true;
const timer = setTimeout(() => {
animatingRef.current = false;
}, 200);
if (open) {
// when dbclick outside of the panel or typing ESC, close the toolbar
const dbcHandler = (e: MouseEvent) => {
if (
!contentRef.current?.contains(e.target as Node) &&
!animatingRef.current
) {
// close the toolbar
onOpenChange?.(false);
}
};
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !animatingRef.current) {
onOpenChange?.(false);
}
};
document.addEventListener('dblclick', dbcHandler);
document.addEventListener('keydown', escHandler);
return () => {
clearTimeout(timer);
document.removeEventListener('dblclick', dbcHandler);
document.removeEventListener('keydown', escHandler);
};
}
return () => {
clearTimeout(timer);
};
}, [onOpenChange, open]);
return (
<Popover.Root open={open}>
{/* Having Anchor here to let Popover to calculate the position of the place it is being used */}
@ -86,9 +40,7 @@ export function FloatingToolbar({
<Popover.Portal>
{/* always pop up on top for now */}
<Popover.Content side="top" className={styles.popoverContent}>
<Toolbar.Root ref={contentRef} className={clsx(styles.root)}>
{children}
</Toolbar.Root>
<Toolbar.Root className={clsx(styles.root)}>{children}</Toolbar.Root>
</Popover.Content>
</Popover.Portal>
</Popover.Root>

View File

@ -11,3 +11,4 @@ export * from './types';
export * from './use-collection-manager';
export * from './utils';
export * from './view';
export * from './virtualized-page-list';

View File

@ -52,8 +52,9 @@ export const header = style({
padding: '0px 16px 0px 6px',
gap: 4,
height: '28px',
background: 'var(--affine-background-primary-color)',
':hover': {
background: 'var(--affine-hover-color)',
background: 'var(--affine-hover-color-filled)',
},
userSelect: 'none',
});

View File

@ -6,14 +6,19 @@ import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons';
import type { PageMeta, Workspace } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
import { PagePreview } from './page-content-preview';
import * as styles from './page-group.css';
import { PageListItem } from './page-list-item';
import { pageListPropsAtom, selectionStateAtom } from './scoped-atoms';
import {
pageGroupCollapseStateAtom,
pageListPropsAtom,
selectionStateAtom,
useAtom,
useAtomValue,
} from './scoped-atoms';
import type {
PageGroupDefinition,
PageGroupProps,
@ -21,7 +26,7 @@ import type {
PageListProps,
} from './types';
import { type DateKey } from './types';
import { betweenDaysAgo, withinDaysAgo } from './utils';
import { betweenDaysAgo, shallowEqual, withinDaysAgo } from './utils';
// todo: optimize date matchers
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
@ -57,6 +62,7 @@ const pageGroupDefinitions = {
createDate: getDateGroupDefinitions('createDate'),
updatedDate: getDateGroupDefinitions('updatedDate'),
// add more here later
// todo: some page group definitions maybe dynamic
};
export function pagesToPageGroups(
@ -101,6 +107,78 @@ export function pagesToPageGroups(
return groups;
}
export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom);
const collapsed = collapseState[id];
const onExpandedClicked: MouseEventHandler = useCallback(
e => {
e.stopPropagation();
e.preventDefault();
setCollapseState(v => ({ ...v, [id]: !v[id] }));
},
[id, setCollapseState]
);
const selectionState = useAtomValue(selectionStateAtom);
const selectedItems = useMemo(() => {
const selectedPageIds = selectionState.selectedPageIds ?? [];
return items.filter(item => selectedPageIds.includes(item.id));
}, [items, selectionState.selectedPageIds]);
const allSelected = useMemo(() => {
return items.every(
item => selectionState.selectedPageIds?.includes(item.id)
);
}, [items, selectionState.selectedPageIds]);
const onSelectAll = useCallback(() => {
const nonCurrentGroupIds =
selectionState.selectedPageIds?.filter(
id => !items.map(item => item.id).includes(id)
) ?? [];
const newSelectedPageIds = allSelected
? nonCurrentGroupIds
: [...nonCurrentGroupIds, ...items.map(item => item.id)];
selectionState.onSelectedPageIdsChange?.(newSelectedPageIds);
}, [items, selectionState, allSelected]);
const t = useAFFiNEI18N();
return label ? (
<div data-testid="page-list-group-header" className={styles.header}>
<div
role="button"
onClick={onExpandedClicked}
data-testid="page-list-group-header-collapsed-button"
className={styles.collapsedIconContainer}
>
<ToggleCollapseIcon
className={styles.collapsedIcon}
data-collapsed={!!collapsed}
/>
</div>
<div className={styles.headerLabel}>{label}</div>
{selectionState.selectionActive ? (
<div className={styles.headerCount}>
{selectedItems.length}/{items.length}
</div>
) : null}
<div className={styles.spacer} />
{selectionState.selectionActive ? (
<button className={styles.selectAllButton} onClick={onSelectAll}>
{t[
allSelected
? 'com.affine.page.group-header.clear'
: 'com.affine.page.group-header.select-all'
]()}
</button>
) : null}
</div>
) : null;
};
export const PageGroup = ({ id, items, label }: PageGroupProps) => {
const [collapsed, setCollapsed] = useState(false);
const onExpandedClicked: MouseEventHandler = useCallback(e => {
@ -173,7 +251,7 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
// todo: optimize how to render page meta list item
const requiredPropNames = [
'blockSuiteWorkspace',
'clickMode',
'rowAsLink',
'isPreferredEdgeless',
'pageOperationsRenderer',
'selectedPageIds',
@ -185,13 +263,17 @@ type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
selectable: boolean;
};
const listPropsAtom = selectAtom(pageListPropsAtom, props => {
return Object.fromEntries(
requiredPropNames.map(name => [name, props[name]])
) as RequiredProps;
});
const listPropsAtom = selectAtom(
pageListPropsAtom,
props => {
return Object.fromEntries(
requiredPropNames.map(name => [name, props[name]])
) as RequiredProps;
},
shallowEqual
);
const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
export const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
const props = useAtomValue(listPropsAtom);
const { selectionActive } = useAtomValue(selectionStateAtom);
return (
@ -247,10 +329,10 @@ function pageMetaToPageItemProp(
? new Date(pageMeta.updatedDate)
: undefined,
to:
props.clickMode === 'link'
props.rowAsLink && !props.selectable
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
: undefined,
onClick: props.clickMode === 'select' ? toggleSelection : undefined,
onClick: props.selectable ? toggleSelection : undefined,
icon: props.isPreferredEdgeless?.(pageMeta.id) ? (
<EdgelessIcon />
) : (

View File

@ -0,0 +1,210 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import clsx from 'clsx';
import { selectAtom } from 'jotai/utils';
import {
type MouseEventHandler,
type ReactNode,
useCallback,
useMemo,
} from 'react';
import { Checkbox, type CheckboxProps } from '../../ui/checkbox';
import * as styles from './page-list.css';
import {
pageListHandlersAtom,
pageListPropsAtom,
pagesAtom,
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;
};
// 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 [selectionState, setSelectionState] = useAtom(selectionStateAtom);
const pages = useAtomValue(pagesAtom);
const onActivateSelection: MouseEventHandler = useCallback(
e => {
stopPropagation(e);
setSelectionState(true);
},
[setSelectionState]
);
const handlers = useAtomValue(pageListHandlersAtom);
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
(e, checked) => {
stopPropagation(e);
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
},
[handlers, pages]
);
if (!selectionState.selectable) {
return null;
}
return (
<div
className={styles.headerTitleSelectionIconWrapper}
onClick={onActivateSelection}
>
{!selectionState.selectionActive ? (
<MultiSelectIcon />
) : (
<Checkbox
checked={selectionState.selectedPageIds?.length === pages.length}
indeterminate={
selectionState.selectedPageIds &&
selectionState.selectedPageIds.length > 0 &&
selectionState.selectedPageIds.length < pages.length
}
onChange={onChange}
/>
)}
</div>
);
};
const PageListHeaderTitleCell = () => {
const t = useAFFiNEI18N();
return (
<div className={styles.headerTitleCell}>
<PageListHeaderCheckbox />
{t['Title']()}
</div>
);
};
const hideHeaderAtom = selectAtom(pageListPropsAtom, props => props.hideHeader);
// the table header for page list
export const PageListTableHeader = () => {
const t = useAFFiNEI18N();
const showOperations = useAtomValue(showOperationsAtom);
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]);
if (hideHeader) {
return false;
}
return (
<div
className={clsx(styles.tableHeader)}
data-selectable={selectionState.selectable}
data-selection-active={selectionState.selectionActive}
>
{headerCols.map(col => {
return (
<PageListHeaderCell
flex={col.flex}
alignment={col.alignment}
key={col.key}
sortKey={col.key as keyof PageMeta}
sortable={col.sortable}
style={{ overflow: 'visible' }}
hideInSmallContainer={col.hideInSmallContainer}
>
{col.content}
</PageListHeaderCell>
);
})}
</div>
);
};

View File

@ -84,7 +84,11 @@ const PageCreateDateCell = ({
createDate,
}: Pick<PageListItemProps, 'createDate'>) => {
return (
<div data-testid="page-list-item-date" className={styles.dateCell}>
<div
data-testid="page-list-item-date"
data-date-raw={createDate}
className={styles.dateCell}
>
{formatDate(createDate)}
</div>
);
@ -94,7 +98,11 @@ const PageUpdatedDateCell = ({
updatedDate,
}: Pick<PageListItemProps, 'updatedDate'>) => {
return (
<div data-testid="page-list-item-date" className={styles.dateCell}>
<div
data-testid="page-list-item-date"
data-date-raw={updatedDate}
className={styles.dateCell}
>
{updatedDate ? formatDate(updatedDate) : '-'}
</div>
);

View File

@ -5,8 +5,8 @@ import * as itemStyles from './page-list-item.css';
export const listRootContainer = createContainer('list-root-container');
export const pageListScrollContainer = style({
overflowY: 'auto',
width: '100%',
flex: 1,
});
export const root = style({
@ -23,7 +23,9 @@ export const groupsContainer = style({
rowGap: '16px',
});
export const header = style({
export const heading = style({});
export const tableHeader = style({
display: 'flex',
alignItems: 'center',
padding: '10px 6px 10px 16px',
@ -37,7 +39,7 @@ export const header = style({
transform: 'translateY(-0.5px)', // fix sticky look through issue
});
globalStyle(`[data-has-scroll-top=true] ${header}`, {
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
boxShadow: '0 1px var(--affine-border-color)',
});
@ -73,13 +75,22 @@ export const headerTitleCell = style({
export const headerTitleSelectionIconWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
justifyContent: 'flex-start',
fontSize: '16px',
selectors: {
[`${tableHeader}[data-selectable=toggle] &`]: {
width: 32,
},
[`${tableHeader}[data-selection-active=true] &`]: {
width: 24,
},
},
});
export const headerCellSortIcon = style({
width: '14px',
height: '14px',
display: 'inline-flex',
fontSize: 14,
color: 'var(--affine-icon-color)',
});
export const colWrapper = style({
@ -104,7 +115,7 @@ export const favoriteCell = style({
flexShrink: 0,
opacity: 0,
selectors: {
[`&[data-favorite], &${itemStyles.root}:hover &`]: {
[`&[data-favorite], ${itemStyles.root}:hover &`]: {
opacity: 1,
},
},

View File

@ -1,86 +1,146 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import clsx from 'clsx';
import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useHydrateAtoms } from 'jotai/utils';
import {
type ForwardedRef,
forwardRef,
type MouseEventHandler,
memo,
type PropsWithChildren,
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import { Checkbox, type CheckboxProps } from '../../ui/checkbox';
import { Scrollable } from '../../ui/scrollbar';
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
import { PageGroup } from './page-group';
import { PageListTableHeader } from './page-header';
import * as styles from './page-list.css';
import {
pageGroupsAtom,
pageListHandlersAtom,
pageListPropsAtom,
pagesAtom,
PageListProvider,
selectionStateAtom,
showOperationsAtom,
sorterAtom,
useAtom,
useAtomValue,
useSetAtom,
} from './scoped-atoms';
import type { PageListHandle, PageListProps } from './types';
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
/**
* Given a list of pages, render a list of pages
*/
export const PageList = forwardRef<PageListHandle, PageListProps>(
function PageListHandle(props, ref) {
function PageList(props, ref) {
return (
<Provider>
<PageListInner {...props} handleRef={ref} />
</Provider>
// 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>
);
}
);
const PageListInner = ({
handleRef,
...props
}: PageListProps & { handleRef: ForwardedRef<PageListHandle> }) => {
// push pageListProps to the atom so that downstream components can consume it
useHydrateAtoms([[pageListPropsAtom, props]], {
// note: by turning on dangerouslyForceHydrate, downstream component need to use selectAtom to consume the atom
// note2: not using it for now because it will cause some other issues
// dangerouslyForceHydrate: true,
});
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
const setPageListSelectionState = useSetAtom(selectionStateAtom);
// 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 [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
useEffect(() => {
setPageListPropsAtom(props);
}, [props, setPageListPropsAtom]);
useImperativeHandle(
handleRef,
() => {
return {
toggleSelectable: () => {
setPageListSelectionState(false);
},
if (
selectionState.selectionActive &&
selectionState.selectable === 'toggle'
) {
const startTime = Date.now();
const dblClickHandler = (e: MouseEvent) => {
if (Date.now() - startTime < 200) {
return;
}
const target = e.target as HTMLElement;
// skip if event target is inside of a button or input
// or within a toolbar (like page list floating toolbar)
if (
target.tagName === 'BUTTON' ||
target.tagName === 'INPUT' ||
(e.target as HTMLElement).closest('button, input, [role="toolbar"]')
) {
return;
}
setSelectionActive(false);
};
},
[setPageListSelectionState]
);
const escHandler = (e: KeyboardEvent) => {
if (Date.now() - startTime < 200) {
return;
}
if (e.key === 'Escape') {
setSelectionActive(false);
}
};
document.addEventListener('dblclick', dblClickHandler);
document.addEventListener('keydown', escHandler);
return () => {
document.removeEventListener('dblclick', dblClickHandler);
document.removeEventListener('keydown', escHandler);
};
}
return;
}, [
selectionState.selectable,
selectionState.selectionActive,
setSelectionActive,
]);
};
export const PageListInnerWrapper = memo(
({
handleRef,
children,
onSelectionActiveChange,
...props
}: PropsWithChildren<
PageListProps & { handleRef: ForwardedRef<PageListHandle> }
>) => {
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
const [selectionState, setPageListSelectionState] =
useAtom(selectionStateAtom);
usePageSelectionStateEffect();
useEffect(() => {
setPageListPropsAtom(props);
}, [props, setPageListPropsAtom]);
useEffect(() => {
onSelectionActiveChange?.(!!selectionState.selectionActive);
}, [onSelectionActiveChange, selectionState.selectionActive]);
useImperativeHandle(
handleRef,
() => {
return {
toggleSelectable: () => {
setPageListSelectionState(false);
},
};
},
[setPageListSelectionState]
);
return children;
}
);
PageListInnerWrapper.displayName = 'PageListInnerWrapper';
const PageListInner = (props: PageListProps) => {
const groups = useAtomValue(pageGroupsAtom);
const hideHeader = props.hideHeader;
return (
<div className={clsx(props.className, styles.root)}>
{!hideHeader ? <PageListHeader /> : null}
{!hideHeader ? <PageListTableHeader /> : null}
<div className={styles.groupsContainer}>
{groups.map(group => (
<PageGroup key={group.id} {...group} />
@ -90,176 +150,6 @@ const PageListInner = ({
);
};
type HeaderCellProps = ColWrapperProps & {
sortKey: keyof PageMeta;
sortable?: boolean;
};
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}
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;
};
// 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 [selectionState, setSelectionState] = useAtom(selectionStateAtom);
const pages = useAtomValue(pagesAtom);
const onActivateSelection: MouseEventHandler = useCallback(
e => {
stopPropagation(e);
setSelectionState(true);
},
[setSelectionState]
);
const handlers = useAtomValue(pageListHandlersAtom);
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
(e, checked) => {
stopPropagation(e);
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
},
[handlers, pages]
);
if (!selectionState.selectable) {
return null;
}
return (
<div
className={styles.headerTitleSelectionIconWrapper}
onClick={onActivateSelection}
>
{!selectionState.selectionActive ? (
<MultiSelectIcon />
) : (
<Checkbox
checked={selectionState.selectedPageIds?.length === pages.length}
indeterminate={
selectionState.selectedPageIds &&
selectionState.selectedPageIds.length > 0 &&
selectionState.selectedPageIds.length < pages.length
}
onChange={onChange}
/>
)}
</div>
);
};
const PageListHeaderTitleCell = () => {
const t = useAFFiNEI18N();
return (
<div className={styles.headerTitleCell}>
<PageListHeaderCheckbox />
{t['Title']()}
</div>
);
};
export const PageListHeader = () => {
const t = useAFFiNEI18N();
const showOperations = useAtomValue(showOperationsAtom);
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]);
return (
<div className={clsx(styles.header)}>
{headerCols.map(col => {
return (
<PageListHeaderCell
flex={col.flex}
alignment={col.alignment}
key={col.key}
sortKey={col.key as keyof PageMeta}
sortable={col.sortable}
style={{ overflow: 'visible' }}
hideInSmallContainer={col.hideInSmallContainer}
>
{col.content}
</PageListHeaderCell>
);
})}
</div>
);
};
interface PageListScrollContainerProps {
className?: string;
style?: React.CSSProperties;
@ -287,14 +177,14 @@ export const PageListScrollContainer = forwardRef<
);
return (
<div
<Scrollable.Root
style={style}
ref={setNodeRef}
data-has-scroll-top={hasScrollTop}
className={clsx(styles.pageListScrollContainer, className)}
>
{children}
</div>
<Scrollable.Viewport ref={setNodeRef}>{children}</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
);
});

View File

@ -29,6 +29,10 @@ const tagColorMap = (color: string) => {
'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;
};
@ -109,7 +113,6 @@ export const PageTags = ({
// @ts-expect-error it's fine
'--hover-max-width': sanitizedWidthOnHover,
}}
onClick={stopPropagation}
>
<div
style={{
@ -123,7 +126,12 @@ export const PageTags = ({
{tagsNormal}
</div>
{maxItems && tags.length > maxItems ? (
<Menu items={tagsInPopover}>
<Menu
items={tagsInPopover}
contentOptions={{
onClick: stopPropagation,
}}
>
<div className={styles.showMoreTag}>
<MoreHorizontalIcon />
</div>

View File

@ -2,28 +2,40 @@ 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 './page-group';
import type { PageListProps, PageMetaRecord } from './types';
import type {
PageListProps,
PageMetaRecord,
VirtualizedPageListProps,
} 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>();
export const pageListPropsAtom = atom<
PageListProps & Partial<VirtualizedPageListProps>
>();
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
const selectionActiveAtom = atom(false);
export const selectionStateAtom = atom(
get => {
const baseAtom = selectAtom(pageListPropsAtom, props => {
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
return {
selectable,
selectedPageIds,
onSelectedPageIdsChange,
};
});
const baseAtom = selectAtom(
pageListPropsAtom,
props => {
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
return {
selectable,
selectedPageIds,
onSelectedPageIdsChange,
};
},
shallowEqual
);
const baseState = get(baseAtom);
const selectionActive =
baseState.selectable === 'toggle'
@ -39,18 +51,27 @@ export const selectionStateAtom = atom(
}
);
// id -> isCollapsed
// maybe reset on page on unmount?
export const pageGroupCollapseStateAtom = atom<Record<string, boolean>>({});
// get handlers from pageListPropsAtom
export const pageListHandlersAtom = selectAtom(pageListPropsAtom, props => {
const { onSelectedPageIdsChange, onDragStart, onDragEnd } = props;
export const pageListHandlersAtom = selectAtom(
pageListPropsAtom,
props => {
const { onSelectedPageIdsChange } = props;
return {
onSelectedPageIdsChange,
};
},
shallowEqual
);
return {
onSelectedPageIdsChange,
onDragStart,
onDragEnd,
};
});
export const pagesAtom = selectAtom(pageListPropsAtom, props => props.pages);
export const pagesAtom = selectAtom(
pageListPropsAtom,
props => props.pages,
shallowEqual
);
export const showOperationsAtom = selectAtom(
pageListPropsAtom,
@ -177,3 +198,10 @@ export const pageGroupsAtom = atom(get => {
}
return pagesToPageGroups(sorter.pages, groupBy);
});
export const {
Provider: PageListProvider,
useAtom,
useAtomValue,
useSetAtom,
} = createIsolation();

View File

@ -40,23 +40,27 @@ export interface PageListProps {
// required data:
pages: PageMeta[];
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;
clickMode?: 'select' | 'link'; // select => click to select; link => click to navigate
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;
onSelectionActiveChange?: (active: boolean) => void;
draggable?: boolean; // whether or not to allow dragging this page item
onDragStart?: (pageId: string) => void;
onDragEnd?: (pageId: string) => void;
// 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;
}
export interface VirtualizedPageListProps extends PageListProps {
heading?: ReactNode; // the user provided heading part (non sticky, above the original header)
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 {
toggleSelectable: () => void;
}

View File

@ -0,0 +1,3 @@
// add dblclick & esc to document when page selection is active
//
export function usePageSelectionEvents() {}

View File

@ -1,105 +0,0 @@
import { useState } from 'react';
type SorterConfig<
T extends Record<string | number | symbol, unknown> = Record<
string | number | symbol,
unknown
>,
> = {
data: T[];
key: keyof T;
order: 'asc' | 'desc' | 'none';
sortingFn?: (
ctx: {
key: keyof T;
order: 'asc' | 'desc';
},
a: T,
b: T
) => number;
};
const defaultSortingFn: SorterConfig['sortingFn'] = (ctx, a, b) => {
const valA = a[ctx.key];
const valB = b[ctx.key];
const revert = ctx.order === 'desc';
const revertSymbol = revert ? -1 : 1;
if (typeof valA === 'string' && typeof valB === 'string') {
return valA.localeCompare(valB) * revertSymbol;
}
if (typeof valA === 'number' && typeof valB === 'number') {
return valA - valB * revertSymbol;
}
if (valA instanceof Date && valB instanceof Date) {
return (valA.getTime() - valB.getTime()) * revertSymbol;
}
if (!valA) {
return -1 * revertSymbol;
}
if (!valB) {
return 1 * revertSymbol;
}
if (Array.isArray(valA) && Array.isArray(valB)) {
return (valA.length - valB.length) * revertSymbol;
}
console.warn(
'Unsupported sorting type! Please use custom sorting function.',
valA,
valB
);
return 0;
};
export const useSorter = <T extends Record<keyof any, unknown>>({
data,
sortingFn = defaultSortingFn,
...defaultSorter
}: SorterConfig<T> & { order: 'asc' | 'desc' }) => {
const [sorter, setSorter] = useState<Omit<SorterConfig<T>, 'data'>>({
...defaultSorter,
// We should not show sorting icon at first time
order: 'none',
});
const sortCtx =
sorter.order === 'none'
? {
key: defaultSorter.key,
order: defaultSorter.order,
}
: {
key: sorter.key,
order: sorter.order,
};
const compareFn = (a: T, b: T) => sortingFn(sortCtx, a, b);
const sortedData = data.sort(compareFn);
const shiftOrder = (key?: keyof T) => {
const orders = ['asc', 'desc', 'none'] as const;
if (key && key !== sorter.key) {
// Key changed
setSorter({
...sorter,
key,
order: orders[0],
});
return;
}
setSorter({
...sorter,
order: orders[(orders.indexOf(sorter.order) + 1) % orders.length],
});
};
return {
data: sortedData,
order: sorter.order,
key: sorter.order !== 'none' ? sorter.key : null,
/**
* @deprecated In most cases, we no necessary use `updateSorter` directly.
*/
updateSorter: (newVal: Partial<SorterConfig<T>>) =>
setSorter({ ...sorter, ...newVal }),
shiftOrder,
resetSorter: () => setSorter(defaultSorter),
};
};

View File

@ -124,10 +124,10 @@ export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
export const withinDaysAgo = (date: Date, days: number): boolean => {
const startDate = new Date();
const day = startDate.getDay();
const day = startDate.getDate();
const month = startDate.getMonth();
const year = startDate.getFullYear();
return new Date(year, month, day - days) <= date;
return new Date(year, month, day - days + 1) <= date;
};
export const betweenDaysAgo = (
@ -145,3 +145,38 @@ export function stopPropagation(event: BaseSyntheticEvent) {
export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) {
event.stopPropagation();
}
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
export function shallowEqual(objA: any, objB: any) {
if (Object.is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!Object.is(objA[keysA[i]], objB[keysA[i]])
) {
return false;
}
}
return true;
}

View File

@ -1,8 +1,7 @@
import {
type AllPageListConfig,
FilterList,
PageList,
PageListScrollContainer,
VirtualizedPageList,
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@ -104,25 +103,22 @@ export const PagesMode = ({
</div>
) : null}
{searchedList.length ? (
<PageListScrollContainer>
<PageList
clickMode="select"
className={styles.pageList}
pages={searchedList}
groupBy={false}
blockSuiteWorkspace={allPageListConfig.workspace}
selectable
onSelectedPageIdsChange={ids => {
updateCollection({
...collection,
allowList: ids,
});
}}
pageOperationsRenderer={pageOperationsRenderer}
selectedPageIds={collection.allowList}
isPreferredEdgeless={allPageListConfig.isEdgeless}
></PageList>
</PageListScrollContainer>
<VirtualizedPageList
className={styles.pageList}
pages={searchedList}
groupBy={false}
blockSuiteWorkspace={allPageListConfig.workspace}
selectable
onSelectedPageIdsChange={ids => {
updateCollection({
...collection,
allowList: ids,
});
}}
pageOperationsRenderer={pageOperationsRenderer}
selectedPageIds={collection.allowList}
isPreferredEdgeless={allPageListConfig.isEdgeless}
></VirtualizedPageList>
) : (
<EmptyList search={searchText} />
)}

View File

@ -248,7 +248,6 @@ export const RulesMode = ({
{rulesPages.length > 0 ? (
<PageList
hideHeader
clickMode="select"
className={styles.resultPages}
pages={rulesPages}
groupBy={false}
@ -269,7 +268,6 @@ export const RulesMode = ({
</div>
<PageList
hideHeader
clickMode="select"
className={styles.resultPages}
pages={allowListPages}
groupBy={false}

View File

@ -6,9 +6,9 @@ import { Menu } from '@toeverything/components/menu';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import { VirtualizedPageList } from '../..';
import { FilterList } from '../../filter';
import { VariableSelect } from '../../filter/vars';
import { PageList, PageListScrollContainer } from '../../page-list';
import { AffineShapeIcon } from '../affine-shape';
import type { AllPageListConfig } from './edit-collection';
import * as styles from './edit-collection.css';
@ -93,19 +93,17 @@ export const SelectPage = ({
</div>
) : null}
{searchedList.length ? (
<PageListScrollContainer>
<PageList
clickMode="select"
className={styles.pageList}
pages={searchedList}
blockSuiteWorkspace={allPageListConfig.workspace}
selectable
onSelectedPageIdsChange={onChange}
selectedPageIds={value}
isPreferredEdgeless={allPageListConfig.isEdgeless}
pageOperationsRenderer={allPageListConfig.favoriteRender}
></PageList>
</PageListScrollContainer>
<VirtualizedPageList
className={styles.pageList}
pages={searchedList}
blockSuiteWorkspace={allPageListConfig.workspace}
selectable
groupBy={false}
onSelectedPageIdsChange={onChange}
selectedPageIds={value}
isPreferredEdgeless={allPageListConfig.isEdgeless}
pageOperationsRenderer={allPageListConfig.favoriteRender}
/>
) : (
<EmptyList search={searchText} />
)}

View File

@ -0,0 +1,218 @@
import type { PageMeta } from '@blocksuite/store';
import clsx from 'clsx';
import { selectAtom } from 'jotai/utils';
import {
forwardRef,
type HTMLAttributes,
type PropsWithChildren,
useCallback,
useMemo,
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Scrollable } from '../../ui/scrollbar';
import { PageGroupHeader, PageMetaListItemRenderer } from './page-group';
import { PageListTableHeader } from './page-header';
import { PageListInnerWrapper } from './page-list';
import * as styles from './page-list.css';
import {
pageGroupCollapseStateAtom,
pageGroupsAtom,
pageListPropsAtom,
PageListProvider,
useAtomValue,
} from './scoped-atoms';
import type {
PageGroupProps,
PageListHandle,
VirtualizedPageListProps,
} from './types';
// we have three item types for rendering rows in Virtuoso
type VirtuosoItemType =
| 'sticky-header'
| 'page-group-header'
| 'page-item'
| 'page-item-spacer';
interface BaseVirtuosoItem {
type: VirtuosoItemType;
}
interface VirtuosoItemStickyHeader extends BaseVirtuosoItem {
type: 'sticky-header';
}
interface VirtuosoItemPageItem extends BaseVirtuosoItem {
type: 'page-item';
data: PageMeta;
}
interface VirtuosoItemPageGroupHeader extends BaseVirtuosoItem {
type: 'page-group-header';
data: PageGroupProps;
}
interface VirtuosoPageItemSpacer extends BaseVirtuosoItem {
type: 'page-item-spacer';
data: {
height: number;
};
}
type VirtuosoItem =
| VirtuosoItemStickyHeader
| VirtuosoItemPageItem
| VirtuosoItemPageGroupHeader
| 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) {
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>
);
});
const headingAtom = selectAtom(pageListPropsAtom, 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);
return useMemo(() => {
const items: VirtuosoItem[] = [];
// 1.
// always put sticky header at the top
// the visibility of sticky header is inside of PageListTableHeader
items.push({
type: 'sticky-header',
});
// 2.
// iterate groups and add page items
for (const group of groups) {
// 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,
});
}
// 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,
});
// 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',
data: {
height: 4,
},
});
}
}
}
// add a spacer between groups (16px)
items.push({
type: 'page-item-spacer',
data: {
height: 16,
},
});
}
return items;
}, [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>>
>(({ children, ...props }, ref) => {
return (
<Scrollable.Root>
<Scrollable.Viewport {...props} ref={ref}>
{children}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
);
});
Scroller.displayName = 'Scroller';
const PageListInner = ({
atTopStateChange,
atTopThreshold,
...props
}: VirtualizedPageListProps) => {
const virtuosoItems = useVirtuosoItems();
const [atTop, setAtTop] = useState(false);
const handleAtTopStateChange = useCallback(
(atTop: boolean) => {
setAtTop(atTop);
atTopStateChange?.(atTop);
},
[atTopStateChange]
);
const components = useMemo(() => {
return {
Header: props.heading ? PageListHeading : undefined,
Scroller: Scroller,
};
}, [props.heading]);
return (
<Virtuoso<VirtuosoItem>
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
topItemCount={1} // sticky header
totalCount={virtuosoItems.length}
itemContent={itemContentRenderer}
className={clsx(props.className, styles.root)}
// todo: set a reasonable overscan value to avoid blank space?
// overscan={100}
/>
);
};

View File

@ -57,6 +57,7 @@ export const Checkbox = ({
return (
<div
className={clsx(styles.root, disabled && styles.disabled)}
role="checkbox"
{...otherProps}
>
{icon}

View File

@ -30,8 +30,7 @@ export const scrollableViewport = style({
});
globalStyle(`${scrollableViewport} > div`, {
maxWidth: '100%',
display: 'block !important',
display: 'contents !important',
});
export const scrollableContainer = style({
@ -44,7 +43,6 @@ export const scrollbar = style({
flexDirection: 'column',
userSelect: 'none',
touchAction: 'none',
marginRight: '4px',
width: 'var(--scrollbar-width)',
height: '100%',
opacity: 1,

View File

@ -1 +1,2 @@
export * from './scrollable';
export * from './scrollbar';

View File

@ -0,0 +1,64 @@
import * as ScrollArea from '@radix-ui/react-scroll-area';
import clsx from 'clsx';
import { forwardRef, type RefAttributes } from 'react';
import * as styles from './index.css';
export const ScrollableRoot = forwardRef<
HTMLDivElement,
ScrollArea.ScrollAreaProps & RefAttributes<HTMLDivElement>
>(({ children, className, ...props }, ref) => {
return (
<ScrollArea.Root
{...props}
ref={ref}
className={clsx(className, styles.scrollableContainerRoot)}
>
{children}
</ScrollArea.Root>
);
});
ScrollableRoot.displayName = 'ScrollableRoot';
export const ScrollableViewport = forwardRef<
HTMLDivElement,
ScrollArea.ScrollAreaViewportProps & RefAttributes<HTMLDivElement>
>(({ children, className, ...props }, ref) => {
return (
<ScrollArea.Viewport
{...props}
ref={ref}
className={clsx(className, styles.scrollableViewport)}
>
{children}
</ScrollArea.Viewport>
);
});
ScrollableViewport.displayName = 'ScrollableViewport';
export const ScrollableScrollbar = forwardRef<
HTMLDivElement,
ScrollArea.ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>
>(({ children, className, ...props }, ref) => {
return (
<ScrollArea.Scrollbar
orientation="vertical"
{...props}
ref={ref}
className={clsx(className, styles.scrollbar)}
>
<ScrollArea.Thumb className={styles.scrollbarThumb} />
{children}
</ScrollArea.Scrollbar>
);
});
ScrollableScrollbar.displayName = 'ScrollableScrollbar';
export const Scrollable = {
Root: ScrollableRoot,
Viewport: ScrollableViewport,
Scrollbar: ScrollableScrollbar,
};

View File

@ -24,6 +24,6 @@ export const workspaceType = style({
});
export const scrollbar = style({
transform: 'translateX(10px)',
transform: 'translateX(8px)',
width: '4px',
});

View File

@ -15,6 +15,8 @@ export const scrollContainer = style({
});
export const allPagesHeader = style({
height: 100,
alignItems: 'center',
padding: '48px 16px 20px 24px',
overflow: 'hidden',
display: 'flex',
@ -23,7 +25,7 @@ export const allPagesHeader = style({
});
export const allPagesHeaderTitle = style({
fontSize: 'var(--affine-font-h-3)',
fontSize: 'var(--affine-font-h-5)',
fontWeight: 500,
color: 'var(--affine-text-secondary-color)',
display: 'flex',

View File

@ -4,10 +4,9 @@ import {
FloatingToolbar,
NewPageButton as PureNewPageButton,
OperationCell,
PageList,
type PageListHandle,
PageListScrollContainer,
useCollectionManager,
VirtualizedPageList,
} from '@affine/component/page-list';
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
import { Trans } from '@affine/i18n';
@ -27,7 +26,6 @@ import clsx from 'clsx';
import {
type PropsWithChildren,
useCallback,
useEffect,
useMemo,
useRef,
useState,
@ -145,19 +143,12 @@ const usePageOperationsRenderer = () => {
const PageListFloatingToolbar = ({
selectedIds,
onClose,
open,
}: {
open: boolean;
selectedIds: string[];
onClose: () => void;
}) => {
const open = selectedIds.length > 0;
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
onClose();
}
},
[onClose]
);
const [currentWorkspace] = useCurrentWorkspace();
const { setTrashModal } = useTrashModalHelper(
currentWorkspace.blockSuiteWorkspace
@ -177,11 +168,7 @@ const PageListFloatingToolbar = ({
}, [pageMetas, selectedIds, setTrashModal]);
return (
<FloatingToolbar
className={styles.floatingToolbar}
open={open}
onOpenChange={handleOpenChange}
>
<FloatingToolbar className={styles.floatingToolbar} open={open}>
<FloatingToolbar.Item>
<Trans
i18nKey="com.affine.page.toolbar.selected"
@ -190,7 +177,7 @@ const PageListFloatingToolbar = ({
<div className={styles.toolbarSelectedNumber}>
{{ count: selectedIds.length } as any}
</div>
pages selected
selected
</Trans>
</FloatingToolbar.Item>
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
@ -245,9 +232,10 @@ export const AllPage = () => {
);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const pageListRef = useRef<PageListHandle>(null);
const containerRef = useRef<HTMLDivElement>(null);
const deselectAllAndToggleSelect = useCallback(() => {
setSelectedPageIds([]);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const hideFloatingToolbar = useCallback(() => {
pageListRef.current?.toggleSelectable();
}, []);
@ -257,24 +245,7 @@ export const AllPage = () => {
return selectedPageIds.filter(id => ids.includes(id));
}, [filteredPageMetas, selectedPageIds]);
const [showHeaderCreateNewPage, setShowHeaderCreateNewPage] = useState(false);
// when PageListScrollContainer scrolls above 40px, show the create new page button on header
useEffect(() => {
const container = containerRef.current;
if (container) {
const handleScroll = () => {
setTimeout(() => {
const scrollTop = container.scrollTop ?? 0;
setShowHeaderCreateNewPage(scrollTop > 40);
});
};
container.addEventListener('scroll', handleScroll);
return () => {
container.removeEventListener('scroll', handleScroll);
};
}
return;
}, []);
const [hideHeaderCreateNewPage, setHideHeaderCreateNewPage] = useState(true);
return (
<div className={styles.root}>
@ -289,7 +260,7 @@ export const AllPage = () => {
size="small"
className={clsx(
styles.headerCreateNewButton,
!showHeaderCreateNewPage && styles.headerCreateNewButtonHidden
hideHeaderCreateNewPage && styles.headerCreateNewButtonHidden
)}
>
<PlusIcon />
@ -297,37 +268,36 @@ export const AllPage = () => {
}
/>
) : null}
<PageListScrollContainer
ref={containerRef}
className={styles.scrollContainer}
>
<PageListHeader />
{filteredPageMetas.length > 0 ? (
<>
<PageList
ref={pageListRef}
selectable="toggle"
draggable
selectedPageIds={filteredSelectedPageIds}
onSelectedPageIdsChange={setSelectedPageIds}
pages={filteredPageMetas}
clickMode="link"
isPreferredEdgeless={isPreferredEdgeless}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
pageOperationsRenderer={pageOperationsRenderer}
/>
<PageListFloatingToolbar
selectedIds={filteredSelectedPageIds}
onClose={deselectAllAndToggleSelect}
/>
</>
) : (
<EmptyPageList
type="all"
{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}
/>
)}
</PageListScrollContainer>
<PageListFloatingToolbar
open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
selectedIds={filteredSelectedPageIds}
onClose={hideFloatingToolbar}
/>
</>
) : (
<EmptyPageList
type="all"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</div>
);
};

View File

@ -1,8 +1,7 @@
import { toast } from '@affine/component';
import {
PageList,
PageListScrollContainer,
TrashOperationCell,
VirtualizedPageList,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@ -61,30 +60,28 @@ export const TrashPage = () => {
);
return (
<>
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.TRASH,
}}
/>
<div className={styles.root}>
<PageListScrollContainer className={styles.scrollContainer}>
{filteredPageMetas.length > 0 ? (
<PageList
pages={filteredPageMetas}
clickMode="link"
groupBy={false}
isPreferredEdgeless={isPreferredEdgeless}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
pageOperationsRenderer={pageOperationsRenderer}
/>
) : (
<EmptyPageList
type="trash"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</PageListScrollContainer>
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.TRASH,
}}
/>
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
pages={filteredPageMetas}
rowAsLink
groupBy={false}
isPreferredEdgeless={isPreferredEdgeless}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
pageOperationsRenderer={pageOperationsRenderer}
/>
) : (
<EmptyPageList
type="trash"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</div>
</>
);

View File

@ -394,8 +394,8 @@
"com.affine.all-pages.header": "All Pages",
"com.affine.collections.header": "Collections",
"com.affine.page.group-header.select-all": "Select All",
"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.page.group-header.clear": "Clear Selection",
"com.affine.page.toolbar.selected": "<0>{{count}}</0> selected",
"com.affine.collection.allCollections": "All Collections",
"com.affine.collection.emptyCollection": "Empty Collection",
"com.affine.collection.emptyCollectionDescription": "Collection is a smart folder where you can manually add pages or automatically add pages through rules.",

View File

@ -4,11 +4,11 @@ import {
checkDatePicker,
checkDatePickerMonth,
checkFilterName,
checkPagesCount,
clickDatePicker,
createFirstFilter,
createPageWithTag,
fillDatePicker,
getPagesCount,
selectDateFromDatePicker,
selectMonthFromMonthPicker,
selectTag,
@ -89,8 +89,7 @@ test('allow creation of filters by created time', async ({ page }) => {
await clickNewPageButton(page);
await clickSideBarAllPageButton(page);
await waitForAllPagesLoad(page);
const pages = await page.locator('[data-testid="page-list-item"]').all();
const pageCount = pages.length;
const pageCount = await getPagesCount(page);
expect(pageCount).not.toBe(0);
await createFirstFilter(page, 'Created');
await checkFilterName(page, 'after');
@ -98,11 +97,11 @@ test('allow creation of filters by created time', async ({ page }) => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
await checkDatePicker(page, yesterday);
await checkPagesCount(page, 1);
expect(await getPagesCount(page)).toBe(1);
// change date
const today = new Date();
await fillDatePicker(page, today);
await checkPagesCount(page, 0);
expect(await getPagesCount(page)).toBe(0);
// change filter
await page.getByTestId('filter-name').click();
await page.getByTestId('filler-tag-before').click();
@ -110,7 +109,7 @@ test('allow creation of filters by created time', async ({ page }) => {
tomorrow.setDate(tomorrow.getDate() + 1);
await fillDatePicker(page, tomorrow);
await checkDatePicker(page, tomorrow);
await checkPagesCount(page, pageCount);
expect(await getPagesCount(page)).toBe(pageCount);
});
test('creation of filters by created time, then click date picker to modify the date', async ({
@ -121,8 +120,7 @@ test('creation of filters by created time, then click date picker to modify the
await clickNewPageButton(page);
await clickSideBarAllPageButton(page);
await waitForAllPagesLoad(page);
const pages = await page.locator('[data-testid="page-list-item"]').all();
const pageCount = pages.length;
const pageCount = await getPagesCount(page);
expect(pageCount).not.toBe(0);
await createFirstFilter(page, 'Created');
await checkFilterName(page, 'after');
@ -130,11 +128,11 @@ test('creation of filters by created time, then click date picker to modify the
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
await checkDatePicker(page, yesterday);
await checkPagesCount(page, 1);
expect(await getPagesCount(page)).toBe(1);
// change date
const today = new Date();
await selectDateFromDatePicker(page, today);
await checkPagesCount(page, 0);
expect(await getPagesCount(page)).toBe(0);
// change filter
await page.locator('[data-testid="filter-name"]').click();
await page.getByTestId('filler-tag-before').click();
@ -142,7 +140,7 @@ test('creation of filters by created time, then click date picker to modify the
tomorrow.setDate(tomorrow.getDate() + 1);
await selectDateFromDatePicker(page, tomorrow);
await checkDatePicker(page, tomorrow);
await checkPagesCount(page, pageCount);
expect(await getPagesCount(page)).toBe(pageCount);
});
test('use monthpicker to modify the month of datepicker', async ({ page }) => {
@ -174,8 +172,7 @@ test('allow creation of filters by tags', async ({ page }) => {
await waitForEditorLoad(page);
await clickSideBarAllPageButton(page);
await waitForAllPagesLoad(page);
const pages = await page.locator('[data-testid="page-list-item"]').all();
const pageCount = pages.length;
const pageCount = await getPagesCount(page);
expect(pageCount).not.toBe(0);
await createFirstFilter(page, 'Tags');
await checkFilterName(page, 'is not empty');
@ -188,12 +185,12 @@ test('allow creation of filters by tags', async ({ page }) => {
await createPageWithTag(page, { title: 'Page B', tags: ['B'] });
await clickSideBarAllPageButton(page);
await checkFilterName(page, 'is not empty');
await checkPagesCount(page, pagesWithTagsCount + 2);
expect(await getPagesCount(page)).toBe(pagesWithTagsCount + 2);
await changeFilter(page, 'contains all');
await checkPagesCount(page, pageCount + 2);
expect(await getPagesCount(page)).toBe(pageCount + 2);
await selectTag(page, 'A');
await checkPagesCount(page, 1);
expect(await getPagesCount(page)).toBe(1);
await changeFilter(page, 'does not contains all');
await selectTag(page, 'B');
await checkPagesCount(page, pageCount + 1);
expect(await getPagesCount(page)).toBe(pageCount + 1);
});

View File

@ -1,4 +1,5 @@
import { test } from '@affine-test/kit/playwright';
import { getPagesCount } from '@affine-test/kit/utils/filter';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
@ -48,13 +49,11 @@ test('create one workspace in the workspace list', async ({
await page.keyboard.press('Escape');
await clickSideBarAllPageButton(page);
await page.waitForTimeout(2000);
const pageList = page.locator('[data-testid=page-list-item]');
const result = await pageList.count();
const result = await getPagesCount(page);
expect(result).toBe(13);
await page.reload();
await page.waitForTimeout(4000);
const pageList1 = page.locator('[data-testid=page-list-item]');
const result1 = await pageList1.count();
const result1 = await getPagesCount(page);
expect(result1).toBe(13);
const currentWorkspace = await workspace.current();

View File

@ -38,10 +38,17 @@ const dateFormat = (date: Date) => {
return `${month} ${day}`;
};
export const checkPagesCount = async (page: Page, count: number) => {
expect(
(await page.locator('[data-testid="page-list-item"]').all()).length
).toBe(count);
// fixme: there could be multiple page lists in the Page
export const getPagesCount = async (page: Page) => {
const locator = page.locator('[data-testid="virtualized-page-list"]');
const pageListCount = await locator.count();
if (pageListCount === 0) {
return 0;
}
const count = await locator.getAttribute('data-total-count');
return count ? parseInt(count) : 0;
};
export const checkDatePicker = async (page: Page, date: Date) => {

View File

@ -280,7 +280,6 @@ export const FloatingToolbarStory: StoryFn<typeof FloatingToolbar> = props => {
style={{ position: 'fixed', bottom: '20px', width: '100%' }}
{...props}
open={open}
onOpenChange={setOpen}
>
<FloatingToolbar.Item>10 Selected</FloatingToolbar.Item>
<FloatingToolbar.Separator />

View File

@ -239,6 +239,8 @@ __metadata:
fake-indexeddb: "npm:^5.0.0"
foxact: "npm:^0.2.20"
jotai: "npm:^2.4.3"
jotai-effect: "npm:^0.2.2"
jotai-scope: "npm:^0.4.0"
lit: "npm:^2.8.0"
lodash: "npm:^4.17.21"
lodash-es: "npm:^4.17.21"
@ -253,6 +255,7 @@ __metadata:
react-is: "npm:^18.2.0"
react-paginate: "npm:^8.2.0"
react-router-dom: "npm:^6.16.0"
react-virtuoso: "npm:^4.6.2"
rxjs: "npm:^7.8.1"
typescript: "npm:^5.2.2"
uuid: "npm:^9.0.1"
@ -24429,6 +24432,25 @@ __metadata:
languageName: node
linkType: hard
"jotai-effect@npm:^0.2.2":
version: 0.2.2
resolution: "jotai-effect@npm:0.2.2"
peerDependencies:
jotai: ">=2.4.3"
checksum: f74f90836b3afb203d65e4621ce9fc267b838685007cf30db2802e15088f40e9bbb4f232121a7010194562286187fe7bc488935d75a948fd256c5bb58e5c858f
languageName: node
linkType: hard
"jotai-scope@npm:^0.4.0":
version: 0.4.0
resolution: "jotai-scope@npm:0.4.0"
peerDependencies:
jotai: ">=2.5.0"
react: ">=17.0.0"
checksum: 398b76507570a674af3e5cbb6f45ede8e8e02014c5d416825952d21f274da6191bbb41d18d0b9bac4dd375e9b5b8c848886a13f930a9810e3de823bfe7a22137
languageName: node
linkType: hard
"jotai@npm:^2.4.3":
version: 2.4.3
resolution: "jotai@npm:2.4.3"
@ -30527,6 +30549,16 @@ __metadata:
languageName: node
linkType: hard
"react-virtuoso@npm:^4.6.2":
version: 4.6.2
resolution: "react-virtuoso@npm:4.6.2"
peerDependencies:
react: ">=16 || >=17 || >= 18"
react-dom: ">=16 || >=17 || >= 18"
checksum: 770c5bc5a842c40cac780bfe6e8bfcf4a1fc79fe84438459240e33e1c347b75a5581cc0cf21035bb250591a7f09fe3eed35b94d0f47540e1ce09f2fc47337840
languageName: node
linkType: hard
"react@npm:18.2.0, react@npm:^18.2.0":
version: 18.2.0
resolution: "react@npm:18.2.0"