mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 21:48:48 +08:00
refactor(component): virtual rendering page list (#4775)
Co-authored-by: Joooye_34 <Joooye1991@gmail.com>
This commit is contained in:
@@ -48,6 +48,8 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"foxact": "^0.2.20",
|
"foxact": "^0.2.20",
|
||||||
"jotai": "^2.4.3",
|
"jotai": "^2.4.3",
|
||||||
|
"jotai-effect": "^0.2.2",
|
||||||
|
"jotai-scope": "^0.4.0",
|
||||||
"lit": "^2.8.0",
|
"lit": "^2.8.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
"react-is": "^18.2.0",
|
"react-is": "^18.2.0",
|
||||||
"react-paginate": "^8.2.0",
|
"react-paginate": "^8.2.0",
|
||||||
"react-router-dom": "^6.16.0",
|
"react-router-dom": "^6.16.0",
|
||||||
|
"react-virtuoso": "^4.6.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-49
@@ -6,8 +6,6 @@ import {
|
|||||||
type MouseEventHandler,
|
type MouseEventHandler,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import * as styles from './floating-toolbar.css';
|
import * as styles from './floating-toolbar.css';
|
||||||
@@ -16,8 +14,6 @@ interface FloatingToolbarProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
// if dbclick outside of the panel, close the toolbar
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FloatingToolbarButtonProps {
|
interface FloatingToolbarButtonProps {
|
||||||
@@ -36,49 +32,7 @@ export function FloatingToolbar({
|
|||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
|
||||||
}: PropsWithChildren<FloatingToolbarProps>) {
|
}: PropsWithChildren<FloatingToolbarProps>) {
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const animatingRef = useRef(false);
|
|
||||||
|
|
||||||
// todo: move dbclick / esc to close to page list instead
|
|
||||||
useEffect(() => {
|
|
||||||
animatingRef.current = true;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
animatingRef.current = false;
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
if (open) {
|
|
||||||
// when dbclick outside of the panel or typing ESC, close the toolbar
|
|
||||||
const dbcHandler = (e: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
!contentRef.current?.contains(e.target as Node) &&
|
|
||||||
!animatingRef.current
|
|
||||||
) {
|
|
||||||
// close the toolbar
|
|
||||||
onOpenChange?.(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape' && !animatingRef.current) {
|
|
||||||
onOpenChange?.(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('dblclick', dbcHandler);
|
|
||||||
document.addEventListener('keydown', escHandler);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
document.removeEventListener('dblclick', dbcHandler);
|
|
||||||
document.removeEventListener('keydown', escHandler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [onOpenChange, open]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root open={open}>
|
<Popover.Root open={open}>
|
||||||
{/* Having Anchor here to let Popover to calculate the position of the place it is being used */}
|
{/* Having Anchor here to let Popover to calculate the position of the place it is being used */}
|
||||||
@@ -86,9 +40,7 @@ export function FloatingToolbar({
|
|||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
{/* always pop up on top for now */}
|
{/* always pop up on top for now */}
|
||||||
<Popover.Content side="top" className={styles.popoverContent}>
|
<Popover.Content side="top" className={styles.popoverContent}>
|
||||||
<Toolbar.Root ref={contentRef} className={clsx(styles.root)}>
|
<Toolbar.Root className={clsx(styles.root)}>{children}</Toolbar.Root>
|
||||||
{children}
|
|
||||||
</Toolbar.Root>
|
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Portal>
|
</Popover.Portal>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from './types';
|
|||||||
export * from './use-collection-manager';
|
export * from './use-collection-manager';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './view';
|
export * from './view';
|
||||||
|
export * from './virtualized-page-list';
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ export const header = style({
|
|||||||
padding: '0px 16px 0px 6px',
|
padding: '0px 16px 0px 6px',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
height: '28px',
|
height: '28px',
|
||||||
|
background: 'var(--affine-background-primary-color)',
|
||||||
':hover': {
|
':hover': {
|
||||||
background: 'var(--affine-hover-color)',
|
background: 'var(--affine-hover-color-filled)',
|
||||||
},
|
},
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons';
|
|||||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { PagePreview } from './page-content-preview';
|
import { PagePreview } from './page-content-preview';
|
||||||
import * as styles from './page-group.css';
|
import * as styles from './page-group.css';
|
||||||
import { PageListItem } from './page-list-item';
|
import { PageListItem } from './page-list-item';
|
||||||
import { pageListPropsAtom, selectionStateAtom } from './scoped-atoms';
|
import {
|
||||||
|
pageGroupCollapseStateAtom,
|
||||||
|
pageListPropsAtom,
|
||||||
|
selectionStateAtom,
|
||||||
|
useAtom,
|
||||||
|
useAtomValue,
|
||||||
|
} from './scoped-atoms';
|
||||||
import type {
|
import type {
|
||||||
PageGroupDefinition,
|
PageGroupDefinition,
|
||||||
PageGroupProps,
|
PageGroupProps,
|
||||||
@@ -21,7 +26,7 @@ import type {
|
|||||||
PageListProps,
|
PageListProps,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { type DateKey } from './types';
|
import { type DateKey } from './types';
|
||||||
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
import { betweenDaysAgo, shallowEqual, withinDaysAgo } from './utils';
|
||||||
|
|
||||||
// todo: optimize date matchers
|
// todo: optimize date matchers
|
||||||
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
||||||
@@ -57,6 +62,7 @@ const pageGroupDefinitions = {
|
|||||||
createDate: getDateGroupDefinitions('createDate'),
|
createDate: getDateGroupDefinitions('createDate'),
|
||||||
updatedDate: getDateGroupDefinitions('updatedDate'),
|
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||||
// add more here later
|
// add more here later
|
||||||
|
// todo: some page group definitions maybe dynamic
|
||||||
};
|
};
|
||||||
|
|
||||||
export function pagesToPageGroups(
|
export function pagesToPageGroups(
|
||||||
@@ -101,6 +107,78 @@ export function pagesToPageGroups(
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
||||||
|
const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom);
|
||||||
|
const collapsed = collapseState[id];
|
||||||
|
const onExpandedClicked: MouseEventHandler = useCallback(
|
||||||
|
e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setCollapseState(v => ({ ...v, [id]: !v[id] }));
|
||||||
|
},
|
||||||
|
[id, setCollapseState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectionState = useAtomValue(selectionStateAtom);
|
||||||
|
const selectedItems = useMemo(() => {
|
||||||
|
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
||||||
|
return items.filter(item => selectedPageIds.includes(item.id));
|
||||||
|
}, [items, selectionState.selectedPageIds]);
|
||||||
|
|
||||||
|
const allSelected = useMemo(() => {
|
||||||
|
return items.every(
|
||||||
|
item => selectionState.selectedPageIds?.includes(item.id)
|
||||||
|
);
|
||||||
|
}, [items, selectionState.selectedPageIds]);
|
||||||
|
|
||||||
|
const onSelectAll = useCallback(() => {
|
||||||
|
const nonCurrentGroupIds =
|
||||||
|
selectionState.selectedPageIds?.filter(
|
||||||
|
id => !items.map(item => item.id).includes(id)
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const newSelectedPageIds = allSelected
|
||||||
|
? nonCurrentGroupIds
|
||||||
|
: [...nonCurrentGroupIds, ...items.map(item => item.id)];
|
||||||
|
|
||||||
|
selectionState.onSelectedPageIdsChange?.(newSelectedPageIds);
|
||||||
|
}, [items, selectionState, allSelected]);
|
||||||
|
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
|
return label ? (
|
||||||
|
<div data-testid="page-list-group-header" className={styles.header}>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
onClick={onExpandedClicked}
|
||||||
|
data-testid="page-list-group-header-collapsed-button"
|
||||||
|
className={styles.collapsedIconContainer}
|
||||||
|
>
|
||||||
|
<ToggleCollapseIcon
|
||||||
|
className={styles.collapsedIcon}
|
||||||
|
data-collapsed={!!collapsed}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerLabel}>{label}</div>
|
||||||
|
{selectionState.selectionActive ? (
|
||||||
|
<div className={styles.headerCount}>
|
||||||
|
{selectedItems.length}/{items.length}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.spacer} />
|
||||||
|
{selectionState.selectionActive ? (
|
||||||
|
<button className={styles.selectAllButton} onClick={onSelectAll}>
|
||||||
|
{t[
|
||||||
|
allSelected
|
||||||
|
? 'com.affine.page.group-header.clear'
|
||||||
|
: 'com.affine.page.group-header.select-all'
|
||||||
|
]()}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
||||||
@@ -173,7 +251,7 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
|||||||
// todo: optimize how to render page meta list item
|
// todo: optimize how to render page meta list item
|
||||||
const requiredPropNames = [
|
const requiredPropNames = [
|
||||||
'blockSuiteWorkspace',
|
'blockSuiteWorkspace',
|
||||||
'clickMode',
|
'rowAsLink',
|
||||||
'isPreferredEdgeless',
|
'isPreferredEdgeless',
|
||||||
'pageOperationsRenderer',
|
'pageOperationsRenderer',
|
||||||
'selectedPageIds',
|
'selectedPageIds',
|
||||||
@@ -185,13 +263,17 @@ type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
|
|||||||
selectable: boolean;
|
selectable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const listPropsAtom = selectAtom(pageListPropsAtom, props => {
|
const listPropsAtom = selectAtom(
|
||||||
return Object.fromEntries(
|
pageListPropsAtom,
|
||||||
requiredPropNames.map(name => [name, props[name]])
|
props => {
|
||||||
) as RequiredProps;
|
return Object.fromEntries(
|
||||||
});
|
requiredPropNames.map(name => [name, props[name]])
|
||||||
|
) as RequiredProps;
|
||||||
|
},
|
||||||
|
shallowEqual
|
||||||
|
);
|
||||||
|
|
||||||
const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
|
export const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
|
||||||
const props = useAtomValue(listPropsAtom);
|
const props = useAtomValue(listPropsAtom);
|
||||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||||
return (
|
return (
|
||||||
@@ -247,10 +329,10 @@ function pageMetaToPageItemProp(
|
|||||||
? new Date(pageMeta.updatedDate)
|
? new Date(pageMeta.updatedDate)
|
||||||
: undefined,
|
: undefined,
|
||||||
to:
|
to:
|
||||||
props.clickMode === 'link'
|
props.rowAsLink && !props.selectable
|
||||||
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
|
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
|
||||||
: undefined,
|
: undefined,
|
||||||
onClick: props.clickMode === 'select' ? toggleSelection : undefined,
|
onClick: props.selectable ? toggleSelection : undefined,
|
||||||
icon: props.isPreferredEdgeless?.(pageMeta.id) ? (
|
icon: props.isPreferredEdgeless?.(pageMeta.id) ? (
|
||||||
<EdgelessIcon />
|
<EdgelessIcon />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
||||||
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import {
|
||||||
|
type MouseEventHandler,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { Checkbox, type CheckboxProps } from '../../ui/checkbox';
|
||||||
|
import * as styles from './page-list.css';
|
||||||
|
import {
|
||||||
|
pageListHandlersAtom,
|
||||||
|
pageListPropsAtom,
|
||||||
|
pagesAtom,
|
||||||
|
selectionStateAtom,
|
||||||
|
showOperationsAtom,
|
||||||
|
sorterAtom,
|
||||||
|
useAtom,
|
||||||
|
useAtomValue,
|
||||||
|
} from './scoped-atoms';
|
||||||
|
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
|
||||||
|
|
||||||
|
export const PageListHeaderCell = (props: HeaderCellProps) => {
|
||||||
|
const [sorter, setSorter] = useAtom(sorterAtom);
|
||||||
|
const onClick: MouseEventHandler = useCallback(() => {
|
||||||
|
if (props.sortable && props.sortKey) {
|
||||||
|
setSorter({
|
||||||
|
newSortKey: props.sortKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [props.sortKey, props.sortable, setSorter]);
|
||||||
|
|
||||||
|
const sorting = sorter.key === props.sortKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ColWrapper
|
||||||
|
flex={props.flex}
|
||||||
|
alignment={props.alignment}
|
||||||
|
onClick={onClick}
|
||||||
|
className={styles.headerCell}
|
||||||
|
data-sortable={props.sortable ? true : undefined}
|
||||||
|
data-sorting={sorting ? true : undefined}
|
||||||
|
style={props.style}
|
||||||
|
role="columnheader"
|
||||||
|
hideInSmallContainer={props.hideInSmallContainer}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
{sorting ? (
|
||||||
|
<div className={styles.headerCellSortIcon}>
|
||||||
|
{sorter.order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ColWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeaderColDef = {
|
||||||
|
key: string;
|
||||||
|
content: ReactNode;
|
||||||
|
flex: ColWrapperProps['flex'];
|
||||||
|
alignment?: ColWrapperProps['alignment'];
|
||||||
|
sortable?: boolean;
|
||||||
|
hideInSmallContainer?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeaderCellProps = ColWrapperProps & {
|
||||||
|
sortKey: keyof PageMeta;
|
||||||
|
sortable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// the checkbox on the header has three states:
|
||||||
|
// when list selectable = true, the checkbox will be presented
|
||||||
|
// when internal selection state is not enabled, it is a clickable <ListIcon /> that enables the selection state
|
||||||
|
// when internal selection state is enabled, it is a checkbox that reflects the selection state
|
||||||
|
const PageListHeaderCheckbox = () => {
|
||||||
|
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
|
||||||
|
const pages = useAtomValue(pagesAtom);
|
||||||
|
const onActivateSelection: MouseEventHandler = useCallback(
|
||||||
|
e => {
|
||||||
|
stopPropagation(e);
|
||||||
|
setSelectionState(true);
|
||||||
|
},
|
||||||
|
[setSelectionState]
|
||||||
|
);
|
||||||
|
const handlers = useAtomValue(pageListHandlersAtom);
|
||||||
|
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
||||||
|
(e, checked) => {
|
||||||
|
stopPropagation(e);
|
||||||
|
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
|
||||||
|
},
|
||||||
|
[handlers, pages]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selectionState.selectable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.headerTitleSelectionIconWrapper}
|
||||||
|
onClick={onActivateSelection}
|
||||||
|
>
|
||||||
|
{!selectionState.selectionActive ? (
|
||||||
|
<MultiSelectIcon />
|
||||||
|
) : (
|
||||||
|
<Checkbox
|
||||||
|
checked={selectionState.selectedPageIds?.length === pages.length}
|
||||||
|
indeterminate={
|
||||||
|
selectionState.selectedPageIds &&
|
||||||
|
selectionState.selectedPageIds.length > 0 &&
|
||||||
|
selectionState.selectedPageIds.length < pages.length
|
||||||
|
}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageListHeaderTitleCell = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
return (
|
||||||
|
<div className={styles.headerTitleCell}>
|
||||||
|
<PageListHeaderCheckbox />
|
||||||
|
{t['Title']()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideHeaderAtom = selectAtom(pageListPropsAtom, props => props.hideHeader);
|
||||||
|
|
||||||
|
// the table header for page list
|
||||||
|
export const PageListTableHeader = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const showOperations = useAtomValue(showOperationsAtom);
|
||||||
|
const hideHeader = useAtomValue(hideHeaderAtom);
|
||||||
|
const selectionState = useAtomValue(selectionStateAtom);
|
||||||
|
const headerCols = useMemo(() => {
|
||||||
|
const cols: (HeaderColDef | boolean)[] = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
content: <PageListHeaderTitleCell />,
|
||||||
|
flex: 6,
|
||||||
|
alignment: 'start',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tags',
|
||||||
|
content: t['Tags'](),
|
||||||
|
flex: 3,
|
||||||
|
alignment: 'end',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createDate',
|
||||||
|
content: t['Created'](),
|
||||||
|
flex: 1,
|
||||||
|
sortable: true,
|
||||||
|
alignment: 'end',
|
||||||
|
hideInSmallContainer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'updatedDate',
|
||||||
|
content: t['Updated'](),
|
||||||
|
flex: 1,
|
||||||
|
sortable: true,
|
||||||
|
alignment: 'end',
|
||||||
|
hideInSmallContainer: true,
|
||||||
|
},
|
||||||
|
showOperations && {
|
||||||
|
key: 'actions',
|
||||||
|
content: '',
|
||||||
|
flex: 1,
|
||||||
|
alignment: 'end',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return cols.filter((def): def is HeaderColDef => !!def);
|
||||||
|
}, [t, showOperations]);
|
||||||
|
|
||||||
|
if (hideHeader) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.tableHeader)}
|
||||||
|
data-selectable={selectionState.selectable}
|
||||||
|
data-selection-active={selectionState.selectionActive}
|
||||||
|
>
|
||||||
|
{headerCols.map(col => {
|
||||||
|
return (
|
||||||
|
<PageListHeaderCell
|
||||||
|
flex={col.flex}
|
||||||
|
alignment={col.alignment}
|
||||||
|
key={col.key}
|
||||||
|
sortKey={col.key as keyof PageMeta}
|
||||||
|
sortable={col.sortable}
|
||||||
|
style={{ overflow: 'visible' }}
|
||||||
|
hideInSmallContainer={col.hideInSmallContainer}
|
||||||
|
>
|
||||||
|
{col.content}
|
||||||
|
</PageListHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -84,7 +84,11 @@ const PageCreateDateCell = ({
|
|||||||
createDate,
|
createDate,
|
||||||
}: Pick<PageListItemProps, 'createDate'>) => {
|
}: Pick<PageListItemProps, 'createDate'>) => {
|
||||||
return (
|
return (
|
||||||
<div data-testid="page-list-item-date" className={styles.dateCell}>
|
<div
|
||||||
|
data-testid="page-list-item-date"
|
||||||
|
data-date-raw={createDate}
|
||||||
|
className={styles.dateCell}
|
||||||
|
>
|
||||||
{formatDate(createDate)}
|
{formatDate(createDate)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -94,7 +98,11 @@ const PageUpdatedDateCell = ({
|
|||||||
updatedDate,
|
updatedDate,
|
||||||
}: Pick<PageListItemProps, 'updatedDate'>) => {
|
}: Pick<PageListItemProps, 'updatedDate'>) => {
|
||||||
return (
|
return (
|
||||||
<div data-testid="page-list-item-date" className={styles.dateCell}>
|
<div
|
||||||
|
data-testid="page-list-item-date"
|
||||||
|
data-date-raw={updatedDate}
|
||||||
|
className={styles.dateCell}
|
||||||
|
>
|
||||||
{updatedDate ? formatDate(updatedDate) : '-'}
|
{updatedDate ? formatDate(updatedDate) : '-'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import * as itemStyles from './page-list-item.css';
|
|||||||
export const listRootContainer = createContainer('list-root-container');
|
export const listRootContainer = createContainer('list-root-container');
|
||||||
|
|
||||||
export const pageListScrollContainer = style({
|
export const pageListScrollContainer = style({
|
||||||
overflowY: 'auto',
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
flex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const root = style({
|
export const root = style({
|
||||||
@@ -23,7 +23,9 @@ export const groupsContainer = style({
|
|||||||
rowGap: '16px',
|
rowGap: '16px',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const header = style({
|
export const heading = style({});
|
||||||
|
|
||||||
|
export const tableHeader = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '10px 6px 10px 16px',
|
padding: '10px 6px 10px 16px',
|
||||||
@@ -37,7 +39,7 @@ export const header = style({
|
|||||||
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(`[data-has-scroll-top=true] ${header}`, {
|
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
|
||||||
boxShadow: '0 1px var(--affine-border-color)',
|
boxShadow: '0 1px var(--affine-border-color)',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,13 +75,22 @@ export const headerTitleCell = style({
|
|||||||
export const headerTitleSelectionIconWrapper = style({
|
export const headerTitleSelectionIconWrapper = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'flex-start',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
|
selectors: {
|
||||||
|
[`${tableHeader}[data-selectable=toggle] &`]: {
|
||||||
|
width: 32,
|
||||||
|
},
|
||||||
|
[`${tableHeader}[data-selection-active=true] &`]: {
|
||||||
|
width: 24,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const headerCellSortIcon = style({
|
export const headerCellSortIcon = style({
|
||||||
width: '14px',
|
display: 'inline-flex',
|
||||||
height: '14px',
|
fontSize: 14,
|
||||||
|
color: 'var(--affine-icon-color)',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const colWrapper = style({
|
export const colWrapper = style({
|
||||||
@@ -104,7 +115,7 @@ export const favoriteCell = style({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
selectors: {
|
selectors: {
|
||||||
[`&[data-favorite], &${itemStyles.root}:hover &`]: {
|
[`&[data-favorite], ${itemStyles.root}:hover &`]: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,86 +1,146 @@
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|
||||||
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai';
|
|
||||||
import { useHydrateAtoms } from 'jotai/utils';
|
|
||||||
import {
|
import {
|
||||||
type ForwardedRef,
|
type ForwardedRef,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
type MouseEventHandler,
|
memo,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { Checkbox, type CheckboxProps } from '../../ui/checkbox';
|
import { Scrollable } from '../../ui/scrollbar';
|
||||||
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
|
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
|
||||||
import { PageGroup } from './page-group';
|
import { PageGroup } from './page-group';
|
||||||
|
import { PageListTableHeader } from './page-header';
|
||||||
import * as styles from './page-list.css';
|
import * as styles from './page-list.css';
|
||||||
import {
|
import {
|
||||||
pageGroupsAtom,
|
pageGroupsAtom,
|
||||||
pageListHandlersAtom,
|
|
||||||
pageListPropsAtom,
|
pageListPropsAtom,
|
||||||
pagesAtom,
|
PageListProvider,
|
||||||
selectionStateAtom,
|
selectionStateAtom,
|
||||||
showOperationsAtom,
|
useAtom,
|
||||||
sorterAtom,
|
useAtomValue,
|
||||||
|
useSetAtom,
|
||||||
} from './scoped-atoms';
|
} from './scoped-atoms';
|
||||||
import type { PageListHandle, PageListProps } from './types';
|
import type { PageListHandle, PageListProps } from './types';
|
||||||
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a list of pages, render a list of pages
|
* Given a list of pages, render a list of pages
|
||||||
*/
|
*/
|
||||||
export const PageList = forwardRef<PageListHandle, PageListProps>(
|
export const PageList = forwardRef<PageListHandle, PageListProps>(
|
||||||
function PageListHandle(props, ref) {
|
function PageList(props, ref) {
|
||||||
return (
|
return (
|
||||||
<Provider>
|
// push pageListProps to the atom so that downstream components can consume it
|
||||||
<PageListInner {...props} handleRef={ref} />
|
// this makes sure pageListPropsAtom is always populated
|
||||||
</Provider>
|
// @ts-expect-error fix type issues later
|
||||||
|
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
||||||
|
<PageListInnerWrapper {...props} handleRef={ref}>
|
||||||
|
<PageListInner {...props} />
|
||||||
|
</PageListInnerWrapper>
|
||||||
|
</PageListProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const PageListInner = ({
|
// when pressing ESC or double clicking outside of the page list, close the selection mode
|
||||||
handleRef,
|
// todo: use jotai-effect instead but it seems it does not work with jotai-scope?
|
||||||
...props
|
const usePageSelectionStateEffect = () => {
|
||||||
}: PageListProps & { handleRef: ForwardedRef<PageListHandle> }) => {
|
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||||
// push pageListProps to the atom so that downstream components can consume it
|
|
||||||
useHydrateAtoms([[pageListPropsAtom, props]], {
|
|
||||||
// note: by turning on dangerouslyForceHydrate, downstream component need to use selectAtom to consume the atom
|
|
||||||
// note2: not using it for now because it will cause some other issues
|
|
||||||
// dangerouslyForceHydrate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
|
|
||||||
const setPageListSelectionState = useSetAtom(selectionStateAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageListPropsAtom(props);
|
if (
|
||||||
}, [props, setPageListPropsAtom]);
|
selectionState.selectionActive &&
|
||||||
|
selectionState.selectable === 'toggle'
|
||||||
useImperativeHandle(
|
) {
|
||||||
handleRef,
|
const startTime = Date.now();
|
||||||
() => {
|
const dblClickHandler = (e: MouseEvent) => {
|
||||||
return {
|
if (Date.now() - startTime < 200) {
|
||||||
toggleSelectable: () => {
|
return;
|
||||||
setPageListSelectionState(false);
|
}
|
||||||
},
|
const target = e.target as HTMLElement;
|
||||||
|
// skip if event target is inside of a button or input
|
||||||
|
// or within a toolbar (like page list floating toolbar)
|
||||||
|
if (
|
||||||
|
target.tagName === 'BUTTON' ||
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
(e.target as HTMLElement).closest('button, input, [role="toolbar"]')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectionActive(false);
|
||||||
};
|
};
|
||||||
},
|
|
||||||
[setPageListSelectionState]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
|
if (Date.now() - startTime < 200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setSelectionActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('dblclick', dblClickHandler);
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('dblclick', dblClickHandler);
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, [
|
||||||
|
selectionState.selectable,
|
||||||
|
selectionState.selectionActive,
|
||||||
|
setSelectionActive,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageListInnerWrapper = memo(
|
||||||
|
({
|
||||||
|
handleRef,
|
||||||
|
children,
|
||||||
|
onSelectionActiveChange,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<
|
||||||
|
PageListProps & { handleRef: ForwardedRef<PageListHandle> }
|
||||||
|
>) => {
|
||||||
|
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
|
||||||
|
const [selectionState, setPageListSelectionState] =
|
||||||
|
useAtom(selectionStateAtom);
|
||||||
|
usePageSelectionStateEffect();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageListPropsAtom(props);
|
||||||
|
}, [props, setPageListPropsAtom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSelectionActiveChange?.(!!selectionState.selectionActive);
|
||||||
|
}, [onSelectionActiveChange, selectionState.selectionActive]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
handleRef,
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
toggleSelectable: () => {
|
||||||
|
setPageListSelectionState(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[setPageListSelectionState]
|
||||||
|
);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
PageListInnerWrapper.displayName = 'PageListInnerWrapper';
|
||||||
|
|
||||||
|
const PageListInner = (props: PageListProps) => {
|
||||||
const groups = useAtomValue(pageGroupsAtom);
|
const groups = useAtomValue(pageGroupsAtom);
|
||||||
const hideHeader = props.hideHeader;
|
const hideHeader = props.hideHeader;
|
||||||
return (
|
return (
|
||||||
<div className={clsx(props.className, styles.root)}>
|
<div className={clsx(props.className, styles.root)}>
|
||||||
{!hideHeader ? <PageListHeader /> : null}
|
{!hideHeader ? <PageListTableHeader /> : null}
|
||||||
<div className={styles.groupsContainer}>
|
<div className={styles.groupsContainer}>
|
||||||
{groups.map(group => (
|
{groups.map(group => (
|
||||||
<PageGroup key={group.id} {...group} />
|
<PageGroup key={group.id} {...group} />
|
||||||
@@ -90,176 +150,6 @@ const PageListInner = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type HeaderCellProps = ColWrapperProps & {
|
|
||||||
sortKey: keyof PageMeta;
|
|
||||||
sortable?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PageListHeaderCell = (props: HeaderCellProps) => {
|
|
||||||
const [sorter, setSorter] = useAtom(sorterAtom);
|
|
||||||
const onClick: MouseEventHandler = useCallback(() => {
|
|
||||||
if (props.sortable && props.sortKey) {
|
|
||||||
setSorter({
|
|
||||||
newSortKey: props.sortKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [props.sortKey, props.sortable, setSorter]);
|
|
||||||
|
|
||||||
const sorting = sorter.key === props.sortKey;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ColWrapper
|
|
||||||
flex={props.flex}
|
|
||||||
alignment={props.alignment}
|
|
||||||
onClick={onClick}
|
|
||||||
className={styles.headerCell}
|
|
||||||
data-sortable={props.sortable ? true : undefined}
|
|
||||||
data-sorting={sorting ? true : undefined}
|
|
||||||
style={props.style}
|
|
||||||
hideInSmallContainer={props.hideInSmallContainer}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
{sorting ? (
|
|
||||||
<div className={styles.headerCellSortIcon}>
|
|
||||||
{sorter.order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</ColWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type HeaderColDef = {
|
|
||||||
key: string;
|
|
||||||
content: ReactNode;
|
|
||||||
flex: ColWrapperProps['flex'];
|
|
||||||
alignment?: ColWrapperProps['alignment'];
|
|
||||||
sortable?: boolean;
|
|
||||||
hideInSmallContainer?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// the checkbox on the header has three states:
|
|
||||||
// when list selectable = true, the checkbox will be presented
|
|
||||||
// when internal selection state is not enabled, it is a clickable <ListIcon /> that enables the selection state
|
|
||||||
// when internal selection state is enabled, it is a checkbox that reflects the selection state
|
|
||||||
const PageListHeaderCheckbox = () => {
|
|
||||||
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
|
|
||||||
const pages = useAtomValue(pagesAtom);
|
|
||||||
const onActivateSelection: MouseEventHandler = useCallback(
|
|
||||||
e => {
|
|
||||||
stopPropagation(e);
|
|
||||||
setSelectionState(true);
|
|
||||||
},
|
|
||||||
[setSelectionState]
|
|
||||||
);
|
|
||||||
const handlers = useAtomValue(pageListHandlersAtom);
|
|
||||||
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
|
||||||
(e, checked) => {
|
|
||||||
stopPropagation(e);
|
|
||||||
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
|
|
||||||
},
|
|
||||||
[handlers, pages]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!selectionState.selectable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.headerTitleSelectionIconWrapper}
|
|
||||||
onClick={onActivateSelection}
|
|
||||||
>
|
|
||||||
{!selectionState.selectionActive ? (
|
|
||||||
<MultiSelectIcon />
|
|
||||||
) : (
|
|
||||||
<Checkbox
|
|
||||||
checked={selectionState.selectedPageIds?.length === pages.length}
|
|
||||||
indeterminate={
|
|
||||||
selectionState.selectedPageIds &&
|
|
||||||
selectionState.selectedPageIds.length > 0 &&
|
|
||||||
selectionState.selectedPageIds.length < pages.length
|
|
||||||
}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PageListHeaderTitleCell = () => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
return (
|
|
||||||
<div className={styles.headerTitleCell}>
|
|
||||||
<PageListHeaderCheckbox />
|
|
||||||
{t['Title']()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PageListHeader = () => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const showOperations = useAtomValue(showOperationsAtom);
|
|
||||||
const headerCols = useMemo(() => {
|
|
||||||
const cols: (HeaderColDef | boolean)[] = [
|
|
||||||
{
|
|
||||||
key: 'title',
|
|
||||||
content: <PageListHeaderTitleCell />,
|
|
||||||
flex: 6,
|
|
||||||
alignment: 'start',
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tags',
|
|
||||||
content: t['Tags'](),
|
|
||||||
flex: 3,
|
|
||||||
alignment: 'end',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'createDate',
|
|
||||||
content: t['Created'](),
|
|
||||||
flex: 1,
|
|
||||||
sortable: true,
|
|
||||||
alignment: 'end',
|
|
||||||
hideInSmallContainer: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'updatedDate',
|
|
||||||
content: t['Updated'](),
|
|
||||||
flex: 1,
|
|
||||||
sortable: true,
|
|
||||||
alignment: 'end',
|
|
||||||
hideInSmallContainer: true,
|
|
||||||
},
|
|
||||||
showOperations && {
|
|
||||||
key: 'actions',
|
|
||||||
content: '',
|
|
||||||
flex: 1,
|
|
||||||
alignment: 'end',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return cols.filter((def): def is HeaderColDef => !!def);
|
|
||||||
}, [t, showOperations]);
|
|
||||||
return (
|
|
||||||
<div className={clsx(styles.header)}>
|
|
||||||
{headerCols.map(col => {
|
|
||||||
return (
|
|
||||||
<PageListHeaderCell
|
|
||||||
flex={col.flex}
|
|
||||||
alignment={col.alignment}
|
|
||||||
key={col.key}
|
|
||||||
sortKey={col.key as keyof PageMeta}
|
|
||||||
sortable={col.sortable}
|
|
||||||
style={{ overflow: 'visible' }}
|
|
||||||
hideInSmallContainer={col.hideInSmallContainer}
|
|
||||||
>
|
|
||||||
{col.content}
|
|
||||||
</PageListHeaderCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageListScrollContainerProps {
|
interface PageListScrollContainerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
@@ -287,14 +177,14 @@ export const PageListScrollContainer = forwardRef<
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Scrollable.Root
|
||||||
style={style}
|
style={style}
|
||||||
ref={setNodeRef}
|
|
||||||
data-has-scroll-top={hasScrollTop}
|
data-has-scroll-top={hasScrollTop}
|
||||||
className={clsx(styles.pageListScrollContainer, className)}
|
className={clsx(styles.pageListScrollContainer, className)}
|
||||||
>
|
>
|
||||||
{children}
|
<Scrollable.Viewport ref={setNodeRef}>{children}</Scrollable.Viewport>
|
||||||
</div>
|
<Scrollable.Scrollbar />
|
||||||
|
</Scrollable.Root>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ const tagColorMap = (color: string) => {
|
|||||||
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
||||||
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
||||||
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
||||||
|
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
|
||||||
|
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
|
||||||
|
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
|
||||||
|
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
|
||||||
};
|
};
|
||||||
return mapping[color] || color;
|
return mapping[color] || color;
|
||||||
};
|
};
|
||||||
@@ -109,7 +113,6 @@ export const PageTags = ({
|
|||||||
// @ts-expect-error it's fine
|
// @ts-expect-error it's fine
|
||||||
'--hover-max-width': sanitizedWidthOnHover,
|
'--hover-max-width': sanitizedWidthOnHover,
|
||||||
}}
|
}}
|
||||||
onClick={stopPropagation}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -123,7 +126,12 @@ export const PageTags = ({
|
|||||||
{tagsNormal}
|
{tagsNormal}
|
||||||
</div>
|
</div>
|
||||||
{maxItems && tags.length > maxItems ? (
|
{maxItems && tags.length > maxItems ? (
|
||||||
<Menu items={tagsInPopover}>
|
<Menu
|
||||||
|
items={tagsInPopover}
|
||||||
|
contentOptions={{
|
||||||
|
onClick: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className={styles.showMoreTag}>
|
<div className={styles.showMoreTag}>
|
||||||
<MoreHorizontalIcon />
|
<MoreHorizontalIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+48
-20
@@ -2,28 +2,40 @@ import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
|||||||
import type { PageMeta } from '@blocksuite/store';
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import { createIsolation } from 'jotai-scope';
|
||||||
|
|
||||||
import { pagesToPageGroups } from './page-group';
|
import { pagesToPageGroups } from './page-group';
|
||||||
import type { PageListProps, PageMetaRecord } from './types';
|
import type {
|
||||||
|
PageListProps,
|
||||||
|
PageMetaRecord,
|
||||||
|
VirtualizedPageListProps,
|
||||||
|
} from './types';
|
||||||
|
import { shallowEqual } from './utils';
|
||||||
|
|
||||||
// for ease of use in the component tree
|
// for ease of use in the component tree
|
||||||
// note: must use selectAtom to access this atom for efficiency
|
// note: must use selectAtom to access this atom for efficiency
|
||||||
// @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms
|
// @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms
|
||||||
export const pageListPropsAtom = atom<PageListProps>();
|
export const pageListPropsAtom = atom<
|
||||||
|
PageListProps & Partial<VirtualizedPageListProps>
|
||||||
|
>();
|
||||||
|
|
||||||
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
|
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
|
||||||
const selectionActiveAtom = atom(false);
|
const selectionActiveAtom = atom(false);
|
||||||
|
|
||||||
export const selectionStateAtom = atom(
|
export const selectionStateAtom = atom(
|
||||||
get => {
|
get => {
|
||||||
const baseAtom = selectAtom(pageListPropsAtom, props => {
|
const baseAtom = selectAtom(
|
||||||
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
|
pageListPropsAtom,
|
||||||
return {
|
props => {
|
||||||
selectable,
|
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
|
||||||
selectedPageIds,
|
return {
|
||||||
onSelectedPageIdsChange,
|
selectable,
|
||||||
};
|
selectedPageIds,
|
||||||
});
|
onSelectedPageIdsChange,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
shallowEqual
|
||||||
|
);
|
||||||
const baseState = get(baseAtom);
|
const baseState = get(baseAtom);
|
||||||
const selectionActive =
|
const selectionActive =
|
||||||
baseState.selectable === 'toggle'
|
baseState.selectable === 'toggle'
|
||||||
@@ -39,18 +51,27 @@ export const selectionStateAtom = atom(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// id -> isCollapsed
|
||||||
|
// maybe reset on page on unmount?
|
||||||
|
export const pageGroupCollapseStateAtom = atom<Record<string, boolean>>({});
|
||||||
|
|
||||||
// get handlers from pageListPropsAtom
|
// get handlers from pageListPropsAtom
|
||||||
export const pageListHandlersAtom = selectAtom(pageListPropsAtom, props => {
|
export const pageListHandlersAtom = selectAtom(
|
||||||
const { onSelectedPageIdsChange, onDragStart, onDragEnd } = props;
|
pageListPropsAtom,
|
||||||
|
props => {
|
||||||
|
const { onSelectedPageIdsChange } = props;
|
||||||
|
return {
|
||||||
|
onSelectedPageIdsChange,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
shallowEqual
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
export const pagesAtom = selectAtom(
|
||||||
onSelectedPageIdsChange,
|
pageListPropsAtom,
|
||||||
onDragStart,
|
props => props.pages,
|
||||||
onDragEnd,
|
shallowEqual
|
||||||
};
|
);
|
||||||
});
|
|
||||||
|
|
||||||
export const pagesAtom = selectAtom(pageListPropsAtom, props => props.pages);
|
|
||||||
|
|
||||||
export const showOperationsAtom = selectAtom(
|
export const showOperationsAtom = selectAtom(
|
||||||
pageListPropsAtom,
|
pageListPropsAtom,
|
||||||
@@ -177,3 +198,10 @@ export const pageGroupsAtom = atom(get => {
|
|||||||
}
|
}
|
||||||
return pagesToPageGroups(sorter.pages, groupBy);
|
return pagesToPageGroups(sorter.pages, groupBy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
Provider: PageListProvider,
|
||||||
|
useAtom,
|
||||||
|
useAtomValue,
|
||||||
|
useSetAtom,
|
||||||
|
} = createIsolation();
|
||||||
@@ -40,23 +40,27 @@ export interface PageListProps {
|
|||||||
// required data:
|
// required data:
|
||||||
pages: PageMeta[];
|
pages: PageMeta[];
|
||||||
blockSuiteWorkspace: Workspace;
|
blockSuiteWorkspace: Workspace;
|
||||||
|
|
||||||
className?: string;
|
className?: string;
|
||||||
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
||||||
groupBy?: PagesGroupByType | false;
|
groupBy?: PagesGroupByType | false;
|
||||||
isPreferredEdgeless: (pageId: string) => boolean;
|
isPreferredEdgeless: (pageId: string) => boolean; // determines the icon used for each row
|
||||||
clickMode?: 'select' | 'link'; // select => click to select; link => click to navigate
|
rowAsLink?: boolean;
|
||||||
selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item
|
selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item
|
||||||
selectedPageIds?: string[]; // selected page ids
|
selectedPageIds?: string[]; // selected page ids
|
||||||
onSelectedPageIdsChange?: (selected: string[]) => void;
|
onSelectedPageIdsChange?: (selected: string[]) => void;
|
||||||
|
onSelectionActiveChange?: (active: boolean) => void;
|
||||||
draggable?: boolean; // whether or not to allow dragging this page item
|
draggable?: boolean; // whether or not to allow dragging this page item
|
||||||
onDragStart?: (pageId: string) => void;
|
|
||||||
onDragEnd?: (pageId: string) => void;
|
|
||||||
// we also need the following to make sure the page list functions properly
|
// we also need the following to make sure the page list functions properly
|
||||||
// maybe we could also give a function to render PageListItem?
|
// maybe we could also give a function to render PageListItem?
|
||||||
pageOperationsRenderer?: (page: PageMeta) => ReactNode;
|
pageOperationsRenderer?: (page: PageMeta) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VirtualizedPageListProps extends PageListProps {
|
||||||
|
heading?: ReactNode; // the user provided heading part (non sticky, above the original header)
|
||||||
|
atTopThreshold?: number; // the threshold to determine whether or not the user has scrolled to the top. default is 0
|
||||||
|
atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageListHandle {
|
export interface PageListHandle {
|
||||||
toggleSelectable: () => void;
|
toggleSelectable: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// add dblclick & esc to document when page selection is active
|
||||||
|
//
|
||||||
|
export function usePageSelectionEvents() {}
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
type SorterConfig<
|
|
||||||
T extends Record<string | number | symbol, unknown> = Record<
|
|
||||||
string | number | symbol,
|
|
||||||
unknown
|
|
||||||
>,
|
|
||||||
> = {
|
|
||||||
data: T[];
|
|
||||||
key: keyof T;
|
|
||||||
order: 'asc' | 'desc' | 'none';
|
|
||||||
sortingFn?: (
|
|
||||||
ctx: {
|
|
||||||
key: keyof T;
|
|
||||||
order: 'asc' | 'desc';
|
|
||||||
},
|
|
||||||
a: T,
|
|
||||||
b: T
|
|
||||||
) => number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultSortingFn: SorterConfig['sortingFn'] = (ctx, a, b) => {
|
|
||||||
const valA = a[ctx.key];
|
|
||||||
const valB = b[ctx.key];
|
|
||||||
const revert = ctx.order === 'desc';
|
|
||||||
const revertSymbol = revert ? -1 : 1;
|
|
||||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
|
||||||
return valA.localeCompare(valB) * revertSymbol;
|
|
||||||
}
|
|
||||||
if (typeof valA === 'number' && typeof valB === 'number') {
|
|
||||||
return valA - valB * revertSymbol;
|
|
||||||
}
|
|
||||||
if (valA instanceof Date && valB instanceof Date) {
|
|
||||||
return (valA.getTime() - valB.getTime()) * revertSymbol;
|
|
||||||
}
|
|
||||||
if (!valA) {
|
|
||||||
return -1 * revertSymbol;
|
|
||||||
}
|
|
||||||
if (!valB) {
|
|
||||||
return 1 * revertSymbol;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(valA) && Array.isArray(valB)) {
|
|
||||||
return (valA.length - valB.length) * revertSymbol;
|
|
||||||
}
|
|
||||||
console.warn(
|
|
||||||
'Unsupported sorting type! Please use custom sorting function.',
|
|
||||||
valA,
|
|
||||||
valB
|
|
||||||
);
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSorter = <T extends Record<keyof any, unknown>>({
|
|
||||||
data,
|
|
||||||
sortingFn = defaultSortingFn,
|
|
||||||
...defaultSorter
|
|
||||||
}: SorterConfig<T> & { order: 'asc' | 'desc' }) => {
|
|
||||||
const [sorter, setSorter] = useState<Omit<SorterConfig<T>, 'data'>>({
|
|
||||||
...defaultSorter,
|
|
||||||
// We should not show sorting icon at first time
|
|
||||||
order: 'none',
|
|
||||||
});
|
|
||||||
const sortCtx =
|
|
||||||
sorter.order === 'none'
|
|
||||||
? {
|
|
||||||
key: defaultSorter.key,
|
|
||||||
order: defaultSorter.order,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
key: sorter.key,
|
|
||||||
order: sorter.order,
|
|
||||||
};
|
|
||||||
const compareFn = (a: T, b: T) => sortingFn(sortCtx, a, b);
|
|
||||||
const sortedData = data.sort(compareFn);
|
|
||||||
|
|
||||||
const shiftOrder = (key?: keyof T) => {
|
|
||||||
const orders = ['asc', 'desc', 'none'] as const;
|
|
||||||
if (key && key !== sorter.key) {
|
|
||||||
// Key changed
|
|
||||||
setSorter({
|
|
||||||
...sorter,
|
|
||||||
key,
|
|
||||||
order: orders[0],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSorter({
|
|
||||||
...sorter,
|
|
||||||
order: orders[(orders.indexOf(sorter.order) + 1) % orders.length],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
data: sortedData,
|
|
||||||
order: sorter.order,
|
|
||||||
key: sorter.order !== 'none' ? sorter.key : null,
|
|
||||||
/**
|
|
||||||
* @deprecated In most cases, we no necessary use `updateSorter` directly.
|
|
||||||
*/
|
|
||||||
updateSorter: (newVal: Partial<SorterConfig<T>>) =>
|
|
||||||
setSorter({ ...sorter, ...newVal }),
|
|
||||||
shiftOrder,
|
|
||||||
resetSorter: () => setSorter(defaultSorter),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -124,10 +124,10 @@ export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
|||||||
|
|
||||||
export const withinDaysAgo = (date: Date, days: number): boolean => {
|
export const withinDaysAgo = (date: Date, days: number): boolean => {
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
const day = startDate.getDay();
|
const day = startDate.getDate();
|
||||||
const month = startDate.getMonth();
|
const month = startDate.getMonth();
|
||||||
const year = startDate.getFullYear();
|
const year = startDate.getFullYear();
|
||||||
return new Date(year, month, day - days) <= date;
|
return new Date(year, month, day - days + 1) <= date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const betweenDaysAgo = (
|
export const betweenDaysAgo = (
|
||||||
@@ -145,3 +145,38 @@ export function stopPropagation(event: BaseSyntheticEvent) {
|
|||||||
export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) {
|
export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
|
||||||
|
export function shallowEqual(objA: any, objB: any) {
|
||||||
|
if (Object.is(objA, objB)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof objA !== 'object' ||
|
||||||
|
objA === null ||
|
||||||
|
typeof objB !== 'object' ||
|
||||||
|
objB === null
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysA = Object.keys(objA);
|
||||||
|
const keysB = Object.keys(objB);
|
||||||
|
|
||||||
|
if (keysA.length !== keysB.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test for A's keys different from B.
|
||||||
|
for (let i = 0; i < keysA.length; i++) {
|
||||||
|
if (
|
||||||
|
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
|
||||||
|
!Object.is(objA[keysA[i]], objB[keysA[i]])
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
+17
-21
@@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type AllPageListConfig,
|
type AllPageListConfig,
|
||||||
FilterList,
|
FilterList,
|
||||||
PageList,
|
VirtualizedPageList,
|
||||||
PageListScrollContainer,
|
|
||||||
} from '@affine/component/page-list';
|
} from '@affine/component/page-list';
|
||||||
import type { Collection } from '@affine/env/filter';
|
import type { Collection } from '@affine/env/filter';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
@@ -104,25 +103,22 @@ export const PagesMode = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{searchedList.length ? (
|
{searchedList.length ? (
|
||||||
<PageListScrollContainer>
|
<VirtualizedPageList
|
||||||
<PageList
|
className={styles.pageList}
|
||||||
clickMode="select"
|
pages={searchedList}
|
||||||
className={styles.pageList}
|
groupBy={false}
|
||||||
pages={searchedList}
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
groupBy={false}
|
selectable
|
||||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
onSelectedPageIdsChange={ids => {
|
||||||
selectable
|
updateCollection({
|
||||||
onSelectedPageIdsChange={ids => {
|
...collection,
|
||||||
updateCollection({
|
allowList: ids,
|
||||||
...collection,
|
});
|
||||||
allowList: ids,
|
}}
|
||||||
});
|
pageOperationsRenderer={pageOperationsRenderer}
|
||||||
}}
|
selectedPageIds={collection.allowList}
|
||||||
pageOperationsRenderer={pageOperationsRenderer}
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
selectedPageIds={collection.allowList}
|
></VirtualizedPageList>
|
||||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
|
||||||
></PageList>
|
|
||||||
</PageListScrollContainer>
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyList search={searchText} />
|
<EmptyList search={searchText} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
-2
@@ -248,7 +248,6 @@ export const RulesMode = ({
|
|||||||
{rulesPages.length > 0 ? (
|
{rulesPages.length > 0 ? (
|
||||||
<PageList
|
<PageList
|
||||||
hideHeader
|
hideHeader
|
||||||
clickMode="select"
|
|
||||||
className={styles.resultPages}
|
className={styles.resultPages}
|
||||||
pages={rulesPages}
|
pages={rulesPages}
|
||||||
groupBy={false}
|
groupBy={false}
|
||||||
@@ -269,7 +268,6 @@ export const RulesMode = ({
|
|||||||
</div>
|
</div>
|
||||||
<PageList
|
<PageList
|
||||||
hideHeader
|
hideHeader
|
||||||
clickMode="select"
|
|
||||||
className={styles.resultPages}
|
className={styles.resultPages}
|
||||||
pages={allowListPages}
|
pages={allowListPages}
|
||||||
groupBy={false}
|
groupBy={false}
|
||||||
|
|||||||
+12
-14
@@ -6,9 +6,9 @@ import { Menu } from '@toeverything/components/menu';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { VirtualizedPageList } from '../..';
|
||||||
import { FilterList } from '../../filter';
|
import { FilterList } from '../../filter';
|
||||||
import { VariableSelect } from '../../filter/vars';
|
import { VariableSelect } from '../../filter/vars';
|
||||||
import { PageList, PageListScrollContainer } from '../../page-list';
|
|
||||||
import { AffineShapeIcon } from '../affine-shape';
|
import { AffineShapeIcon } from '../affine-shape';
|
||||||
import type { AllPageListConfig } from './edit-collection';
|
import type { AllPageListConfig } from './edit-collection';
|
||||||
import * as styles from './edit-collection.css';
|
import * as styles from './edit-collection.css';
|
||||||
@@ -93,19 +93,17 @@ export const SelectPage = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{searchedList.length ? (
|
{searchedList.length ? (
|
||||||
<PageListScrollContainer>
|
<VirtualizedPageList
|
||||||
<PageList
|
className={styles.pageList}
|
||||||
clickMode="select"
|
pages={searchedList}
|
||||||
className={styles.pageList}
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
pages={searchedList}
|
selectable
|
||||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
groupBy={false}
|
||||||
selectable
|
onSelectedPageIdsChange={onChange}
|
||||||
onSelectedPageIdsChange={onChange}
|
selectedPageIds={value}
|
||||||
selectedPageIds={value}
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
/>
|
||||||
></PageList>
|
|
||||||
</PageListScrollContainer>
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyList search={searchText} />
|
<EmptyList search={searchText} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { Scrollable } from '../../ui/scrollbar';
|
||||||
|
import { PageGroupHeader, PageMetaListItemRenderer } from './page-group';
|
||||||
|
import { PageListTableHeader } from './page-header';
|
||||||
|
import { PageListInnerWrapper } from './page-list';
|
||||||
|
import * as styles from './page-list.css';
|
||||||
|
import {
|
||||||
|
pageGroupCollapseStateAtom,
|
||||||
|
pageGroupsAtom,
|
||||||
|
pageListPropsAtom,
|
||||||
|
PageListProvider,
|
||||||
|
useAtomValue,
|
||||||
|
} from './scoped-atoms';
|
||||||
|
import type {
|
||||||
|
PageGroupProps,
|
||||||
|
PageListHandle,
|
||||||
|
VirtualizedPageListProps,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// we have three item types for rendering rows in Virtuoso
|
||||||
|
type VirtuosoItemType =
|
||||||
|
| 'sticky-header'
|
||||||
|
| 'page-group-header'
|
||||||
|
| 'page-item'
|
||||||
|
| 'page-item-spacer';
|
||||||
|
|
||||||
|
interface BaseVirtuosoItem {
|
||||||
|
type: VirtuosoItemType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtuosoItemStickyHeader extends BaseVirtuosoItem {
|
||||||
|
type: 'sticky-header';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtuosoItemPageItem extends BaseVirtuosoItem {
|
||||||
|
type: 'page-item';
|
||||||
|
data: PageMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtuosoItemPageGroupHeader extends BaseVirtuosoItem {
|
||||||
|
type: 'page-group-header';
|
||||||
|
data: PageGroupProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtuosoPageItemSpacer extends BaseVirtuosoItem {
|
||||||
|
type: 'page-item-spacer';
|
||||||
|
data: {
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type VirtuosoItem =
|
||||||
|
| VirtuosoItemStickyHeader
|
||||||
|
| VirtuosoItemPageItem
|
||||||
|
| VirtuosoItemPageGroupHeader
|
||||||
|
| VirtuosoPageItemSpacer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of pages, render a list of pages
|
||||||
|
* Similar to normal PageList, but uses react-virtuoso to render the list (virtual rendering)
|
||||||
|
*/
|
||||||
|
export const VirtualizedPageList = forwardRef<
|
||||||
|
PageListHandle,
|
||||||
|
VirtualizedPageListProps
|
||||||
|
>(function VirtualizedPageList(props, ref) {
|
||||||
|
return (
|
||||||
|
// push pageListProps to the atom so that downstream components can consume it
|
||||||
|
// this makes sure pageListPropsAtom is always populated
|
||||||
|
// @ts-expect-error fix type issues later
|
||||||
|
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
||||||
|
<PageListInnerWrapper {...props} handleRef={ref}>
|
||||||
|
<PageListInner {...props} />
|
||||||
|
</PageListInnerWrapper>
|
||||||
|
</PageListProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const headingAtom = selectAtom(pageListPropsAtom, props => props.heading);
|
||||||
|
|
||||||
|
const PageListHeading = () => {
|
||||||
|
const heading = useAtomValue(headingAtom);
|
||||||
|
return <div className={styles.heading}>{heading}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useVirtuosoItems = () => {
|
||||||
|
const groups = useAtomValue(pageGroupsAtom);
|
||||||
|
const groupCollapsedState = useAtomValue(pageGroupCollapseStateAtom);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const items: VirtuosoItem[] = [];
|
||||||
|
|
||||||
|
// 1.
|
||||||
|
// always put sticky header at the top
|
||||||
|
// the visibility of sticky header is inside of PageListTableHeader
|
||||||
|
items.push({
|
||||||
|
type: 'sticky-header',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2.
|
||||||
|
// iterate groups and add page items
|
||||||
|
for (const group of groups) {
|
||||||
|
// skip empty group header since it will cause issue in virtuoso ("Zero-sized element")
|
||||||
|
if (group.label) {
|
||||||
|
items.push({
|
||||||
|
type: 'page-group-header',
|
||||||
|
data: group,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// do not render items if the group is collapsed
|
||||||
|
if (!groupCollapsedState[group.id]) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
items.push({
|
||||||
|
type: 'page-item',
|
||||||
|
data: item,
|
||||||
|
});
|
||||||
|
// add a spacer between items (4px), unless it's the last item
|
||||||
|
if (item !== group.items[group.items.length - 1]) {
|
||||||
|
items.push({
|
||||||
|
type: 'page-item-spacer',
|
||||||
|
data: {
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add a spacer between groups (16px)
|
||||||
|
items.push({
|
||||||
|
type: 'page-item-spacer',
|
||||||
|
data: {
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [groupCollapsedState, groups]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemContentRenderer = (_index: number, data: VirtuosoItem) => {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'sticky-header':
|
||||||
|
return <PageListTableHeader />;
|
||||||
|
case 'page-group-header':
|
||||||
|
return <PageGroupHeader {...data.data} />;
|
||||||
|
case 'page-item':
|
||||||
|
return <PageMetaListItemRenderer {...data.data} />;
|
||||||
|
case 'page-item-spacer':
|
||||||
|
return <div style={{ height: data.data.height }} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Scroller = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
|
||||||
|
>(({ children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Scrollable.Root>
|
||||||
|
<Scrollable.Viewport {...props} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</Scrollable.Viewport>
|
||||||
|
<Scrollable.Scrollbar />
|
||||||
|
</Scrollable.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Scroller.displayName = 'Scroller';
|
||||||
|
|
||||||
|
const PageListInner = ({
|
||||||
|
atTopStateChange,
|
||||||
|
atTopThreshold,
|
||||||
|
...props
|
||||||
|
}: VirtualizedPageListProps) => {
|
||||||
|
const virtuosoItems = useVirtuosoItems();
|
||||||
|
const [atTop, setAtTop] = useState(false);
|
||||||
|
const handleAtTopStateChange = useCallback(
|
||||||
|
(atTop: boolean) => {
|
||||||
|
setAtTop(atTop);
|
||||||
|
atTopStateChange?.(atTop);
|
||||||
|
},
|
||||||
|
[atTopStateChange]
|
||||||
|
);
|
||||||
|
const components = useMemo(() => {
|
||||||
|
return {
|
||||||
|
Header: props.heading ? PageListHeading : undefined,
|
||||||
|
Scroller: Scroller,
|
||||||
|
};
|
||||||
|
}, [props.heading]);
|
||||||
|
return (
|
||||||
|
<Virtuoso<VirtuosoItem>
|
||||||
|
data-has-scroll-top={!atTop}
|
||||||
|
atTopThreshold={atTopThreshold ?? 0}
|
||||||
|
atTopStateChange={handleAtTopStateChange}
|
||||||
|
components={components}
|
||||||
|
data={virtuosoItems}
|
||||||
|
data-testid="virtualized-page-list"
|
||||||
|
data-total-count={props.pages.length} // for testing, since we do not know the total count in test
|
||||||
|
topItemCount={1} // sticky header
|
||||||
|
totalCount={virtuosoItems.length}
|
||||||
|
itemContent={itemContentRenderer}
|
||||||
|
className={clsx(props.className, styles.root)}
|
||||||
|
// todo: set a reasonable overscan value to avoid blank space?
|
||||||
|
// overscan={100}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -57,6 +57,7 @@ export const Checkbox = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(styles.root, disabled && styles.disabled)}
|
className={clsx(styles.root, disabled && styles.disabled)}
|
||||||
|
role="checkbox"
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ export const scrollableViewport = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(`${scrollableViewport} > div`, {
|
globalStyle(`${scrollableViewport} > div`, {
|
||||||
maxWidth: '100%',
|
display: 'contents !important',
|
||||||
display: 'block !important',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const scrollableContainer = style({
|
export const scrollableContainer = style({
|
||||||
@@ -44,7 +43,6 @@ export const scrollbar = style({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
marginRight: '4px',
|
|
||||||
width: 'var(--scrollbar-width)',
|
width: 'var(--scrollbar-width)',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './scrollable';
|
||||||
export * from './scrollbar';
|
export * from './scrollbar';
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { forwardRef, type RefAttributes } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './index.css';
|
||||||
|
|
||||||
|
export const ScrollableRoot = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ScrollArea.ScrollAreaProps & RefAttributes<HTMLDivElement>
|
||||||
|
>(({ children, className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ScrollArea.Root
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(className, styles.scrollableContainerRoot)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollArea.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ScrollableRoot.displayName = 'ScrollableRoot';
|
||||||
|
|
||||||
|
export const ScrollableViewport = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ScrollArea.ScrollAreaViewportProps & RefAttributes<HTMLDivElement>
|
||||||
|
>(({ children, className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ScrollArea.Viewport
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(className, styles.scrollableViewport)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollArea.Viewport>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ScrollableViewport.displayName = 'ScrollableViewport';
|
||||||
|
|
||||||
|
export const ScrollableScrollbar = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ScrollArea.ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>
|
||||||
|
>(({ children, className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ScrollArea.Scrollbar
|
||||||
|
orientation="vertical"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(className, styles.scrollbar)}
|
||||||
|
>
|
||||||
|
<ScrollArea.Thumb className={styles.scrollbarThumb} />
|
||||||
|
{children}
|
||||||
|
</ScrollArea.Scrollbar>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ScrollableScrollbar.displayName = 'ScrollableScrollbar';
|
||||||
|
|
||||||
|
export const Scrollable = {
|
||||||
|
Root: ScrollableRoot,
|
||||||
|
Viewport: ScrollableViewport,
|
||||||
|
Scrollbar: ScrollableScrollbar,
|
||||||
|
};
|
||||||
+1
-1
@@ -24,6 +24,6 @@ export const workspaceType = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const scrollbar = style({
|
export const scrollbar = style({
|
||||||
transform: 'translateX(10px)',
|
transform: 'translateX(8px)',
|
||||||
width: '4px',
|
width: '4px',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const scrollContainer = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const allPagesHeader = style({
|
export const allPagesHeader = style({
|
||||||
|
height: 100,
|
||||||
|
alignItems: 'center',
|
||||||
padding: '48px 16px 20px 24px',
|
padding: '48px 16px 20px 24px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -23,7 +25,7 @@ export const allPagesHeader = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const allPagesHeaderTitle = style({
|
export const allPagesHeaderTitle = style({
|
||||||
fontSize: 'var(--affine-font-h-3)',
|
fontSize: 'var(--affine-font-h-5)',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: 'var(--affine-text-secondary-color)',
|
color: 'var(--affine-text-secondary-color)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import {
|
|||||||
FloatingToolbar,
|
FloatingToolbar,
|
||||||
NewPageButton as PureNewPageButton,
|
NewPageButton as PureNewPageButton,
|
||||||
OperationCell,
|
OperationCell,
|
||||||
PageList,
|
|
||||||
type PageListHandle,
|
type PageListHandle,
|
||||||
PageListScrollContainer,
|
|
||||||
useCollectionManager,
|
useCollectionManager,
|
||||||
|
VirtualizedPageList,
|
||||||
} from '@affine/component/page-list';
|
} from '@affine/component/page-list';
|
||||||
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
|
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
|
||||||
import { Trans } from '@affine/i18n';
|
import { Trans } from '@affine/i18n';
|
||||||
@@ -27,7 +26,6 @@ import clsx from 'clsx';
|
|||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -145,19 +143,12 @@ const usePageOperationsRenderer = () => {
|
|||||||
const PageListFloatingToolbar = ({
|
const PageListFloatingToolbar = ({
|
||||||
selectedIds,
|
selectedIds,
|
||||||
onClose,
|
onClose,
|
||||||
|
open,
|
||||||
}: {
|
}: {
|
||||||
|
open: boolean;
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const open = selectedIds.length > 0;
|
|
||||||
const handleOpenChange = useCallback(
|
|
||||||
(open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose]
|
|
||||||
);
|
|
||||||
const [currentWorkspace] = useCurrentWorkspace();
|
const [currentWorkspace] = useCurrentWorkspace();
|
||||||
const { setTrashModal } = useTrashModalHelper(
|
const { setTrashModal } = useTrashModalHelper(
|
||||||
currentWorkspace.blockSuiteWorkspace
|
currentWorkspace.blockSuiteWorkspace
|
||||||
@@ -177,11 +168,7 @@ const PageListFloatingToolbar = ({
|
|||||||
}, [pageMetas, selectedIds, setTrashModal]);
|
}, [pageMetas, selectedIds, setTrashModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FloatingToolbar
|
<FloatingToolbar className={styles.floatingToolbar} open={open}>
|
||||||
className={styles.floatingToolbar}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
>
|
|
||||||
<FloatingToolbar.Item>
|
<FloatingToolbar.Item>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="com.affine.page.toolbar.selected"
|
i18nKey="com.affine.page.toolbar.selected"
|
||||||
@@ -190,7 +177,7 @@ const PageListFloatingToolbar = ({
|
|||||||
<div className={styles.toolbarSelectedNumber}>
|
<div className={styles.toolbarSelectedNumber}>
|
||||||
{{ count: selectedIds.length } as any}
|
{{ count: selectedIds.length } as any}
|
||||||
</div>
|
</div>
|
||||||
pages selected
|
selected
|
||||||
</Trans>
|
</Trans>
|
||||||
</FloatingToolbar.Item>
|
</FloatingToolbar.Item>
|
||||||
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
|
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
|
||||||
@@ -245,9 +232,10 @@ export const AllPage = () => {
|
|||||||
);
|
);
|
||||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||||
const pageListRef = useRef<PageListHandle>(null);
|
const pageListRef = useRef<PageListHandle>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const deselectAllAndToggleSelect = useCallback(() => {
|
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||||
setSelectedPageIds([]);
|
|
||||||
|
const hideFloatingToolbar = useCallback(() => {
|
||||||
pageListRef.current?.toggleSelectable();
|
pageListRef.current?.toggleSelectable();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -257,24 +245,7 @@ export const AllPage = () => {
|
|||||||
return selectedPageIds.filter(id => ids.includes(id));
|
return selectedPageIds.filter(id => ids.includes(id));
|
||||||
}, [filteredPageMetas, selectedPageIds]);
|
}, [filteredPageMetas, selectedPageIds]);
|
||||||
|
|
||||||
const [showHeaderCreateNewPage, setShowHeaderCreateNewPage] = useState(false);
|
const [hideHeaderCreateNewPage, setHideHeaderCreateNewPage] = useState(true);
|
||||||
// when PageListScrollContainer scrolls above 40px, show the create new page button on header
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (container) {
|
|
||||||
const handleScroll = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
const scrollTop = container.scrollTop ?? 0;
|
|
||||||
setShowHeaderCreateNewPage(scrollTop > 40);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
container.addEventListener('scroll', handleScroll);
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('scroll', handleScroll);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
@@ -289,7 +260,7 @@ export const AllPage = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
styles.headerCreateNewButton,
|
styles.headerCreateNewButton,
|
||||||
!showHeaderCreateNewPage && styles.headerCreateNewButtonHidden
|
hideHeaderCreateNewPage && styles.headerCreateNewButtonHidden
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
@@ -297,37 +268,36 @@ export const AllPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<PageListScrollContainer
|
{filteredPageMetas.length > 0 ? (
|
||||||
ref={containerRef}
|
<>
|
||||||
className={styles.scrollContainer}
|
<VirtualizedPageList
|
||||||
>
|
ref={pageListRef}
|
||||||
<PageListHeader />
|
selectable="toggle"
|
||||||
{filteredPageMetas.length > 0 ? (
|
draggable
|
||||||
<>
|
atTopThreshold={80}
|
||||||
<PageList
|
atTopStateChange={setHideHeaderCreateNewPage}
|
||||||
ref={pageListRef}
|
onSelectionActiveChange={setShowFloatingToolbar}
|
||||||
selectable="toggle"
|
heading={<PageListHeader />}
|
||||||
draggable
|
selectedPageIds={filteredSelectedPageIds}
|
||||||
selectedPageIds={filteredSelectedPageIds}
|
onSelectedPageIdsChange={setSelectedPageIds}
|
||||||
onSelectedPageIdsChange={setSelectedPageIds}
|
pages={filteredPageMetas}
|
||||||
pages={filteredPageMetas}
|
rowAsLink
|
||||||
clickMode="link"
|
isPreferredEdgeless={isPreferredEdgeless}
|
||||||
isPreferredEdgeless={isPreferredEdgeless}
|
|
||||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
|
||||||
pageOperationsRenderer={pageOperationsRenderer}
|
|
||||||
/>
|
|
||||||
<PageListFloatingToolbar
|
|
||||||
selectedIds={filteredSelectedPageIds}
|
|
||||||
onClose={deselectAllAndToggleSelect}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<EmptyPageList
|
|
||||||
type="all"
|
|
||||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||||
|
pageOperationsRenderer={pageOperationsRenderer}
|
||||||
/>
|
/>
|
||||||
)}
|
<PageListFloatingToolbar
|
||||||
</PageListScrollContainer>
|
open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
|
||||||
|
selectedIds={filteredSelectedPageIds}
|
||||||
|
onClose={hideFloatingToolbar}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyPageList
|
||||||
|
type="all"
|
||||||
|
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { toast } from '@affine/component';
|
import { toast } from '@affine/component';
|
||||||
import {
|
import {
|
||||||
PageList,
|
|
||||||
PageListScrollContainer,
|
|
||||||
TrashOperationCell,
|
TrashOperationCell,
|
||||||
|
VirtualizedPageList,
|
||||||
} from '@affine/component/page-list';
|
} from '@affine/component/page-list';
|
||||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
import { WorkspaceSubPath } from '@affine/env/workspace';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
@@ -61,30 +60,28 @@ export const TrashPage = () => {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WorkspaceHeader
|
|
||||||
currentWorkspaceId={currentWorkspace.id}
|
|
||||||
currentEntry={{
|
|
||||||
subPath: WorkspaceSubPath.TRASH,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<PageListScrollContainer className={styles.scrollContainer}>
|
<WorkspaceHeader
|
||||||
{filteredPageMetas.length > 0 ? (
|
currentWorkspaceId={currentWorkspace.id}
|
||||||
<PageList
|
currentEntry={{
|
||||||
pages={filteredPageMetas}
|
subPath: WorkspaceSubPath.TRASH,
|
||||||
clickMode="link"
|
}}
|
||||||
groupBy={false}
|
/>
|
||||||
isPreferredEdgeless={isPreferredEdgeless}
|
{filteredPageMetas.length > 0 ? (
|
||||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
<VirtualizedPageList
|
||||||
pageOperationsRenderer={pageOperationsRenderer}
|
pages={filteredPageMetas}
|
||||||
/>
|
rowAsLink
|
||||||
) : (
|
groupBy={false}
|
||||||
<EmptyPageList
|
isPreferredEdgeless={isPreferredEdgeless}
|
||||||
type="trash"
|
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
pageOperationsRenderer={pageOperationsRenderer}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
</PageListScrollContainer>
|
<EmptyPageList
|
||||||
|
type="trash"
|
||||||
|
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -394,8 +394,8 @@
|
|||||||
"com.affine.all-pages.header": "All Pages",
|
"com.affine.all-pages.header": "All Pages",
|
||||||
"com.affine.collections.header": "Collections",
|
"com.affine.collections.header": "Collections",
|
||||||
"com.affine.page.group-header.select-all": "Select All",
|
"com.affine.page.group-header.select-all": "Select All",
|
||||||
"com.affine.page.toolbar.selected_one": "<0>{{count}}</0> page selected",
|
"com.affine.page.group-header.clear": "Clear Selection",
|
||||||
"com.affine.page.toolbar.selected_others": "<0>{{count}}</0> page(s) selected",
|
"com.affine.page.toolbar.selected": "<0>{{count}}</0> selected",
|
||||||
"com.affine.collection.allCollections": "All Collections",
|
"com.affine.collection.allCollections": "All Collections",
|
||||||
"com.affine.collection.emptyCollection": "Empty Collection",
|
"com.affine.collection.emptyCollection": "Empty Collection",
|
||||||
"com.affine.collection.emptyCollectionDescription": "Collection is a smart folder where you can manually add pages or automatically add pages through rules.",
|
"com.affine.collection.emptyCollectionDescription": "Collection is a smart folder where you can manually add pages or automatically add pages through rules.",
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
checkDatePicker,
|
checkDatePicker,
|
||||||
checkDatePickerMonth,
|
checkDatePickerMonth,
|
||||||
checkFilterName,
|
checkFilterName,
|
||||||
checkPagesCount,
|
|
||||||
clickDatePicker,
|
clickDatePicker,
|
||||||
createFirstFilter,
|
createFirstFilter,
|
||||||
createPageWithTag,
|
createPageWithTag,
|
||||||
fillDatePicker,
|
fillDatePicker,
|
||||||
|
getPagesCount,
|
||||||
selectDateFromDatePicker,
|
selectDateFromDatePicker,
|
||||||
selectMonthFromMonthPicker,
|
selectMonthFromMonthPicker,
|
||||||
selectTag,
|
selectTag,
|
||||||
@@ -89,8 +89,7 @@ test('allow creation of filters by created time', async ({ page }) => {
|
|||||||
await clickNewPageButton(page);
|
await clickNewPageButton(page);
|
||||||
await clickSideBarAllPageButton(page);
|
await clickSideBarAllPageButton(page);
|
||||||
await waitForAllPagesLoad(page);
|
await waitForAllPagesLoad(page);
|
||||||
const pages = await page.locator('[data-testid="page-list-item"]').all();
|
const pageCount = await getPagesCount(page);
|
||||||
const pageCount = pages.length;
|
|
||||||
expect(pageCount).not.toBe(0);
|
expect(pageCount).not.toBe(0);
|
||||||
await createFirstFilter(page, 'Created');
|
await createFirstFilter(page, 'Created');
|
||||||
await checkFilterName(page, 'after');
|
await checkFilterName(page, 'after');
|
||||||
@@ -98,11 +97,11 @@ test('allow creation of filters by created time', async ({ page }) => {
|
|||||||
const yesterday = new Date();
|
const yesterday = new Date();
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
await checkDatePicker(page, yesterday);
|
await checkDatePicker(page, yesterday);
|
||||||
await checkPagesCount(page, 1);
|
expect(await getPagesCount(page)).toBe(1);
|
||||||
// change date
|
// change date
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
await fillDatePicker(page, today);
|
await fillDatePicker(page, today);
|
||||||
await checkPagesCount(page, 0);
|
expect(await getPagesCount(page)).toBe(0);
|
||||||
// change filter
|
// change filter
|
||||||
await page.getByTestId('filter-name').click();
|
await page.getByTestId('filter-name').click();
|
||||||
await page.getByTestId('filler-tag-before').click();
|
await page.getByTestId('filler-tag-before').click();
|
||||||
@@ -110,7 +109,7 @@ test('allow creation of filters by created time', async ({ page }) => {
|
|||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
await fillDatePicker(page, tomorrow);
|
await fillDatePicker(page, tomorrow);
|
||||||
await checkDatePicker(page, tomorrow);
|
await checkDatePicker(page, tomorrow);
|
||||||
await checkPagesCount(page, pageCount);
|
expect(await getPagesCount(page)).toBe(pageCount);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creation of filters by created time, then click date picker to modify the date', async ({
|
test('creation of filters by created time, then click date picker to modify the date', async ({
|
||||||
@@ -121,8 +120,7 @@ test('creation of filters by created time, then click date picker to modify the
|
|||||||
await clickNewPageButton(page);
|
await clickNewPageButton(page);
|
||||||
await clickSideBarAllPageButton(page);
|
await clickSideBarAllPageButton(page);
|
||||||
await waitForAllPagesLoad(page);
|
await waitForAllPagesLoad(page);
|
||||||
const pages = await page.locator('[data-testid="page-list-item"]').all();
|
const pageCount = await getPagesCount(page);
|
||||||
const pageCount = pages.length;
|
|
||||||
expect(pageCount).not.toBe(0);
|
expect(pageCount).not.toBe(0);
|
||||||
await createFirstFilter(page, 'Created');
|
await createFirstFilter(page, 'Created');
|
||||||
await checkFilterName(page, 'after');
|
await checkFilterName(page, 'after');
|
||||||
@@ -130,11 +128,11 @@ test('creation of filters by created time, then click date picker to modify the
|
|||||||
const yesterday = new Date();
|
const yesterday = new Date();
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
await checkDatePicker(page, yesterday);
|
await checkDatePicker(page, yesterday);
|
||||||
await checkPagesCount(page, 1);
|
expect(await getPagesCount(page)).toBe(1);
|
||||||
// change date
|
// change date
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
await selectDateFromDatePicker(page, today);
|
await selectDateFromDatePicker(page, today);
|
||||||
await checkPagesCount(page, 0);
|
expect(await getPagesCount(page)).toBe(0);
|
||||||
// change filter
|
// change filter
|
||||||
await page.locator('[data-testid="filter-name"]').click();
|
await page.locator('[data-testid="filter-name"]').click();
|
||||||
await page.getByTestId('filler-tag-before').click();
|
await page.getByTestId('filler-tag-before').click();
|
||||||
@@ -142,7 +140,7 @@ test('creation of filters by created time, then click date picker to modify the
|
|||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
await selectDateFromDatePicker(page, tomorrow);
|
await selectDateFromDatePicker(page, tomorrow);
|
||||||
await checkDatePicker(page, tomorrow);
|
await checkDatePicker(page, tomorrow);
|
||||||
await checkPagesCount(page, pageCount);
|
expect(await getPagesCount(page)).toBe(pageCount);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('use monthpicker to modify the month of datepicker', async ({ page }) => {
|
test('use monthpicker to modify the month of datepicker', async ({ page }) => {
|
||||||
@@ -174,8 +172,7 @@ test('allow creation of filters by tags', async ({ page }) => {
|
|||||||
await waitForEditorLoad(page);
|
await waitForEditorLoad(page);
|
||||||
await clickSideBarAllPageButton(page);
|
await clickSideBarAllPageButton(page);
|
||||||
await waitForAllPagesLoad(page);
|
await waitForAllPagesLoad(page);
|
||||||
const pages = await page.locator('[data-testid="page-list-item"]').all();
|
const pageCount = await getPagesCount(page);
|
||||||
const pageCount = pages.length;
|
|
||||||
expect(pageCount).not.toBe(0);
|
expect(pageCount).not.toBe(0);
|
||||||
await createFirstFilter(page, 'Tags');
|
await createFirstFilter(page, 'Tags');
|
||||||
await checkFilterName(page, 'is not empty');
|
await checkFilterName(page, 'is not empty');
|
||||||
@@ -188,12 +185,12 @@ test('allow creation of filters by tags', async ({ page }) => {
|
|||||||
await createPageWithTag(page, { title: 'Page B', tags: ['B'] });
|
await createPageWithTag(page, { title: 'Page B', tags: ['B'] });
|
||||||
await clickSideBarAllPageButton(page);
|
await clickSideBarAllPageButton(page);
|
||||||
await checkFilterName(page, 'is not empty');
|
await checkFilterName(page, 'is not empty');
|
||||||
await checkPagesCount(page, pagesWithTagsCount + 2);
|
expect(await getPagesCount(page)).toBe(pagesWithTagsCount + 2);
|
||||||
await changeFilter(page, 'contains all');
|
await changeFilter(page, 'contains all');
|
||||||
await checkPagesCount(page, pageCount + 2);
|
expect(await getPagesCount(page)).toBe(pageCount + 2);
|
||||||
await selectTag(page, 'A');
|
await selectTag(page, 'A');
|
||||||
await checkPagesCount(page, 1);
|
expect(await getPagesCount(page)).toBe(1);
|
||||||
await changeFilter(page, 'does not contains all');
|
await changeFilter(page, 'does not contains all');
|
||||||
await selectTag(page, 'B');
|
await selectTag(page, 'B');
|
||||||
await checkPagesCount(page, pageCount + 1);
|
expect(await getPagesCount(page)).toBe(pageCount + 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test } from '@affine-test/kit/playwright';
|
import { test } from '@affine-test/kit/playwright';
|
||||||
|
import { getPagesCount } from '@affine-test/kit/utils/filter';
|
||||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||||
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
|
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
|
||||||
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
|
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
|
||||||
@@ -48,13 +49,11 @@ test('create one workspace in the workspace list', async ({
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await clickSideBarAllPageButton(page);
|
await clickSideBarAllPageButton(page);
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
const pageList = page.locator('[data-testid=page-list-item]');
|
const result = await getPagesCount(page);
|
||||||
const result = await pageList.count();
|
|
||||||
expect(result).toBe(13);
|
expect(result).toBe(13);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForTimeout(4000);
|
await page.waitForTimeout(4000);
|
||||||
const pageList1 = page.locator('[data-testid=page-list-item]');
|
const result1 = await getPagesCount(page);
|
||||||
const result1 = await pageList1.count();
|
|
||||||
expect(result1).toBe(13);
|
expect(result1).toBe(13);
|
||||||
const currentWorkspace = await workspace.current();
|
const currentWorkspace = await workspace.current();
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,17 @@ const dateFormat = (date: Date) => {
|
|||||||
return `${month} ${day}`;
|
return `${month} ${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkPagesCount = async (page: Page, count: number) => {
|
// fixme: there could be multiple page lists in the Page
|
||||||
expect(
|
export const getPagesCount = async (page: Page) => {
|
||||||
(await page.locator('[data-testid="page-list-item"]').all()).length
|
const locator = page.locator('[data-testid="virtualized-page-list"]');
|
||||||
).toBe(count);
|
const pageListCount = await locator.count();
|
||||||
|
|
||||||
|
if (pageListCount === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await locator.getAttribute('data-total-count');
|
||||||
|
return count ? parseInt(count) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkDatePicker = async (page: Page, date: Date) => {
|
export const checkDatePicker = async (page: Page, date: Date) => {
|
||||||
|
|||||||
@@ -280,7 +280,6 @@ export const FloatingToolbarStory: StoryFn<typeof FloatingToolbar> = props => {
|
|||||||
style={{ position: 'fixed', bottom: '20px', width: '100%' }}
|
style={{ position: 'fixed', bottom: '20px', width: '100%' }}
|
||||||
{...props}
|
{...props}
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
|
||||||
>
|
>
|
||||||
<FloatingToolbar.Item>10 Selected</FloatingToolbar.Item>
|
<FloatingToolbar.Item>10 Selected</FloatingToolbar.Item>
|
||||||
<FloatingToolbar.Separator />
|
<FloatingToolbar.Separator />
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ __metadata:
|
|||||||
fake-indexeddb: "npm:^5.0.0"
|
fake-indexeddb: "npm:^5.0.0"
|
||||||
foxact: "npm:^0.2.20"
|
foxact: "npm:^0.2.20"
|
||||||
jotai: "npm:^2.4.3"
|
jotai: "npm:^2.4.3"
|
||||||
|
jotai-effect: "npm:^0.2.2"
|
||||||
|
jotai-scope: "npm:^0.4.0"
|
||||||
lit: "npm:^2.8.0"
|
lit: "npm:^2.8.0"
|
||||||
lodash: "npm:^4.17.21"
|
lodash: "npm:^4.17.21"
|
||||||
lodash-es: "npm:^4.17.21"
|
lodash-es: "npm:^4.17.21"
|
||||||
@@ -253,6 +255,7 @@ __metadata:
|
|||||||
react-is: "npm:^18.2.0"
|
react-is: "npm:^18.2.0"
|
||||||
react-paginate: "npm:^8.2.0"
|
react-paginate: "npm:^8.2.0"
|
||||||
react-router-dom: "npm:^6.16.0"
|
react-router-dom: "npm:^6.16.0"
|
||||||
|
react-virtuoso: "npm:^4.6.2"
|
||||||
rxjs: "npm:^7.8.1"
|
rxjs: "npm:^7.8.1"
|
||||||
typescript: "npm:^5.2.2"
|
typescript: "npm:^5.2.2"
|
||||||
uuid: "npm:^9.0.1"
|
uuid: "npm:^9.0.1"
|
||||||
@@ -24429,6 +24432,25 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"jotai-effect@npm:^0.2.2":
|
||||||
|
version: 0.2.2
|
||||||
|
resolution: "jotai-effect@npm:0.2.2"
|
||||||
|
peerDependencies:
|
||||||
|
jotai: ">=2.4.3"
|
||||||
|
checksum: f74f90836b3afb203d65e4621ce9fc267b838685007cf30db2802e15088f40e9bbb4f232121a7010194562286187fe7bc488935d75a948fd256c5bb58e5c858f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"jotai-scope@npm:^0.4.0":
|
||||||
|
version: 0.4.0
|
||||||
|
resolution: "jotai-scope@npm:0.4.0"
|
||||||
|
peerDependencies:
|
||||||
|
jotai: ">=2.5.0"
|
||||||
|
react: ">=17.0.0"
|
||||||
|
checksum: 398b76507570a674af3e5cbb6f45ede8e8e02014c5d416825952d21f274da6191bbb41d18d0b9bac4dd375e9b5b8c848886a13f930a9810e3de823bfe7a22137
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"jotai@npm:^2.4.3":
|
"jotai@npm:^2.4.3":
|
||||||
version: 2.4.3
|
version: 2.4.3
|
||||||
resolution: "jotai@npm:2.4.3"
|
resolution: "jotai@npm:2.4.3"
|
||||||
@@ -30527,6 +30549,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"react-virtuoso@npm:^4.6.2":
|
||||||
|
version: 4.6.2
|
||||||
|
resolution: "react-virtuoso@npm:4.6.2"
|
||||||
|
peerDependencies:
|
||||||
|
react: ">=16 || >=17 || >= 18"
|
||||||
|
react-dom: ">=16 || >=17 || >= 18"
|
||||||
|
checksum: 770c5bc5a842c40cac780bfe6e8bfcf4a1fc79fe84438459240e33e1c347b75a5581cc0cf21035bb250591a7f09fe3eed35b94d0f47540e1ce09f2fc47337840
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"react@npm:18.2.0, react@npm:^18.2.0":
|
"react@npm:18.2.0, react@npm:^18.2.0":
|
||||||
version: 18.2.0
|
version: 18.2.0
|
||||||
resolution: "react@npm:18.2.0"
|
resolution: "react@npm:18.2.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user