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
33 changed files with 997 additions and 589 deletions
+3
View File
@@ -48,6 +48,8 @@
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"foxact": "^0.2.20", "foxact": "^0.2.20",
"jotai": "^2.4.3", "jotai": "^2.4.3",
"jotai-effect": "^0.2.2",
"jotai-scope": "^0.4.0",
"lit": "^2.8.0", "lit": "^2.8.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
@@ -62,6 +64,7 @@
"react-is": "^18.2.0", "react-is": "^18.2.0",
"react-paginate": "^8.2.0", "react-paginate": "^8.2.0",
"react-router-dom": "^6.16.0", "react-router-dom": "^6.16.0",
"react-virtuoso": "^4.6.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"uuid": "^9.0.1" "uuid": "^9.0.1"
}, },
@@ -6,8 +6,6 @@ import {
type MouseEventHandler, type MouseEventHandler,
type PropsWithChildren, type PropsWithChildren,
type ReactNode, type ReactNode,
useEffect,
useRef,
} from 'react'; } from 'react';
import * as styles from './floating-toolbar.css'; import * as styles from './floating-toolbar.css';
@@ -16,8 +14,6 @@ interface FloatingToolbarProps {
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
open?: boolean; open?: boolean;
// if dbclick outside of the panel, close the toolbar
onOpenChange?: (open: boolean) => void;
} }
interface FloatingToolbarButtonProps { interface FloatingToolbarButtonProps {
@@ -36,49 +32,7 @@ export function FloatingToolbar({
style, style,
className, className,
open, open,
onOpenChange,
}: PropsWithChildren<FloatingToolbarProps>) { }: 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 ( return (
<Popover.Root open={open}> <Popover.Root open={open}>
{/* Having Anchor here to let Popover to calculate the position of the place it is being used */} {/* 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> <Popover.Portal>
{/* always pop up on top for now */} {/* always pop up on top for now */}
<Popover.Content side="top" className={styles.popoverContent}> <Popover.Content side="top" className={styles.popoverContent}>
<Toolbar.Root ref={contentRef} className={clsx(styles.root)}> <Toolbar.Root className={clsx(styles.root)}>{children}</Toolbar.Root>
{children}
</Toolbar.Root>
</Popover.Content> </Popover.Content>
</Popover.Portal> </Popover.Portal>
</Popover.Root> </Popover.Root>
@@ -11,3 +11,4 @@ export * from './types';
export * from './use-collection-manager'; export * from './use-collection-manager';
export * from './utils'; export * from './utils';
export * from './view'; export * from './view';
export * from './virtualized-page-list';
@@ -52,8 +52,9 @@ export const header = style({
padding: '0px 16px 0px 6px', padding: '0px 16px 0px 6px',
gap: 4, gap: 4,
height: '28px', height: '28px',
background: 'var(--affine-background-primary-color)',
':hover': { ':hover': {
background: 'var(--affine-hover-color)', background: 'var(--affine-hover-color-filled)',
}, },
userSelect: 'none', userSelect: 'none',
}); });
@@ -6,14 +6,19 @@ import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons';
import type { PageMeta, Workspace } from '@blocksuite/store'; import type { PageMeta, Workspace } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible'; import * as Collapsible from '@radix-ui/react-collapsible';
import clsx from 'clsx'; import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils'; import { selectAtom } from 'jotai/utils';
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react'; import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
import { PagePreview } from './page-content-preview'; import { PagePreview } from './page-content-preview';
import * as styles from './page-group.css'; import * as styles from './page-group.css';
import { PageListItem } from './page-list-item'; import { PageListItem } from './page-list-item';
import { pageListPropsAtom, selectionStateAtom } from './scoped-atoms'; import {
pageGroupCollapseStateAtom,
pageListPropsAtom,
selectionStateAtom,
useAtom,
useAtomValue,
} from './scoped-atoms';
import type { import type {
PageGroupDefinition, PageGroupDefinition,
PageGroupProps, PageGroupProps,
@@ -21,7 +26,7 @@ import type {
PageListProps, PageListProps,
} from './types'; } from './types';
import { type DateKey } from './types'; import { type DateKey } from './types';
import { betweenDaysAgo, withinDaysAgo } from './utils'; import { betweenDaysAgo, shallowEqual, withinDaysAgo } from './utils';
// todo: optimize date matchers // todo: optimize date matchers
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [ const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
@@ -57,6 +62,7 @@ const pageGroupDefinitions = {
createDate: getDateGroupDefinitions('createDate'), createDate: getDateGroupDefinitions('createDate'),
updatedDate: getDateGroupDefinitions('updatedDate'), updatedDate: getDateGroupDefinitions('updatedDate'),
// add more here later // add more here later
// todo: some page group definitions maybe dynamic
}; };
export function pagesToPageGroups( export function pagesToPageGroups(
@@ -101,6 +107,78 @@ export function pagesToPageGroups(
return groups; 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) => { export const PageGroup = ({ id, items, label }: PageGroupProps) => {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const onExpandedClicked: MouseEventHandler = useCallback(e => { 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 // todo: optimize how to render page meta list item
const requiredPropNames = [ const requiredPropNames = [
'blockSuiteWorkspace', 'blockSuiteWorkspace',
'clickMode', 'rowAsLink',
'isPreferredEdgeless', 'isPreferredEdgeless',
'pageOperationsRenderer', 'pageOperationsRenderer',
'selectedPageIds', 'selectedPageIds',
@@ -185,13 +263,17 @@ type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
selectable: boolean; selectable: boolean;
}; };
const listPropsAtom = selectAtom(pageListPropsAtom, props => { const listPropsAtom = selectAtom(
return Object.fromEntries( pageListPropsAtom,
requiredPropNames.map(name => [name, props[name]]) props => {
) as RequiredProps; 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 props = useAtomValue(listPropsAtom);
const { selectionActive } = useAtomValue(selectionStateAtom); const { selectionActive } = useAtomValue(selectionStateAtom);
return ( return (
@@ -247,10 +329,10 @@ function pageMetaToPageItemProp(
? new Date(pageMeta.updatedDate) ? new Date(pageMeta.updatedDate)
: undefined, : undefined,
to: to:
props.clickMode === 'link' props.rowAsLink && !props.selectable
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}` ? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
: undefined, : undefined,
onClick: props.clickMode === 'select' ? toggleSelection : undefined, onClick: props.selectable ? toggleSelection : undefined,
icon: props.isPreferredEdgeless?.(pageMeta.id) ? ( icon: props.isPreferredEdgeless?.(pageMeta.id) ? (
<EdgelessIcon /> <EdgelessIcon />
) : ( ) : (
@@ -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>
);
};
@@ -84,7 +84,11 @@ const PageCreateDateCell = ({
createDate, createDate,
}: Pick<PageListItemProps, 'createDate'>) => { }: Pick<PageListItemProps, 'createDate'>) => {
return ( 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)} {formatDate(createDate)}
</div> </div>
); );
@@ -94,7 +98,11 @@ const PageUpdatedDateCell = ({
updatedDate, updatedDate,
}: Pick<PageListItemProps, 'updatedDate'>) => { }: Pick<PageListItemProps, 'updatedDate'>) => {
return ( 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) : '-'} {updatedDate ? formatDate(updatedDate) : '-'}
</div> </div>
); );
@@ -5,8 +5,8 @@ import * as itemStyles from './page-list-item.css';
export const listRootContainer = createContainer('list-root-container'); export const listRootContainer = createContainer('list-root-container');
export const pageListScrollContainer = style({ export const pageListScrollContainer = style({
overflowY: 'auto',
width: '100%', width: '100%',
flex: 1,
}); });
export const root = style({ export const root = style({
@@ -23,7 +23,9 @@ export const groupsContainer = style({
rowGap: '16px', rowGap: '16px',
}); });
export const header = style({ export const heading = style({});
export const tableHeader = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '10px 6px 10px 16px', padding: '10px 6px 10px 16px',
@@ -37,7 +39,7 @@ export const header = style({
transform: 'translateY(-0.5px)', // fix sticky look through issue 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)', boxShadow: '0 1px var(--affine-border-color)',
}); });
@@ -73,13 +75,22 @@ export const headerTitleCell = style({
export const headerTitleSelectionIconWrapper = style({ export const headerTitleSelectionIconWrapper = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'flex-start',
fontSize: '16px', fontSize: '16px',
selectors: {
[`${tableHeader}[data-selectable=toggle] &`]: {
width: 32,
},
[`${tableHeader}[data-selection-active=true] &`]: {
width: 24,
},
},
}); });
export const headerCellSortIcon = style({ export const headerCellSortIcon = style({
width: '14px', display: 'inline-flex',
height: '14px', fontSize: 14,
color: 'var(--affine-icon-color)',
}); });
export const colWrapper = style({ export const colWrapper = style({
@@ -104,7 +115,7 @@ export const favoriteCell = style({
flexShrink: 0, flexShrink: 0,
opacity: 0, opacity: 0,
selectors: { selectors: {
[`&[data-favorite], &${itemStyles.root}:hover &`]: { [`&[data-favorite], ${itemStyles.root}:hover &`]: {
opacity: 1, opacity: 1,
}, },
}, },
@@ -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 clsx from 'clsx';
import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useHydrateAtoms } from 'jotai/utils';
import { import {
type ForwardedRef, type ForwardedRef,
forwardRef, forwardRef,
type MouseEventHandler, memo,
type PropsWithChildren, type PropsWithChildren,
type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo,
useRef, useRef,
} from 'react'; } 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 { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
import { PageGroup } from './page-group'; import { PageGroup } from './page-group';
import { PageListTableHeader } from './page-header';
import * as styles from './page-list.css'; import * as styles from './page-list.css';
import { import {
pageGroupsAtom, pageGroupsAtom,
pageListHandlersAtom,
pageListPropsAtom, pageListPropsAtom,
pagesAtom, PageListProvider,
selectionStateAtom, selectionStateAtom,
showOperationsAtom, useAtom,
sorterAtom, useAtomValue,
useSetAtom,
} from './scoped-atoms'; } from './scoped-atoms';
import type { PageListHandle, PageListProps } from './types'; import type { PageListHandle, PageListProps } from './types';
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
/** /**
* Given a list of pages, render a list of pages * Given a list of pages, render a list of pages
*/ */
export const PageList = forwardRef<PageListHandle, PageListProps>( export const PageList = forwardRef<PageListHandle, PageListProps>(
function PageListHandle(props, ref) { function PageList(props, ref) {
return ( return (
<Provider> // push pageListProps to the atom so that downstream components can consume it
<PageListInner {...props} handleRef={ref} /> // this makes sure pageListPropsAtom is always populated
</Provider> // @ts-expect-error fix type issues later
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
<PageListInnerWrapper {...props} handleRef={ref}>
<PageListInner {...props} />
</PageListInnerWrapper>
</PageListProvider>
); );
} }
); );
const PageListInner = ({ // when pressing ESC or double clicking outside of the page list, close the selection mode
handleRef, // todo: use jotai-effect instead but it seems it does not work with jotai-scope?
...props const usePageSelectionStateEffect = () => {
}: PageListProps & { handleRef: ForwardedRef<PageListHandle> }) => { const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
// 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);
useEffect(() => { useEffect(() => {
setPageListPropsAtom(props); if (
}, [props, setPageListPropsAtom]); selectionState.selectionActive &&
selectionState.selectable === 'toggle'
useImperativeHandle( ) {
handleRef, const startTime = Date.now();
() => { const dblClickHandler = (e: MouseEvent) => {
return { if (Date.now() - startTime < 200) {
toggleSelectable: () => { return;
setPageListSelectionState(false); }
}, 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 groups = useAtomValue(pageGroupsAtom);
const hideHeader = props.hideHeader; const hideHeader = props.hideHeader;
return ( return (
<div className={clsx(props.className, styles.root)}> <div className={clsx(props.className, styles.root)}>
{!hideHeader ? <PageListHeader /> : null} {!hideHeader ? <PageListTableHeader /> : null}
<div className={styles.groupsContainer}> <div className={styles.groupsContainer}>
{groups.map(group => ( {groups.map(group => (
<PageGroup key={group.id} {...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 { interface PageListScrollContainerProps {
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
@@ -287,14 +177,14 @@ export const PageListScrollContainer = forwardRef<
); );
return ( return (
<div <Scrollable.Root
style={style} style={style}
ref={setNodeRef}
data-has-scroll-top={hasScrollTop} data-has-scroll-top={hasScrollTop}
className={clsx(styles.pageListScrollContainer, className)} className={clsx(styles.pageListScrollContainer, className)}
> >
{children} <Scrollable.Viewport ref={setNodeRef}>{children}</Scrollable.Viewport>
</div> <Scrollable.Scrollbar />
</Scrollable.Root>
); );
}); });
@@ -29,6 +29,10 @@ const tagColorMap = (color: string) => {
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)', 'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)', 'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)', '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; return mapping[color] || color;
}; };
@@ -109,7 +113,6 @@ export const PageTags = ({
// @ts-expect-error it's fine // @ts-expect-error it's fine
'--hover-max-width': sanitizedWidthOnHover, '--hover-max-width': sanitizedWidthOnHover,
}} }}
onClick={stopPropagation}
> >
<div <div
style={{ style={{
@@ -123,7 +126,12 @@ export const PageTags = ({
{tagsNormal} {tagsNormal}
</div> </div>
{maxItems && tags.length > maxItems ? ( {maxItems && tags.length > maxItems ? (
<Menu items={tagsInPopover}> <Menu
items={tagsInPopover}
contentOptions={{
onClick: stopPropagation,
}}
>
<div className={styles.showMoreTag}> <div className={styles.showMoreTag}>
<MoreHorizontalIcon /> <MoreHorizontalIcon />
</div> </div>
@@ -2,28 +2,40 @@ import { DEFAULT_SORT_KEY } from '@affine/env/constant';
import type { PageMeta } from '@blocksuite/store'; import type { PageMeta } from '@blocksuite/store';
import { atom } from 'jotai'; import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils'; import { selectAtom } from 'jotai/utils';
import { createIsolation } from 'jotai-scope';
import { pagesToPageGroups } from './page-group'; 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 // for ease of use in the component tree
// note: must use selectAtom to access this atom for efficiency // 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 // @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) // whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
const selectionActiveAtom = atom(false); const selectionActiveAtom = atom(false);
export const selectionStateAtom = atom( export const selectionStateAtom = atom(
get => { get => {
const baseAtom = selectAtom(pageListPropsAtom, props => { const baseAtom = selectAtom(
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props; pageListPropsAtom,
return { props => {
selectable, const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
selectedPageIds, return {
onSelectedPageIdsChange, selectable,
}; selectedPageIds,
}); onSelectedPageIdsChange,
};
},
shallowEqual
);
const baseState = get(baseAtom); const baseState = get(baseAtom);
const selectionActive = const selectionActive =
baseState.selectable === 'toggle' 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 // get handlers from pageListPropsAtom
export const pageListHandlersAtom = selectAtom(pageListPropsAtom, props => { export const pageListHandlersAtom = selectAtom(
const { onSelectedPageIdsChange, onDragStart, onDragEnd } = props; pageListPropsAtom,
props => {
const { onSelectedPageIdsChange } = props;
return {
onSelectedPageIdsChange,
};
},
shallowEqual
);
return { export const pagesAtom = selectAtom(
onSelectedPageIdsChange, pageListPropsAtom,
onDragStart, props => props.pages,
onDragEnd, shallowEqual
}; );
});
export const pagesAtom = selectAtom(pageListPropsAtom, props => props.pages);
export const showOperationsAtom = selectAtom( export const showOperationsAtom = selectAtom(
pageListPropsAtom, pageListPropsAtom,
@@ -177,3 +198,10 @@ export const pageGroupsAtom = atom(get => {
} }
return pagesToPageGroups(sorter.pages, groupBy); return pagesToPageGroups(sorter.pages, groupBy);
}); });
export const {
Provider: PageListProvider,
useAtom,
useAtomValue,
useSetAtom,
} = createIsolation();
@@ -40,23 +40,27 @@ export interface PageListProps {
// required data: // required data:
pages: PageMeta[]; pages: PageMeta[];
blockSuiteWorkspace: Workspace; blockSuiteWorkspace: Workspace;
className?: string; className?: string;
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header) hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
groupBy?: PagesGroupByType | false; groupBy?: PagesGroupByType | false;
isPreferredEdgeless: (pageId: string) => boolean; isPreferredEdgeless: (pageId: string) => boolean; // determines the icon used for each row
clickMode?: 'select' | 'link'; // select => click to select; link => click to navigate 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 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 selectedPageIds?: string[]; // selected page ids
onSelectedPageIdsChange?: (selected: string[]) => void; onSelectedPageIdsChange?: (selected: string[]) => void;
onSelectionActiveChange?: (active: boolean) => void;
draggable?: boolean; // whether or not to allow dragging this page item 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 // we also need the following to make sure the page list functions properly
// maybe we could also give a function to render PageListItem? // maybe we could also give a function to render PageListItem?
pageOperationsRenderer?: (page: PageMeta) => ReactNode; 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 { export interface PageListHandle {
toggleSelectable: () => void; toggleSelectable: () => void;
} }
@@ -0,0 +1,3 @@
// add dblclick & esc to document when page selection is active
//
export function usePageSelectionEvents() {}
@@ -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),
};
};
@@ -124,10 +124,10 @@ export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
export const withinDaysAgo = (date: Date, days: number): boolean => { export const withinDaysAgo = (date: Date, days: number): boolean => {
const startDate = new Date(); const startDate = new Date();
const day = startDate.getDay(); const day = startDate.getDate();
const month = startDate.getMonth(); const month = startDate.getMonth();
const year = startDate.getFullYear(); const year = startDate.getFullYear();
return new Date(year, month, day - days) <= date; return new Date(year, month, day - days + 1) <= date;
}; };
export const betweenDaysAgo = ( export const betweenDaysAgo = (
@@ -145,3 +145,38 @@ export function stopPropagation(event: BaseSyntheticEvent) {
export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) { export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) {
event.stopPropagation(); 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;
}
@@ -1,8 +1,7 @@
import { import {
type AllPageListConfig, type AllPageListConfig,
FilterList, FilterList,
PageList, VirtualizedPageList,
PageListScrollContainer,
} from '@affine/component/page-list'; } from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter'; import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -104,25 +103,22 @@ export const PagesMode = ({
</div> </div>
) : null} ) : null}
{searchedList.length ? ( {searchedList.length ? (
<PageListScrollContainer> <VirtualizedPageList
<PageList className={styles.pageList}
clickMode="select" pages={searchedList}
className={styles.pageList} groupBy={false}
pages={searchedList} blockSuiteWorkspace={allPageListConfig.workspace}
groupBy={false} selectable
blockSuiteWorkspace={allPageListConfig.workspace} onSelectedPageIdsChange={ids => {
selectable updateCollection({
onSelectedPageIdsChange={ids => { ...collection,
updateCollection({ allowList: ids,
...collection, });
allowList: ids, }}
}); pageOperationsRenderer={pageOperationsRenderer}
}} selectedPageIds={collection.allowList}
pageOperationsRenderer={pageOperationsRenderer} isPreferredEdgeless={allPageListConfig.isEdgeless}
selectedPageIds={collection.allowList} ></VirtualizedPageList>
isPreferredEdgeless={allPageListConfig.isEdgeless}
></PageList>
</PageListScrollContainer>
) : ( ) : (
<EmptyList search={searchText} /> <EmptyList search={searchText} />
)} )}
@@ -248,7 +248,6 @@ export const RulesMode = ({
{rulesPages.length > 0 ? ( {rulesPages.length > 0 ? (
<PageList <PageList
hideHeader hideHeader
clickMode="select"
className={styles.resultPages} className={styles.resultPages}
pages={rulesPages} pages={rulesPages}
groupBy={false} groupBy={false}
@@ -269,7 +268,6 @@ export const RulesMode = ({
</div> </div>
<PageList <PageList
hideHeader hideHeader
clickMode="select"
className={styles.resultPages} className={styles.resultPages}
pages={allowListPages} pages={allowListPages}
groupBy={false} groupBy={false}
@@ -6,9 +6,9 @@ import { Menu } from '@toeverything/components/menu';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { VirtualizedPageList } from '../..';
import { FilterList } from '../../filter'; import { FilterList } from '../../filter';
import { VariableSelect } from '../../filter/vars'; import { VariableSelect } from '../../filter/vars';
import { PageList, PageListScrollContainer } from '../../page-list';
import { AffineShapeIcon } from '../affine-shape'; import { AffineShapeIcon } from '../affine-shape';
import type { AllPageListConfig } from './edit-collection'; import type { AllPageListConfig } from './edit-collection';
import * as styles from './edit-collection.css'; import * as styles from './edit-collection.css';
@@ -93,19 +93,17 @@ export const SelectPage = ({
</div> </div>
) : null} ) : null}
{searchedList.length ? ( {searchedList.length ? (
<PageListScrollContainer> <VirtualizedPageList
<PageList className={styles.pageList}
clickMode="select" pages={searchedList}
className={styles.pageList} blockSuiteWorkspace={allPageListConfig.workspace}
pages={searchedList} selectable
blockSuiteWorkspace={allPageListConfig.workspace} groupBy={false}
selectable onSelectedPageIdsChange={onChange}
onSelectedPageIdsChange={onChange} selectedPageIds={value}
selectedPageIds={value} isPreferredEdgeless={allPageListConfig.isEdgeless}
isPreferredEdgeless={allPageListConfig.isEdgeless} pageOperationsRenderer={allPageListConfig.favoriteRender}
pageOperationsRenderer={allPageListConfig.favoriteRender} />
></PageList>
</PageListScrollContainer>
) : ( ) : (
<EmptyList search={searchText} /> <EmptyList search={searchText} />
)} )}
@@ -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}
/>
);
};
@@ -57,6 +57,7 @@ export const Checkbox = ({
return ( return (
<div <div
className={clsx(styles.root, disabled && styles.disabled)} className={clsx(styles.root, disabled && styles.disabled)}
role="checkbox"
{...otherProps} {...otherProps}
> >
{icon} {icon}
@@ -30,8 +30,7 @@ export const scrollableViewport = style({
}); });
globalStyle(`${scrollableViewport} > div`, { globalStyle(`${scrollableViewport} > div`, {
maxWidth: '100%', display: 'contents !important',
display: 'block !important',
}); });
export const scrollableContainer = style({ export const scrollableContainer = style({
@@ -44,7 +43,6 @@ export const scrollbar = style({
flexDirection: 'column', flexDirection: 'column',
userSelect: 'none', userSelect: 'none',
touchAction: 'none', touchAction: 'none',
marginRight: '4px',
width: 'var(--scrollbar-width)', width: 'var(--scrollbar-width)',
height: '100%', height: '100%',
opacity: 1, opacity: 1,
@@ -1 +1,2 @@
export * from './scrollable';
export * from './scrollbar'; export * from './scrollbar';
@@ -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,
};
@@ -24,6 +24,6 @@ export const workspaceType = style({
}); });
export const scrollbar = style({ export const scrollbar = style({
transform: 'translateX(10px)', transform: 'translateX(8px)',
width: '4px', width: '4px',
}); });
@@ -15,6 +15,8 @@ export const scrollContainer = style({
}); });
export const allPagesHeader = style({ export const allPagesHeader = style({
height: 100,
alignItems: 'center',
padding: '48px 16px 20px 24px', padding: '48px 16px 20px 24px',
overflow: 'hidden', overflow: 'hidden',
display: 'flex', display: 'flex',
@@ -23,7 +25,7 @@ export const allPagesHeader = style({
}); });
export const allPagesHeaderTitle = style({ export const allPagesHeaderTitle = style({
fontSize: 'var(--affine-font-h-3)', fontSize: 'var(--affine-font-h-5)',
fontWeight: 500, fontWeight: 500,
color: 'var(--affine-text-secondary-color)', color: 'var(--affine-text-secondary-color)',
display: 'flex', display: 'flex',
@@ -4,10 +4,9 @@ import {
FloatingToolbar, FloatingToolbar,
NewPageButton as PureNewPageButton, NewPageButton as PureNewPageButton,
OperationCell, OperationCell,
PageList,
type PageListHandle, type PageListHandle,
PageListScrollContainer,
useCollectionManager, useCollectionManager,
VirtualizedPageList,
} from '@affine/component/page-list'; } from '@affine/component/page-list';
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace'; import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
@@ -27,7 +26,6 @@ import clsx from 'clsx';
import { import {
type PropsWithChildren, type PropsWithChildren,
useCallback, useCallback,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -145,19 +143,12 @@ const usePageOperationsRenderer = () => {
const PageListFloatingToolbar = ({ const PageListFloatingToolbar = ({
selectedIds, selectedIds,
onClose, onClose,
open,
}: { }: {
open: boolean;
selectedIds: string[]; selectedIds: string[];
onClose: () => void; onClose: () => void;
}) => { }) => {
const open = selectedIds.length > 0;
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
onClose();
}
},
[onClose]
);
const [currentWorkspace] = useCurrentWorkspace(); const [currentWorkspace] = useCurrentWorkspace();
const { setTrashModal } = useTrashModalHelper( const { setTrashModal } = useTrashModalHelper(
currentWorkspace.blockSuiteWorkspace currentWorkspace.blockSuiteWorkspace
@@ -177,11 +168,7 @@ const PageListFloatingToolbar = ({
}, [pageMetas, selectedIds, setTrashModal]); }, [pageMetas, selectedIds, setTrashModal]);
return ( return (
<FloatingToolbar <FloatingToolbar className={styles.floatingToolbar} open={open}>
className={styles.floatingToolbar}
open={open}
onOpenChange={handleOpenChange}
>
<FloatingToolbar.Item> <FloatingToolbar.Item>
<Trans <Trans
i18nKey="com.affine.page.toolbar.selected" i18nKey="com.affine.page.toolbar.selected"
@@ -190,7 +177,7 @@ const PageListFloatingToolbar = ({
<div className={styles.toolbarSelectedNumber}> <div className={styles.toolbarSelectedNumber}>
{{ count: selectedIds.length } as any} {{ count: selectedIds.length } as any}
</div> </div>
pages selected selected
</Trans> </Trans>
</FloatingToolbar.Item> </FloatingToolbar.Item>
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} /> <FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
@@ -245,9 +232,10 @@ export const AllPage = () => {
); );
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]); const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const pageListRef = useRef<PageListHandle>(null); const pageListRef = useRef<PageListHandle>(null);
const containerRef = useRef<HTMLDivElement>(null);
const deselectAllAndToggleSelect = useCallback(() => { const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
setSelectedPageIds([]);
const hideFloatingToolbar = useCallback(() => {
pageListRef.current?.toggleSelectable(); pageListRef.current?.toggleSelectable();
}, []); }, []);
@@ -257,24 +245,7 @@ export const AllPage = () => {
return selectedPageIds.filter(id => ids.includes(id)); return selectedPageIds.filter(id => ids.includes(id));
}, [filteredPageMetas, selectedPageIds]); }, [filteredPageMetas, selectedPageIds]);
const [showHeaderCreateNewPage, setShowHeaderCreateNewPage] = useState(false); const [hideHeaderCreateNewPage, setHideHeaderCreateNewPage] = useState(true);
// 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;
}, []);
return ( return (
<div className={styles.root}> <div className={styles.root}>
@@ -289,7 +260,7 @@ export const AllPage = () => {
size="small" size="small"
className={clsx( className={clsx(
styles.headerCreateNewButton, styles.headerCreateNewButton,
!showHeaderCreateNewPage && styles.headerCreateNewButtonHidden hideHeaderCreateNewPage && styles.headerCreateNewButtonHidden
)} )}
> >
<PlusIcon /> <PlusIcon />
@@ -297,37 +268,36 @@ export const AllPage = () => {
} }
/> />
) : null} ) : null}
<PageListScrollContainer {filteredPageMetas.length > 0 ? (
ref={containerRef} <>
className={styles.scrollContainer} <VirtualizedPageList
> ref={pageListRef}
<PageListHeader /> selectable="toggle"
{filteredPageMetas.length > 0 ? ( draggable
<> atTopThreshold={80}
<PageList atTopStateChange={setHideHeaderCreateNewPage}
ref={pageListRef} onSelectionActiveChange={setShowFloatingToolbar}
selectable="toggle" heading={<PageListHeader />}
draggable selectedPageIds={filteredSelectedPageIds}
selectedPageIds={filteredSelectedPageIds} onSelectedPageIdsChange={setSelectedPageIds}
onSelectedPageIdsChange={setSelectedPageIds} pages={filteredPageMetas}
pages={filteredPageMetas} rowAsLink
clickMode="link" isPreferredEdgeless={isPreferredEdgeless}
isPreferredEdgeless={isPreferredEdgeless}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
pageOperationsRenderer={pageOperationsRenderer}
/>
<PageListFloatingToolbar
selectedIds={filteredSelectedPageIds}
onClose={deselectAllAndToggleSelect}
/>
</>
) : (
<EmptyPageList
type="all"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
pageOperationsRenderer={pageOperationsRenderer}
/> />
)} <PageListFloatingToolbar
</PageListScrollContainer> open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
selectedIds={filteredSelectedPageIds}
onClose={hideFloatingToolbar}
/>
</>
) : (
<EmptyPageList
type="all"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</div> </div>
); );
}; };
@@ -1,8 +1,7 @@
import { toast } from '@affine/component'; import { toast } from '@affine/component';
import { import {
PageList,
PageListScrollContainer,
TrashOperationCell, TrashOperationCell,
VirtualizedPageList,
} from '@affine/component/page-list'; } from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace'; import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -61,30 +60,28 @@ export const TrashPage = () => {
); );
return ( return (
<> <>
<WorkspaceHeader
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.TRASH,
}}
/>
<div className={styles.root}> <div className={styles.root}>
<PageListScrollContainer className={styles.scrollContainer}> <WorkspaceHeader
{filteredPageMetas.length > 0 ? ( currentWorkspaceId={currentWorkspace.id}
<PageList currentEntry={{
pages={filteredPageMetas} subPath: WorkspaceSubPath.TRASH,
clickMode="link" }}
groupBy={false} />
isPreferredEdgeless={isPreferredEdgeless} {filteredPageMetas.length > 0 ? (
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} <VirtualizedPageList
pageOperationsRenderer={pageOperationsRenderer} pages={filteredPageMetas}
/> rowAsLink
) : ( groupBy={false}
<EmptyPageList isPreferredEdgeless={isPreferredEdgeless}
type="trash" blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} pageOperationsRenderer={pageOperationsRenderer}
/> />
)} ) : (
</PageListScrollContainer> <EmptyPageList
type="trash"
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
)}
</div> </div>
</> </>
); );
+2 -2
View File
@@ -394,8 +394,8 @@
"com.affine.all-pages.header": "All Pages", "com.affine.all-pages.header": "All Pages",
"com.affine.collections.header": "Collections", "com.affine.collections.header": "Collections",
"com.affine.page.group-header.select-all": "Select All", "com.affine.page.group-header.select-all": "Select All",
"com.affine.page.toolbar.selected_one": "<0>{{count}}</0> page selected", "com.affine.page.group-header.clear": "Clear Selection",
"com.affine.page.toolbar.selected_others": "<0>{{count}}</0> page(s) selected", "com.affine.page.toolbar.selected": "<0>{{count}}</0> selected",
"com.affine.collection.allCollections": "All Collections", "com.affine.collection.allCollections": "All Collections",
"com.affine.collection.emptyCollection": "Empty Collection", "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.", "com.affine.collection.emptyCollectionDescription": "Collection is a smart folder where you can manually add pages or automatically add pages through rules.",
+14 -17
View File
@@ -4,11 +4,11 @@ import {
checkDatePicker, checkDatePicker,
checkDatePickerMonth, checkDatePickerMonth,
checkFilterName, checkFilterName,
checkPagesCount,
clickDatePicker, clickDatePicker,
createFirstFilter, createFirstFilter,
createPageWithTag, createPageWithTag,
fillDatePicker, fillDatePicker,
getPagesCount,
selectDateFromDatePicker, selectDateFromDatePicker,
selectMonthFromMonthPicker, selectMonthFromMonthPicker,
selectTag, selectTag,
@@ -89,8 +89,7 @@ test('allow creation of filters by created time', async ({ page }) => {
await clickNewPageButton(page); await clickNewPageButton(page);
await clickSideBarAllPageButton(page); await clickSideBarAllPageButton(page);
await waitForAllPagesLoad(page); await waitForAllPagesLoad(page);
const pages = await page.locator('[data-testid="page-list-item"]').all(); const pageCount = await getPagesCount(page);
const pageCount = pages.length;
expect(pageCount).not.toBe(0); expect(pageCount).not.toBe(0);
await createFirstFilter(page, 'Created'); await createFirstFilter(page, 'Created');
await checkFilterName(page, 'after'); await checkFilterName(page, 'after');
@@ -98,11 +97,11 @@ test('allow creation of filters by created time', async ({ page }) => {
const yesterday = new Date(); const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
await checkDatePicker(page, yesterday); await checkDatePicker(page, yesterday);
await checkPagesCount(page, 1); expect(await getPagesCount(page)).toBe(1);
// change date // change date
const today = new Date(); const today = new Date();
await fillDatePicker(page, today); await fillDatePicker(page, today);
await checkPagesCount(page, 0); expect(await getPagesCount(page)).toBe(0);
// change filter // change filter
await page.getByTestId('filter-name').click(); await page.getByTestId('filter-name').click();
await page.getByTestId('filler-tag-before').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); tomorrow.setDate(tomorrow.getDate() + 1);
await fillDatePicker(page, tomorrow); await fillDatePicker(page, tomorrow);
await checkDatePicker(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 ({ 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 clickNewPageButton(page);
await clickSideBarAllPageButton(page); await clickSideBarAllPageButton(page);
await waitForAllPagesLoad(page); await waitForAllPagesLoad(page);
const pages = await page.locator('[data-testid="page-list-item"]').all(); const pageCount = await getPagesCount(page);
const pageCount = pages.length;
expect(pageCount).not.toBe(0); expect(pageCount).not.toBe(0);
await createFirstFilter(page, 'Created'); await createFirstFilter(page, 'Created');
await checkFilterName(page, 'after'); 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(); const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
await checkDatePicker(page, yesterday); await checkDatePicker(page, yesterday);
await checkPagesCount(page, 1); expect(await getPagesCount(page)).toBe(1);
// change date // change date
const today = new Date(); const today = new Date();
await selectDateFromDatePicker(page, today); await selectDateFromDatePicker(page, today);
await checkPagesCount(page, 0); expect(await getPagesCount(page)).toBe(0);
// change filter // change filter
await page.locator('[data-testid="filter-name"]').click(); await page.locator('[data-testid="filter-name"]').click();
await page.getByTestId('filler-tag-before').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); tomorrow.setDate(tomorrow.getDate() + 1);
await selectDateFromDatePicker(page, tomorrow); await selectDateFromDatePicker(page, tomorrow);
await checkDatePicker(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 }) => { 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 waitForEditorLoad(page);
await clickSideBarAllPageButton(page); await clickSideBarAllPageButton(page);
await waitForAllPagesLoad(page); await waitForAllPagesLoad(page);
const pages = await page.locator('[data-testid="page-list-item"]').all(); const pageCount = await getPagesCount(page);
const pageCount = pages.length;
expect(pageCount).not.toBe(0); expect(pageCount).not.toBe(0);
await createFirstFilter(page, 'Tags'); await createFirstFilter(page, 'Tags');
await checkFilterName(page, 'is not empty'); 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 createPageWithTag(page, { title: 'Page B', tags: ['B'] });
await clickSideBarAllPageButton(page); await clickSideBarAllPageButton(page);
await checkFilterName(page, 'is not empty'); await checkFilterName(page, 'is not empty');
await checkPagesCount(page, pagesWithTagsCount + 2); expect(await getPagesCount(page)).toBe(pagesWithTagsCount + 2);
await changeFilter(page, 'contains all'); await changeFilter(page, 'contains all');
await checkPagesCount(page, pageCount + 2); expect(await getPagesCount(page)).toBe(pageCount + 2);
await selectTag(page, 'A'); await selectTag(page, 'A');
await checkPagesCount(page, 1); expect(await getPagesCount(page)).toBe(1);
await changeFilter(page, 'does not contains all'); await changeFilter(page, 'does not contains all');
await selectTag(page, 'B'); await selectTag(page, 'B');
await checkPagesCount(page, pageCount + 1); expect(await getPagesCount(page)).toBe(pageCount + 1);
}); });
@@ -1,4 +1,5 @@
import { test } from '@affine-test/kit/playwright'; 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 { openHomePage } from '@affine-test/kit/utils/load-page';
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic'; import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; 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 page.keyboard.press('Escape');
await clickSideBarAllPageButton(page); await clickSideBarAllPageButton(page);
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
const pageList = page.locator('[data-testid=page-list-item]'); const result = await getPagesCount(page);
const result = await pageList.count();
expect(result).toBe(13); expect(result).toBe(13);
await page.reload(); await page.reload();
await page.waitForTimeout(4000); await page.waitForTimeout(4000);
const pageList1 = page.locator('[data-testid=page-list-item]'); const result1 = await getPagesCount(page);
const result1 = await pageList1.count();
expect(result1).toBe(13); expect(result1).toBe(13);
const currentWorkspace = await workspace.current(); const currentWorkspace = await workspace.current();
+11 -4
View File
@@ -38,10 +38,17 @@ const dateFormat = (date: Date) => {
return `${month} ${day}`; return `${month} ${day}`;
}; };
export const checkPagesCount = async (page: Page, count: number) => { // fixme: there could be multiple page lists in the Page
expect( export const getPagesCount = async (page: Page) => {
(await page.locator('[data-testid="page-list-item"]').all()).length const locator = page.locator('[data-testid="virtualized-page-list"]');
).toBe(count); 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) => { export const checkDatePicker = async (page: Page, date: Date) => {
@@ -280,7 +280,6 @@ export const FloatingToolbarStory: StoryFn<typeof FloatingToolbar> = props => {
style={{ position: 'fixed', bottom: '20px', width: '100%' }} style={{ position: 'fixed', bottom: '20px', width: '100%' }}
{...props} {...props}
open={open} open={open}
onOpenChange={setOpen}
> >
<FloatingToolbar.Item>10 Selected</FloatingToolbar.Item> <FloatingToolbar.Item>10 Selected</FloatingToolbar.Item>
<FloatingToolbar.Separator /> <FloatingToolbar.Separator />
+32
View File
@@ -239,6 +239,8 @@ __metadata:
fake-indexeddb: "npm:^5.0.0" fake-indexeddb: "npm:^5.0.0"
foxact: "npm:^0.2.20" foxact: "npm:^0.2.20"
jotai: "npm:^2.4.3" jotai: "npm:^2.4.3"
jotai-effect: "npm:^0.2.2"
jotai-scope: "npm:^0.4.0"
lit: "npm:^2.8.0" lit: "npm:^2.8.0"
lodash: "npm:^4.17.21" lodash: "npm:^4.17.21"
lodash-es: "npm:^4.17.21" lodash-es: "npm:^4.17.21"
@@ -253,6 +255,7 @@ __metadata:
react-is: "npm:^18.2.0" react-is: "npm:^18.2.0"
react-paginate: "npm:^8.2.0" react-paginate: "npm:^8.2.0"
react-router-dom: "npm:^6.16.0" react-router-dom: "npm:^6.16.0"
react-virtuoso: "npm:^4.6.2"
rxjs: "npm:^7.8.1" rxjs: "npm:^7.8.1"
typescript: "npm:^5.2.2" typescript: "npm:^5.2.2"
uuid: "npm:^9.0.1" uuid: "npm:^9.0.1"
@@ -24429,6 +24432,25 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "jotai@npm:^2.4.3":
version: 2.4.3 version: 2.4.3
resolution: "jotai@npm:2.4.3" resolution: "jotai@npm:2.4.3"
@@ -30527,6 +30549,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "react@npm:18.2.0, react@npm:^18.2.0":
version: 18.2.0 version: 18.2.0
resolution: "react@npm:18.2.0" resolution: "react@npm:18.2.0"