mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-24 06:02:51 +03:00
refactor(component): virtual rendering page list (#4775)
Co-authored-by: Joooye_34 <Joooye1991@gmail.com>
This commit is contained in:
parent
a3906bf92b
commit
65321e39cc
@ -48,6 +48,8 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"foxact": "^0.2.20",
|
||||
"jotai": "^2.4.3",
|
||||
"jotai-effect": "^0.2.2",
|
||||
"jotai-scope": "^0.4.0",
|
||||
"lit": "^2.8.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
@ -62,6 +64,7 @@
|
||||
"react-is": "^18.2.0",
|
||||
"react-paginate": "^8.2.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-virtuoso": "^4.6.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -11,3 +11,4 @@ export * from './types';
|
||||
export * from './use-collection-manager';
|
||||
export * from './utils';
|
||||
export * from './view';
|
||||
export * from './virtualized-page-list';
|
||||
|
@ -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',
|
||||
});
|
||||
|
@ -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 />
|
||||
) : (
|
||||
|
@ -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,
|
||||
}: 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>
|
||||
);
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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();
|
@ -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;
|
||||
}
|
||||
|
@ -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 => {
|
||||
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;
|
||||
}
|
||||
|
@ -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} />
|
||||
)}
|
||||
|
@ -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}
|
||||
|
@ -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} />
|
||||
)}
|
||||
|
@ -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 (
|
||||
<div
|
||||
className={clsx(styles.root, disabled && styles.disabled)}
|
||||
role="checkbox"
|
||||
{...otherProps}
|
||||
>
|
||||
{icon}
|
||||
|
@ -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,
|
||||
|
@ -1 +1,2 @@
|
||||
export * from './scrollable';
|
||||
export * from './scrollbar';
|
||||
|
64
packages/frontend/component/src/ui/scrollbar/scrollable.tsx
Normal file
64
packages/frontend/component/src/ui/scrollbar/scrollable.tsx
Normal 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,
|
||||
};
|
@ -24,6 +24,6 @@ export const workspaceType = style({
|
||||
});
|
||||
|
||||
export const scrollbar = style({
|
||||
transform: 'translateX(10px)',
|
||||
transform: 'translateX(8px)',
|
||||
width: '4px',
|
||||
});
|
||||
|
@ -15,6 +15,8 @@ export const scrollContainer = style({
|
||||
});
|
||||
|
||||
export const allPagesHeader = style({
|
||||
height: 100,
|
||||
alignItems: 'center',
|
||||
padding: '48px 16px 20px 24px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
@ -23,7 +25,7 @@ export const allPagesHeader = style({
|
||||
});
|
||||
|
||||
export const allPagesHeaderTitle = style({
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
fontWeight: 500,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
|
@ -4,10 +4,9 @@ import {
|
||||
FloatingToolbar,
|
||||
NewPageButton as PureNewPageButton,
|
||||
OperationCell,
|
||||
PageList,
|
||||
type PageListHandle,
|
||||
PageListScrollContainer,
|
||||
useCollectionManager,
|
||||
VirtualizedPageList,
|
||||
} from '@affine/component/page-list';
|
||||
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { Trans } from '@affine/i18n';
|
||||
@ -27,7 +26,6 @@ import clsx from 'clsx';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@ -145,19 +143,12 @@ const usePageOperationsRenderer = () => {
|
||||
const PageListFloatingToolbar = ({
|
||||
selectedIds,
|
||||
onClose,
|
||||
open,
|
||||
}: {
|
||||
open: boolean;
|
||||
selectedIds: string[];
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const open = selectedIds.length > 0;
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
@ -177,11 +168,7 @@ const PageListFloatingToolbar = ({
|
||||
}, [pageMetas, selectedIds, setTrashModal]);
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
className={styles.floatingToolbar}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<FloatingToolbar className={styles.floatingToolbar} open={open}>
|
||||
<FloatingToolbar.Item>
|
||||
<Trans
|
||||
i18nKey="com.affine.page.toolbar.selected"
|
||||
@ -190,7 +177,7 @@ const PageListFloatingToolbar = ({
|
||||
<div className={styles.toolbarSelectedNumber}>
|
||||
{{ count: selectedIds.length } as any}
|
||||
</div>
|
||||
pages selected
|
||||
selected
|
||||
</Trans>
|
||||
</FloatingToolbar.Item>
|
||||
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
|
||||
@ -245,9 +232,10 @@ export const AllPage = () => {
|
||||
);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const pageListRef = useRef<PageListHandle>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const deselectAllAndToggleSelect = useCallback(() => {
|
||||
setSelectedPageIds([]);
|
||||
|
||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||
|
||||
const hideFloatingToolbar = useCallback(() => {
|
||||
pageListRef.current?.toggleSelectable();
|
||||
}, []);
|
||||
|
||||
@ -257,24 +245,7 @@ export const AllPage = () => {
|
||||
return selectedPageIds.filter(id => ids.includes(id));
|
||||
}, [filteredPageMetas, selectedPageIds]);
|
||||
|
||||
const [showHeaderCreateNewPage, setShowHeaderCreateNewPage] = useState(false);
|
||||
// when PageListScrollContainer scrolls above 40px, show the create new page button on header
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
const handleScroll = () => {
|
||||
setTimeout(() => {
|
||||
const scrollTop = container.scrollTop ?? 0;
|
||||
setShowHeaderCreateNewPage(scrollTop > 40);
|
||||
});
|
||||
};
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, []);
|
||||
const [hideHeaderCreateNewPage, setHideHeaderCreateNewPage] = useState(true);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
@ -289,7 +260,7 @@ export const AllPage = () => {
|
||||
size="small"
|
||||
className={clsx(
|
||||
styles.headerCreateNewButton,
|
||||
!showHeaderCreateNewPage && styles.headerCreateNewButtonHidden
|
||||
hideHeaderCreateNewPage && styles.headerCreateNewButtonHidden
|
||||
)}
|
||||
>
|
||||
<PlusIcon />
|
||||
@ -297,37 +268,36 @@ export const AllPage = () => {
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<PageListScrollContainer
|
||||
ref={containerRef}
|
||||
className={styles.scrollContainer}
|
||||
>
|
||||
<PageListHeader />
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<>
|
||||
<PageList
|
||||
ref={pageListRef}
|
||||
selectable="toggle"
|
||||
draggable
|
||||
selectedPageIds={filteredSelectedPageIds}
|
||||
onSelectedPageIdsChange={setSelectedPageIds}
|
||||
pages={filteredPageMetas}
|
||||
clickMode="link"
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
<PageListFloatingToolbar
|
||||
selectedIds={filteredSelectedPageIds}
|
||||
onClose={deselectAllAndToggleSelect}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="all"
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<>
|
||||
<VirtualizedPageList
|
||||
ref={pageListRef}
|
||||
selectable="toggle"
|
||||
draggable
|
||||
atTopThreshold={80}
|
||||
atTopStateChange={setHideHeaderCreateNewPage}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
heading={<PageListHeader />}
|
||||
selectedPageIds={filteredSelectedPageIds}
|
||||
onSelectedPageIdsChange={setSelectedPageIds}
|
||||
pages={filteredPageMetas}
|
||||
rowAsLink
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
)}
|
||||
</PageListScrollContainer>
|
||||
<PageListFloatingToolbar
|
||||
open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
|
||||
selectedIds={filteredSelectedPageIds}
|
||||
onClose={hideFloatingToolbar}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="all"
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { toast } from '@affine/component';
|
||||
import {
|
||||
PageList,
|
||||
PageListScrollContainer,
|
||||
TrashOperationCell,
|
||||
VirtualizedPageList,
|
||||
} from '@affine/component/page-list';
|
||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@ -61,30 +60,28 @@ export const TrashPage = () => {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<WorkspaceHeader
|
||||
currentWorkspaceId={currentWorkspace.id}
|
||||
currentEntry={{
|
||||
subPath: WorkspaceSubPath.TRASH,
|
||||
}}
|
||||
/>
|
||||
<div className={styles.root}>
|
||||
<PageListScrollContainer className={styles.scrollContainer}>
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<PageList
|
||||
pages={filteredPageMetas}
|
||||
clickMode="link"
|
||||
groupBy={false}
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="trash"
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
)}
|
||||
</PageListScrollContainer>
|
||||
<WorkspaceHeader
|
||||
currentWorkspaceId={currentWorkspace.id}
|
||||
currentEntry={{
|
||||
subPath: WorkspaceSubPath.TRASH,
|
||||
}}
|
||||
/>
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<VirtualizedPageList
|
||||
pages={filteredPageMetas}
|
||||
rowAsLink
|
||||
groupBy={false}
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="trash"
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -394,8 +394,8 @@
|
||||
"com.affine.all-pages.header": "All Pages",
|
||||
"com.affine.collections.header": "Collections",
|
||||
"com.affine.page.group-header.select-all": "Select All",
|
||||
"com.affine.page.toolbar.selected_one": "<0>{{count}}</0> page selected",
|
||||
"com.affine.page.toolbar.selected_others": "<0>{{count}}</0> page(s) selected",
|
||||
"com.affine.page.group-header.clear": "Clear Selection",
|
||||
"com.affine.page.toolbar.selected": "<0>{{count}}</0> selected",
|
||||
"com.affine.collection.allCollections": "All Collections",
|
||||
"com.affine.collection.emptyCollection": "Empty Collection",
|
||||
"com.affine.collection.emptyCollectionDescription": "Collection is a smart folder where you can manually add pages or automatically add pages through rules.",
|
||||
|
@ -4,11 +4,11 @@ import {
|
||||
checkDatePicker,
|
||||
checkDatePickerMonth,
|
||||
checkFilterName,
|
||||
checkPagesCount,
|
||||
clickDatePicker,
|
||||
createFirstFilter,
|
||||
createPageWithTag,
|
||||
fillDatePicker,
|
||||
getPagesCount,
|
||||
selectDateFromDatePicker,
|
||||
selectMonthFromMonthPicker,
|
||||
selectTag,
|
||||
@ -89,8 +89,7 @@ test('allow creation of filters by created time', async ({ page }) => {
|
||||
await clickNewPageButton(page);
|
||||
await clickSideBarAllPageButton(page);
|
||||
await waitForAllPagesLoad(page);
|
||||
const pages = await page.locator('[data-testid="page-list-item"]').all();
|
||||
const pageCount = pages.length;
|
||||
const pageCount = await getPagesCount(page);
|
||||
expect(pageCount).not.toBe(0);
|
||||
await createFirstFilter(page, 'Created');
|
||||
await checkFilterName(page, 'after');
|
||||
@ -98,11 +97,11 @@ test('allow creation of filters by created time', async ({ page }) => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
await checkDatePicker(page, yesterday);
|
||||
await checkPagesCount(page, 1);
|
||||
expect(await getPagesCount(page)).toBe(1);
|
||||
// change date
|
||||
const today = new Date();
|
||||
await fillDatePicker(page, today);
|
||||
await checkPagesCount(page, 0);
|
||||
expect(await getPagesCount(page)).toBe(0);
|
||||
// change filter
|
||||
await page.getByTestId('filter-name').click();
|
||||
await page.getByTestId('filler-tag-before').click();
|
||||
@ -110,7 +109,7 @@ test('allow creation of filters by created time', async ({ page }) => {
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
await fillDatePicker(page, tomorrow);
|
||||
await checkDatePicker(page, tomorrow);
|
||||
await checkPagesCount(page, pageCount);
|
||||
expect(await getPagesCount(page)).toBe(pageCount);
|
||||
});
|
||||
|
||||
test('creation of filters by created time, then click date picker to modify the date', async ({
|
||||
@ -121,8 +120,7 @@ test('creation of filters by created time, then click date picker to modify the
|
||||
await clickNewPageButton(page);
|
||||
await clickSideBarAllPageButton(page);
|
||||
await waitForAllPagesLoad(page);
|
||||
const pages = await page.locator('[data-testid="page-list-item"]').all();
|
||||
const pageCount = pages.length;
|
||||
const pageCount = await getPagesCount(page);
|
||||
expect(pageCount).not.toBe(0);
|
||||
await createFirstFilter(page, 'Created');
|
||||
await checkFilterName(page, 'after');
|
||||
@ -130,11 +128,11 @@ test('creation of filters by created time, then click date picker to modify the
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
await checkDatePicker(page, yesterday);
|
||||
await checkPagesCount(page, 1);
|
||||
expect(await getPagesCount(page)).toBe(1);
|
||||
// change date
|
||||
const today = new Date();
|
||||
await selectDateFromDatePicker(page, today);
|
||||
await checkPagesCount(page, 0);
|
||||
expect(await getPagesCount(page)).toBe(0);
|
||||
// change filter
|
||||
await page.locator('[data-testid="filter-name"]').click();
|
||||
await page.getByTestId('filler-tag-before').click();
|
||||
@ -142,7 +140,7 @@ test('creation of filters by created time, then click date picker to modify the
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
await selectDateFromDatePicker(page, tomorrow);
|
||||
await checkDatePicker(page, tomorrow);
|
||||
await checkPagesCount(page, pageCount);
|
||||
expect(await getPagesCount(page)).toBe(pageCount);
|
||||
});
|
||||
|
||||
test('use monthpicker to modify the month of datepicker', async ({ page }) => {
|
||||
@ -174,8 +172,7 @@ test('allow creation of filters by tags', async ({ page }) => {
|
||||
await waitForEditorLoad(page);
|
||||
await clickSideBarAllPageButton(page);
|
||||
await waitForAllPagesLoad(page);
|
||||
const pages = await page.locator('[data-testid="page-list-item"]').all();
|
||||
const pageCount = pages.length;
|
||||
const pageCount = await getPagesCount(page);
|
||||
expect(pageCount).not.toBe(0);
|
||||
await createFirstFilter(page, 'Tags');
|
||||
await checkFilterName(page, 'is not empty');
|
||||
@ -188,12 +185,12 @@ test('allow creation of filters by tags', async ({ page }) => {
|
||||
await createPageWithTag(page, { title: 'Page B', tags: ['B'] });
|
||||
await clickSideBarAllPageButton(page);
|
||||
await checkFilterName(page, 'is not empty');
|
||||
await checkPagesCount(page, pagesWithTagsCount + 2);
|
||||
expect(await getPagesCount(page)).toBe(pagesWithTagsCount + 2);
|
||||
await changeFilter(page, 'contains all');
|
||||
await checkPagesCount(page, pageCount + 2);
|
||||
expect(await getPagesCount(page)).toBe(pageCount + 2);
|
||||
await selectTag(page, 'A');
|
||||
await checkPagesCount(page, 1);
|
||||
expect(await getPagesCount(page)).toBe(1);
|
||||
await changeFilter(page, 'does not contains all');
|
||||
await selectTag(page, 'B');
|
||||
await checkPagesCount(page, pageCount + 1);
|
||||
expect(await getPagesCount(page)).toBe(pageCount + 1);
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { test } from '@affine-test/kit/playwright';
|
||||
import { getPagesCount } from '@affine-test/kit/utils/filter';
|
||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
|
||||
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
|
||||
@ -48,13 +49,11 @@ test('create one workspace in the workspace list', async ({
|
||||
await page.keyboard.press('Escape');
|
||||
await clickSideBarAllPageButton(page);
|
||||
await page.waitForTimeout(2000);
|
||||
const pageList = page.locator('[data-testid=page-list-item]');
|
||||
const result = await pageList.count();
|
||||
const result = await getPagesCount(page);
|
||||
expect(result).toBe(13);
|
||||
await page.reload();
|
||||
await page.waitForTimeout(4000);
|
||||
const pageList1 = page.locator('[data-testid=page-list-item]');
|
||||
const result1 = await pageList1.count();
|
||||
const result1 = await getPagesCount(page);
|
||||
expect(result1).toBe(13);
|
||||
const currentWorkspace = await workspace.current();
|
||||
|
||||
|
@ -38,10 +38,17 @@ const dateFormat = (date: Date) => {
|
||||
return `${month} ${day}`;
|
||||
};
|
||||
|
||||
export const checkPagesCount = async (page: Page, count: number) => {
|
||||
expect(
|
||||
(await page.locator('[data-testid="page-list-item"]').all()).length
|
||||
).toBe(count);
|
||||
// fixme: there could be multiple page lists in the Page
|
||||
export const getPagesCount = async (page: Page) => {
|
||||
const locator = page.locator('[data-testid="virtualized-page-list"]');
|
||||
const pageListCount = await locator.count();
|
||||
|
||||
if (pageListCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const count = await locator.getAttribute('data-total-count');
|
||||
return count ? parseInt(count) : 0;
|
||||
};
|
||||
|
||||
export const checkDatePicker = async (page: Page, date: Date) => {
|
||||
|
@ -280,7 +280,6 @@ export const FloatingToolbarStory: StoryFn<typeof FloatingToolbar> = props => {
|
||||
style={{ position: 'fixed', bottom: '20px', width: '100%' }}
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<FloatingToolbar.Item>10 Selected</FloatingToolbar.Item>
|
||||
<FloatingToolbar.Separator />
|
||||
|
32
yarn.lock
32
yarn.lock
@ -239,6 +239,8 @@ __metadata:
|
||||
fake-indexeddb: "npm:^5.0.0"
|
||||
foxact: "npm:^0.2.20"
|
||||
jotai: "npm:^2.4.3"
|
||||
jotai-effect: "npm:^0.2.2"
|
||||
jotai-scope: "npm:^0.4.0"
|
||||
lit: "npm:^2.8.0"
|
||||
lodash: "npm:^4.17.21"
|
||||
lodash-es: "npm:^4.17.21"
|
||||
@ -253,6 +255,7 @@ __metadata:
|
||||
react-is: "npm:^18.2.0"
|
||||
react-paginate: "npm:^8.2.0"
|
||||
react-router-dom: "npm:^6.16.0"
|
||||
react-virtuoso: "npm:^4.6.2"
|
||||
rxjs: "npm:^7.8.1"
|
||||
typescript: "npm:^5.2.2"
|
||||
uuid: "npm:^9.0.1"
|
||||
@ -24429,6 +24432,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jotai-effect@npm:^0.2.2":
|
||||
version: 0.2.2
|
||||
resolution: "jotai-effect@npm:0.2.2"
|
||||
peerDependencies:
|
||||
jotai: ">=2.4.3"
|
||||
checksum: f74f90836b3afb203d65e4621ce9fc267b838685007cf30db2802e15088f40e9bbb4f232121a7010194562286187fe7bc488935d75a948fd256c5bb58e5c858f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jotai-scope@npm:^0.4.0":
|
||||
version: 0.4.0
|
||||
resolution: "jotai-scope@npm:0.4.0"
|
||||
peerDependencies:
|
||||
jotai: ">=2.5.0"
|
||||
react: ">=17.0.0"
|
||||
checksum: 398b76507570a674af3e5cbb6f45ede8e8e02014c5d416825952d21f274da6191bbb41d18d0b9bac4dd375e9b5b8c848886a13f930a9810e3de823bfe7a22137
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jotai@npm:^2.4.3":
|
||||
version: 2.4.3
|
||||
resolution: "jotai@npm:2.4.3"
|
||||
@ -30527,6 +30549,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-virtuoso@npm:^4.6.2":
|
||||
version: 4.6.2
|
||||
resolution: "react-virtuoso@npm:4.6.2"
|
||||
peerDependencies:
|
||||
react: ">=16 || >=17 || >= 18"
|
||||
react-dom: ">=16 || >=17 || >= 18"
|
||||
checksum: 770c5bc5a842c40cac780bfe6e8bfcf4a1fc79fe84438459240e33e1c347b75a5581cc0cf21035bb250591a7f09fe3eed35b94d0f47540e1ce09f2fc47337840
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:18.2.0, react@npm:^18.2.0":
|
||||
version: 18.2.0
|
||||
resolution: "react@npm:18.2.0"
|
||||
|
Loading…
Reference in New Issue
Block a user