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

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,
};