diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 5a94947e22..6521539331 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -48,6 +48,8 @@ "dayjs": "^1.11.10", "foxact": "^0.2.20", "jotai": "^2.4.3", + "jotai-effect": "^0.2.2", + "jotai-scope": "^0.4.0", "lit": "^2.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", @@ -62,6 +64,7 @@ "react-is": "^18.2.0", "react-paginate": "^8.2.0", "react-router-dom": "^6.16.0", + "react-virtuoso": "^4.6.2", "rxjs": "^7.8.1", "uuid": "^9.0.1" }, diff --git a/packages/frontend/component/src/components/page-list/components/floating-toobar.tsx b/packages/frontend/component/src/components/page-list/components/floating-toobar.tsx index 5c3efe0014..515452490b 100644 --- a/packages/frontend/component/src/components/page-list/components/floating-toobar.tsx +++ b/packages/frontend/component/src/components/page-list/components/floating-toobar.tsx @@ -6,8 +6,6 @@ import { type MouseEventHandler, type PropsWithChildren, type ReactNode, - useEffect, - useRef, } from 'react'; import * as styles from './floating-toolbar.css'; @@ -16,8 +14,6 @@ interface FloatingToolbarProps { className?: string; style?: CSSProperties; open?: boolean; - // if dbclick outside of the panel, close the toolbar - onOpenChange?: (open: boolean) => void; } interface FloatingToolbarButtonProps { @@ -36,49 +32,7 @@ export function FloatingToolbar({ style, className, open, - onOpenChange, }: PropsWithChildren) { - const contentRef = useRef(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 ( {/* Having Anchor here to let Popover to calculate the position of the place it is being used */} @@ -86,9 +40,7 @@ export function FloatingToolbar({ {/* always pop up on top for now */} - - {children} - + {children} diff --git a/packages/frontend/component/src/components/page-list/index.tsx b/packages/frontend/component/src/components/page-list/index.tsx index 05b9d061ac..6a500cb2a6 100644 --- a/packages/frontend/component/src/components/page-list/index.tsx +++ b/packages/frontend/component/src/components/page-list/index.tsx @@ -11,3 +11,4 @@ export * from './types'; export * from './use-collection-manager'; export * from './utils'; export * from './view'; +export * from './virtualized-page-list'; diff --git a/packages/frontend/component/src/components/page-list/page-group.css.ts b/packages/frontend/component/src/components/page-list/page-group.css.ts index ddc8a8cb94..f8299c74e7 100644 --- a/packages/frontend/component/src/components/page-list/page-group.css.ts +++ b/packages/frontend/component/src/components/page-list/page-group.css.ts @@ -52,8 +52,9 @@ export const header = style({ padding: '0px 16px 0px 6px', gap: 4, height: '28px', + background: 'var(--affine-background-primary-color)', ':hover': { - background: 'var(--affine-hover-color)', + background: 'var(--affine-hover-color-filled)', }, userSelect: 'none', }); diff --git a/packages/frontend/component/src/components/page-list/page-group.tsx b/packages/frontend/component/src/components/page-list/page-group.tsx index 5aeea688aa..29f8bf9f1d 100644 --- a/packages/frontend/component/src/components/page-list/page-group.tsx +++ b/packages/frontend/component/src/components/page-list/page-group.tsx @@ -6,14 +6,19 @@ import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons'; import type { PageMeta, Workspace } from '@blocksuite/store'; import * as Collapsible from '@radix-ui/react-collapsible'; import clsx from 'clsx'; -import { useAtomValue } from 'jotai'; import { selectAtom } from 'jotai/utils'; import { type MouseEventHandler, useCallback, useMemo, useState } from 'react'; import { PagePreview } from './page-content-preview'; import * as styles from './page-group.css'; import { PageListItem } from './page-list-item'; -import { pageListPropsAtom, selectionStateAtom } from './scoped-atoms'; +import { + pageGroupCollapseStateAtom, + pageListPropsAtom, + selectionStateAtom, + useAtom, + useAtomValue, +} from './scoped-atoms'; import type { PageGroupDefinition, PageGroupProps, @@ -21,7 +26,7 @@ import type { PageListProps, } from './types'; import { type DateKey } from './types'; -import { betweenDaysAgo, withinDaysAgo } from './utils'; +import { betweenDaysAgo, shallowEqual, withinDaysAgo } from './utils'; // todo: optimize date matchers const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [ @@ -57,6 +62,7 @@ const pageGroupDefinitions = { createDate: getDateGroupDefinitions('createDate'), updatedDate: getDateGroupDefinitions('updatedDate'), // add more here later + // todo: some page group definitions maybe dynamic }; export function pagesToPageGroups( @@ -101,6 +107,78 @@ export function pagesToPageGroups( return groups; } +export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => { + const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom); + const collapsed = collapseState[id]; + const onExpandedClicked: MouseEventHandler = useCallback( + e => { + e.stopPropagation(); + e.preventDefault(); + setCollapseState(v => ({ ...v, [id]: !v[id] })); + }, + [id, setCollapseState] + ); + + const selectionState = useAtomValue(selectionStateAtom); + const selectedItems = useMemo(() => { + const selectedPageIds = selectionState.selectedPageIds ?? []; + return items.filter(item => selectedPageIds.includes(item.id)); + }, [items, selectionState.selectedPageIds]); + + const allSelected = useMemo(() => { + return items.every( + item => selectionState.selectedPageIds?.includes(item.id) + ); + }, [items, selectionState.selectedPageIds]); + + const onSelectAll = useCallback(() => { + const nonCurrentGroupIds = + selectionState.selectedPageIds?.filter( + id => !items.map(item => item.id).includes(id) + ) ?? []; + + const newSelectedPageIds = allSelected + ? nonCurrentGroupIds + : [...nonCurrentGroupIds, ...items.map(item => item.id)]; + + selectionState.onSelectedPageIdsChange?.(newSelectedPageIds); + }, [items, selectionState, allSelected]); + + const t = useAFFiNEI18N(); + + return label ? ( +
+
+ +
+
{label}
+ {selectionState.selectionActive ? ( +
+ {selectedItems.length}/{items.length} +
+ ) : null} +
+ {selectionState.selectionActive ? ( + + ) : null} +
+ ) : null; +}; + export const PageGroup = ({ id, items, label }: PageGroupProps) => { const [collapsed, setCollapsed] = useState(false); const onExpandedClicked: MouseEventHandler = useCallback(e => { @@ -173,7 +251,7 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => { // todo: optimize how to render page meta list item const requiredPropNames = [ 'blockSuiteWorkspace', - 'clickMode', + 'rowAsLink', 'isPreferredEdgeless', 'pageOperationsRenderer', 'selectedPageIds', @@ -185,13 +263,17 @@ type RequiredProps = Pick & { selectable: boolean; }; -const listPropsAtom = selectAtom(pageListPropsAtom, props => { - return Object.fromEntries( - requiredPropNames.map(name => [name, props[name]]) - ) as RequiredProps; -}); +const listPropsAtom = selectAtom( + pageListPropsAtom, + props => { + return Object.fromEntries( + requiredPropNames.map(name => [name, props[name]]) + ) as RequiredProps; + }, + shallowEqual +); -const PageMetaListItemRenderer = (pageMeta: PageMeta) => { +export const PageMetaListItemRenderer = (pageMeta: PageMeta) => { const props = useAtomValue(listPropsAtom); const { selectionActive } = useAtomValue(selectionStateAtom); return ( @@ -247,10 +329,10 @@ function pageMetaToPageItemProp( ? new Date(pageMeta.updatedDate) : undefined, to: - props.clickMode === 'link' + props.rowAsLink && !props.selectable ? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}` : undefined, - onClick: props.clickMode === 'select' ? toggleSelection : undefined, + onClick: props.selectable ? toggleSelection : undefined, icon: props.isPreferredEdgeless?.(pageMeta.id) ? ( ) : ( diff --git a/packages/frontend/component/src/components/page-list/page-header.tsx b/packages/frontend/component/src/components/page-list/page-header.tsx new file mode 100644 index 0000000000..7d031f6f71 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/page-header.tsx @@ -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 ( + + {props.children} + {sorting ? ( +
+ {sorter.order === 'asc' ? : } +
+ ) : null} +
+ ); +}; + +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 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 = useCallback( + (e, checked) => { + stopPropagation(e); + handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []); + }, + [handlers, pages] + ); + + if (!selectionState.selectable) { + return null; + } + + return ( +
+ {!selectionState.selectionActive ? ( + + ) : ( + 0 && + selectionState.selectedPageIds.length < pages.length + } + onChange={onChange} + /> + )} +
+ ); +}; + +const PageListHeaderTitleCell = () => { + const t = useAFFiNEI18N(); + return ( +
+ + {t['Title']()} +
+ ); +}; + +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: , + 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 ( +
+ {headerCols.map(col => { + return ( + + {col.content} + + ); + })} +
+ ); +}; diff --git a/packages/frontend/component/src/components/page-list/page-list-item.tsx b/packages/frontend/component/src/components/page-list/page-list-item.tsx index 3e69482234..d5f0babc49 100644 --- a/packages/frontend/component/src/components/page-list/page-list-item.tsx +++ b/packages/frontend/component/src/components/page-list/page-list-item.tsx @@ -84,7 +84,11 @@ const PageCreateDateCell = ({ createDate, }: Pick) => { return ( -
+
{formatDate(createDate)}
); @@ -94,7 +98,11 @@ const PageUpdatedDateCell = ({ updatedDate, }: Pick) => { return ( -
+
{updatedDate ? formatDate(updatedDate) : '-'}
); diff --git a/packages/frontend/component/src/components/page-list/page-list.css.ts b/packages/frontend/component/src/components/page-list/page-list.css.ts index e20ca1b569..64a855b9ed 100644 --- a/packages/frontend/component/src/components/page-list/page-list.css.ts +++ b/packages/frontend/component/src/components/page-list/page-list.css.ts @@ -5,8 +5,8 @@ import * as itemStyles from './page-list-item.css'; export const listRootContainer = createContainer('list-root-container'); export const pageListScrollContainer = style({ - overflowY: 'auto', width: '100%', + flex: 1, }); export const root = style({ @@ -23,7 +23,9 @@ export const groupsContainer = style({ rowGap: '16px', }); -export const header = style({ +export const heading = style({}); + +export const tableHeader = style({ display: 'flex', alignItems: 'center', padding: '10px 6px 10px 16px', @@ -37,7 +39,7 @@ export const header = style({ transform: 'translateY(-0.5px)', // fix sticky look through issue }); -globalStyle(`[data-has-scroll-top=true] ${header}`, { +globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, { boxShadow: '0 1px var(--affine-border-color)', }); @@ -73,13 +75,22 @@ export const headerTitleCell = style({ export const headerTitleSelectionIconWrapper = style({ display: 'flex', alignItems: 'center', - justifyContent: 'center', + justifyContent: 'flex-start', fontSize: '16px', + selectors: { + [`${tableHeader}[data-selectable=toggle] &`]: { + width: 32, + }, + [`${tableHeader}[data-selection-active=true] &`]: { + width: 24, + }, + }, }); export const headerCellSortIcon = style({ - width: '14px', - height: '14px', + display: 'inline-flex', + fontSize: 14, + color: 'var(--affine-icon-color)', }); export const colWrapper = style({ @@ -104,7 +115,7 @@ export const favoriteCell = style({ flexShrink: 0, opacity: 0, selectors: { - [`&[data-favorite], &${itemStyles.root}:hover &`]: { + [`&[data-favorite], ${itemStyles.root}:hover &`]: { opacity: 1, }, }, diff --git a/packages/frontend/component/src/components/page-list/page-list.tsx b/packages/frontend/component/src/components/page-list/page-list.tsx index 43492997c6..218d572d86 100644 --- a/packages/frontend/component/src/components/page-list/page-list.tsx +++ b/packages/frontend/component/src/components/page-list/page-list.tsx @@ -1,86 +1,146 @@ -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons'; -import type { PageMeta } from '@blocksuite/store'; import clsx from 'clsx'; -import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { useHydrateAtoms } from 'jotai/utils'; import { type ForwardedRef, forwardRef, - type MouseEventHandler, + memo, type PropsWithChildren, - type ReactNode, useCallback, useEffect, useImperativeHandle, - useMemo, useRef, } from 'react'; -import { Checkbox, type CheckboxProps } from '../../ui/checkbox'; +import { Scrollable } from '../../ui/scrollbar'; import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top'; import { PageGroup } from './page-group'; +import { PageListTableHeader } from './page-header'; import * as styles from './page-list.css'; import { pageGroupsAtom, - pageListHandlersAtom, pageListPropsAtom, - pagesAtom, + PageListProvider, selectionStateAtom, - showOperationsAtom, - sorterAtom, + useAtom, + useAtomValue, + useSetAtom, } from './scoped-atoms'; import type { PageListHandle, PageListProps } from './types'; -import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils'; /** * Given a list of pages, render a list of pages */ export const PageList = forwardRef( - function PageListHandle(props, ref) { + function PageList(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 + + + + + ); } ); -const PageListInner = ({ - handleRef, - ...props -}: PageListProps & { handleRef: ForwardedRef }) => { - // push pageListProps to the atom so that downstream components can consume it - useHydrateAtoms([[pageListPropsAtom, props]], { - // note: by turning on dangerouslyForceHydrate, downstream component need to use selectAtom to consume the atom - // note2: not using it for now because it will cause some other issues - // dangerouslyForceHydrate: true, - }); - - const setPageListPropsAtom = useSetAtom(pageListPropsAtom); - const setPageListSelectionState = useSetAtom(selectionStateAtom); - +// when pressing ESC or double clicking outside of the page list, close the selection mode +// todo: use jotai-effect instead but it seems it does not work with jotai-scope? +const usePageSelectionStateEffect = () => { + const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); useEffect(() => { - setPageListPropsAtom(props); - }, [props, setPageListPropsAtom]); - - useImperativeHandle( - handleRef, - () => { - return { - toggleSelectable: () => { - setPageListSelectionState(false); - }, + if ( + selectionState.selectionActive && + selectionState.selectable === 'toggle' + ) { + const startTime = Date.now(); + const dblClickHandler = (e: MouseEvent) => { + if (Date.now() - startTime < 200) { + return; + } + const target = e.target as HTMLElement; + // skip if event target is inside of a button or input + // or within a toolbar (like page list floating toolbar) + if ( + target.tagName === 'BUTTON' || + target.tagName === 'INPUT' || + (e.target as HTMLElement).closest('button, input, [role="toolbar"]') + ) { + return; + } + setSelectionActive(false); }; - }, - [setPageListSelectionState] - ); + const escHandler = (e: KeyboardEvent) => { + if (Date.now() - startTime < 200) { + return; + } + if (e.key === 'Escape') { + setSelectionActive(false); + } + }; + + document.addEventListener('dblclick', dblClickHandler); + document.addEventListener('keydown', escHandler); + + return () => { + document.removeEventListener('dblclick', dblClickHandler); + document.removeEventListener('keydown', escHandler); + }; + } + return; + }, [ + selectionState.selectable, + selectionState.selectionActive, + setSelectionActive, + ]); +}; + +export const PageListInnerWrapper = memo( + ({ + handleRef, + children, + onSelectionActiveChange, + ...props + }: PropsWithChildren< + PageListProps & { handleRef: ForwardedRef } + >) => { + const setPageListPropsAtom = useSetAtom(pageListPropsAtom); + const [selectionState, setPageListSelectionState] = + useAtom(selectionStateAtom); + usePageSelectionStateEffect(); + + useEffect(() => { + setPageListPropsAtom(props); + }, [props, setPageListPropsAtom]); + + useEffect(() => { + onSelectionActiveChange?.(!!selectionState.selectionActive); + }, [onSelectionActiveChange, selectionState.selectionActive]); + + useImperativeHandle( + handleRef, + () => { + return { + toggleSelectable: () => { + setPageListSelectionState(false); + }, + }; + }, + [setPageListSelectionState] + ); + return children; + } +); + +PageListInnerWrapper.displayName = 'PageListInnerWrapper'; + +const PageListInner = (props: PageListProps) => { const groups = useAtomValue(pageGroupsAtom); const hideHeader = props.hideHeader; return (
- {!hideHeader ? : null} + {!hideHeader ? : null}
{groups.map(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 ( - - {props.children} - {sorting ? ( -
- {sorter.order === 'asc' ? : } -
- ) : null} -
- ); -}; - -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 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 = useCallback( - (e, checked) => { - stopPropagation(e); - handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []); - }, - [handlers, pages] - ); - - if (!selectionState.selectable) { - return null; - } - - return ( -
- {!selectionState.selectionActive ? ( - - ) : ( - 0 && - selectionState.selectedPageIds.length < pages.length - } - onChange={onChange} - /> - )} -
- ); -}; - -const PageListHeaderTitleCell = () => { - const t = useAFFiNEI18N(); - return ( -
- - {t['Title']()} -
- ); -}; - -export const PageListHeader = () => { - const t = useAFFiNEI18N(); - const showOperations = useAtomValue(showOperationsAtom); - const headerCols = useMemo(() => { - const cols: (HeaderColDef | boolean)[] = [ - { - key: 'title', - content: , - 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 ( -
- {headerCols.map(col => { - return ( - - {col.content} - - ); - })} -
- ); -}; - interface PageListScrollContainerProps { className?: string; style?: React.CSSProperties; @@ -287,14 +177,14 @@ export const PageListScrollContainer = forwardRef< ); return ( -
- {children} -
+ {children} + + ); }); diff --git a/packages/frontend/component/src/components/page-list/page-tags.tsx b/packages/frontend/component/src/components/page-list/page-tags.tsx index edf9e91505..4b6b4072a2 100644 --- a/packages/frontend/component/src/components/page-list/page-tags.tsx +++ b/packages/frontend/component/src/components/page-list/page-tags.tsx @@ -29,6 +29,10 @@ const tagColorMap = (color: string) => { 'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)', 'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)', 'var(--affine-tag-white)': 'var(--affine-palette-line-grey)', + 'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)', + 'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)', + 'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)', + 'var(--affine-tag-green)': 'var(--affine-palette-line-green)', }; return mapping[color] || color; }; @@ -109,7 +113,6 @@ export const PageTags = ({ // @ts-expect-error it's fine '--hover-max-width': sanitizedWidthOnHover, }} - onClick={stopPropagation} >
{maxItems && tags.length > maxItems ? ( - +
diff --git a/packages/frontend/component/src/components/page-list/scoped-atoms.ts b/packages/frontend/component/src/components/page-list/scoped-atoms.tsx similarity index 78% rename from packages/frontend/component/src/components/page-list/scoped-atoms.ts rename to packages/frontend/component/src/components/page-list/scoped-atoms.tsx index 3ba6a3b517..f252693d5d 100644 --- a/packages/frontend/component/src/components/page-list/scoped-atoms.ts +++ b/packages/frontend/component/src/components/page-list/scoped-atoms.tsx @@ -2,28 +2,40 @@ import { DEFAULT_SORT_KEY } from '@affine/env/constant'; import type { PageMeta } from '@blocksuite/store'; import { atom } from 'jotai'; import { selectAtom } from 'jotai/utils'; +import { createIsolation } from 'jotai-scope'; import { pagesToPageGroups } from './page-group'; -import type { PageListProps, PageMetaRecord } from './types'; +import type { + PageListProps, + PageMetaRecord, + VirtualizedPageListProps, +} from './types'; +import { shallowEqual } from './utils'; // for ease of use in the component tree // note: must use selectAtom to access this atom for efficiency // @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms -export const pageListPropsAtom = atom(); +export const pageListPropsAtom = atom< + PageListProps & Partial +>(); // whether or not the table is in selection mode (showing selection checkbox & selection floating bar) const selectionActiveAtom = atom(false); export const selectionStateAtom = atom( get => { - const baseAtom = selectAtom(pageListPropsAtom, props => { - const { selectable, selectedPageIds, onSelectedPageIdsChange } = props; - return { - selectable, - selectedPageIds, - onSelectedPageIdsChange, - }; - }); + const baseAtom = selectAtom( + pageListPropsAtom, + props => { + const { selectable, selectedPageIds, onSelectedPageIdsChange } = props; + return { + selectable, + selectedPageIds, + onSelectedPageIdsChange, + }; + }, + shallowEqual + ); const baseState = get(baseAtom); const selectionActive = baseState.selectable === 'toggle' @@ -39,18 +51,27 @@ export const selectionStateAtom = atom( } ); +// id -> isCollapsed +// maybe reset on page on unmount? +export const pageGroupCollapseStateAtom = atom>({}); + // get handlers from pageListPropsAtom -export const pageListHandlersAtom = selectAtom(pageListPropsAtom, props => { - const { onSelectedPageIdsChange, onDragStart, onDragEnd } = props; +export const pageListHandlersAtom = selectAtom( + pageListPropsAtom, + props => { + const { onSelectedPageIdsChange } = props; + return { + onSelectedPageIdsChange, + }; + }, + shallowEqual +); - return { - onSelectedPageIdsChange, - onDragStart, - onDragEnd, - }; -}); - -export const pagesAtom = selectAtom(pageListPropsAtom, props => props.pages); +export const pagesAtom = selectAtom( + pageListPropsAtom, + props => props.pages, + shallowEqual +); export const showOperationsAtom = selectAtom( pageListPropsAtom, @@ -177,3 +198,10 @@ export const pageGroupsAtom = atom(get => { } return pagesToPageGroups(sorter.pages, groupBy); }); + +export const { + Provider: PageListProvider, + useAtom, + useAtomValue, + useSetAtom, +} = createIsolation(); diff --git a/packages/frontend/component/src/components/page-list/types.ts b/packages/frontend/component/src/components/page-list/types.ts index 8dbc9e77fb..1443f660d9 100644 --- a/packages/frontend/component/src/components/page-list/types.ts +++ b/packages/frontend/component/src/components/page-list/types.ts @@ -40,23 +40,27 @@ export interface PageListProps { // required data: pages: PageMeta[]; blockSuiteWorkspace: Workspace; - className?: string; hideHeader?: boolean; // whether or not to hide the header. default is false (showing header) groupBy?: PagesGroupByType | false; - isPreferredEdgeless: (pageId: string) => boolean; - clickMode?: 'select' | 'link'; // select => click to select; link => click to navigate + isPreferredEdgeless: (pageId: string) => boolean; // determines the icon used for each row + rowAsLink?: boolean; selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item selectedPageIds?: string[]; // selected page ids onSelectedPageIdsChange?: (selected: string[]) => void; + onSelectionActiveChange?: (active: boolean) => void; draggable?: boolean; // whether or not to allow dragging this page item - onDragStart?: (pageId: string) => void; - onDragEnd?: (pageId: string) => void; // we also need the following to make sure the page list functions properly // maybe we could also give a function to render PageListItem? pageOperationsRenderer?: (page: PageMeta) => ReactNode; } +export interface VirtualizedPageListProps extends PageListProps { + heading?: ReactNode; // the user provided heading part (non sticky, above the original header) + atTopThreshold?: number; // the threshold to determine whether or not the user has scrolled to the top. default is 0 + atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not +} + export interface PageListHandle { toggleSelectable: () => void; } diff --git a/packages/frontend/component/src/components/page-list/use-page-selection-events.ts b/packages/frontend/component/src/components/page-list/use-page-selection-events.ts new file mode 100644 index 0000000000..d6b5c521a4 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/use-page-selection-events.ts @@ -0,0 +1,3 @@ +// add dblclick & esc to document when page selection is active +// +export function usePageSelectionEvents() {} diff --git a/packages/frontend/component/src/components/page-list/use-sorter.ts b/packages/frontend/component/src/components/page-list/use-sorter.ts deleted file mode 100644 index 3e5e64c991..0000000000 --- a/packages/frontend/component/src/components/page-list/use-sorter.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { useState } from 'react'; - -type SorterConfig< - T extends Record = 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 = >({ - data, - sortingFn = defaultSortingFn, - ...defaultSorter -}: SorterConfig & { order: 'asc' | 'desc' }) => { - const [sorter, setSorter] = useState, '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>) => - setSorter({ ...sorter, ...newVal }), - shiftOrder, - resetSorter: () => setSorter(defaultSorter), - }; -}; diff --git a/packages/frontend/component/src/components/page-list/utils.tsx b/packages/frontend/component/src/components/page-list/utils.tsx index 0620139f4e..b98e2fea98 100644 --- a/packages/frontend/component/src/components/page-list/utils.tsx +++ b/packages/frontend/component/src/components/page-list/utils.tsx @@ -124,10 +124,10 @@ export const ColWrapper = forwardRef( export const withinDaysAgo = (date: Date, days: number): boolean => { const startDate = new Date(); - const day = startDate.getDay(); + const day = startDate.getDate(); const month = startDate.getMonth(); const year = startDate.getFullYear(); - return new Date(year, month, day - days) <= date; + return new Date(year, month, day - days + 1) <= date; }; export const betweenDaysAgo = ( @@ -145,3 +145,38 @@ export function stopPropagation(event: BaseSyntheticEvent) { export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) { event.stopPropagation(); } + +// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js +export function shallowEqual(objA: any, objB: any) { + if (Object.is(objA, objB)) { + return true; + } + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + // Test for A's keys different from B. + for (let i = 0; i < keysA.length; i++) { + if ( + !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || + !Object.is(objA[keysA[i]], objB[keysA[i]]) + ) { + return false; + } + } + + return true; +} diff --git a/packages/frontend/component/src/components/page-list/view/edit-collection/pages-mode.tsx b/packages/frontend/component/src/components/page-list/view/edit-collection/pages-mode.tsx index 5ae9a96687..358f5b460b 100644 --- a/packages/frontend/component/src/components/page-list/view/edit-collection/pages-mode.tsx +++ b/packages/frontend/component/src/components/page-list/view/edit-collection/pages-mode.tsx @@ -1,8 +1,7 @@ import { type AllPageListConfig, FilterList, - PageList, - PageListScrollContainer, + VirtualizedPageList, } from '@affine/component/page-list'; import type { Collection } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -104,25 +103,22 @@ export const PagesMode = ({
) : null} {searchedList.length ? ( - - { - updateCollection({ - ...collection, - allowList: ids, - }); - }} - pageOperationsRenderer={pageOperationsRenderer} - selectedPageIds={collection.allowList} - isPreferredEdgeless={allPageListConfig.isEdgeless} - > - + { + updateCollection({ + ...collection, + allowList: ids, + }); + }} + pageOperationsRenderer={pageOperationsRenderer} + selectedPageIds={collection.allowList} + isPreferredEdgeless={allPageListConfig.isEdgeless} + > ) : ( )} diff --git a/packages/frontend/component/src/components/page-list/view/edit-collection/rules-mode.tsx b/packages/frontend/component/src/components/page-list/view/edit-collection/rules-mode.tsx index cdda6769b4..fc9bc1cfe2 100644 --- a/packages/frontend/component/src/components/page-list/view/edit-collection/rules-mode.tsx +++ b/packages/frontend/component/src/components/page-list/view/edit-collection/rules-mode.tsx @@ -248,7 +248,6 @@ export const RulesMode = ({ {rulesPages.length > 0 ? ( ) : null} {searchedList.length ? ( - - - + ) : ( )} diff --git a/packages/frontend/component/src/components/page-list/virtualized-page-list.tsx b/packages/frontend/component/src/components/page-list/virtualized-page-list.tsx new file mode 100644 index 0000000000..40fc9f8637 --- /dev/null +++ b/packages/frontend/component/src/components/page-list/virtualized-page-list.tsx @@ -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 + + + + + + ); +}); + +const headingAtom = selectAtom(pageListPropsAtom, props => props.heading); + +const PageListHeading = () => { + const heading = useAtomValue(headingAtom); + return
{heading}
; +}; + +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 ; + case 'page-group-header': + return ; + case 'page-item': + return ; + case 'page-item-spacer': + return
; + } +}; + +const Scroller = forwardRef< + HTMLDivElement, + PropsWithChildren> +>(({ children, ...props }, ref) => { + return ( + + + {children} + + + + ); +}); + +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 ( + + 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} + /> + ); +}; diff --git a/packages/frontend/component/src/ui/checkbox/checkbox.tsx b/packages/frontend/component/src/ui/checkbox/checkbox.tsx index 73af747261..285767e3a7 100644 --- a/packages/frontend/component/src/ui/checkbox/checkbox.tsx +++ b/packages/frontend/component/src/ui/checkbox/checkbox.tsx @@ -57,6 +57,7 @@ export const Checkbox = ({ return (
{icon} diff --git a/packages/frontend/component/src/ui/scrollbar/index.css.ts b/packages/frontend/component/src/ui/scrollbar/index.css.ts index 1cb606a860..a8bf23b435 100644 --- a/packages/frontend/component/src/ui/scrollbar/index.css.ts +++ b/packages/frontend/component/src/ui/scrollbar/index.css.ts @@ -30,8 +30,7 @@ export const scrollableViewport = style({ }); globalStyle(`${scrollableViewport} > div`, { - maxWidth: '100%', - display: 'block !important', + display: 'contents !important', }); export const scrollableContainer = style({ @@ -44,7 +43,6 @@ export const scrollbar = style({ flexDirection: 'column', userSelect: 'none', touchAction: 'none', - marginRight: '4px', width: 'var(--scrollbar-width)', height: '100%', opacity: 1, diff --git a/packages/frontend/component/src/ui/scrollbar/index.ts b/packages/frontend/component/src/ui/scrollbar/index.ts index 17863d47fe..e493a30b9a 100644 --- a/packages/frontend/component/src/ui/scrollbar/index.ts +++ b/packages/frontend/component/src/ui/scrollbar/index.ts @@ -1 +1,2 @@ +export * from './scrollable'; export * from './scrollbar'; diff --git a/packages/frontend/component/src/ui/scrollbar/scrollable.tsx b/packages/frontend/component/src/ui/scrollbar/scrollable.tsx new file mode 100644 index 0000000000..7b2da6585f --- /dev/null +++ b/packages/frontend/component/src/ui/scrollbar/scrollable.tsx @@ -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 +>(({ children, className, ...props }, ref) => { + return ( + + {children} + + ); +}); + +ScrollableRoot.displayName = 'ScrollableRoot'; + +export const ScrollableViewport = forwardRef< + HTMLDivElement, + ScrollArea.ScrollAreaViewportProps & RefAttributes +>(({ children, className, ...props }, ref) => { + return ( + + {children} + + ); +}); + +ScrollableViewport.displayName = 'ScrollableViewport'; + +export const ScrollableScrollbar = forwardRef< + HTMLDivElement, + ScrollArea.ScrollAreaScrollbarProps & RefAttributes +>(({ children, className, ...props }, ref) => { + return ( + + + {children} + + ); +}); + +ScrollableScrollbar.displayName = 'ScrollableScrollbar'; + +export const Scrollable = { + Root: ScrollableRoot, + Viewport: ScrollableViewport, + Scrollbar: ScrollableScrollbar, +}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts index 0ace06dbcc..90f5bc6e39 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts @@ -24,6 +24,6 @@ export const workspaceType = style({ }); export const scrollbar = style({ - transform: 'translateX(10px)', + transform: 'translateX(8px)', width: '4px', }); diff --git a/packages/frontend/core/src/pages/workspace/all-page.css.ts b/packages/frontend/core/src/pages/workspace/all-page.css.ts index ab3fb948bc..31761789af 100644 --- a/packages/frontend/core/src/pages/workspace/all-page.css.ts +++ b/packages/frontend/core/src/pages/workspace/all-page.css.ts @@ -15,6 +15,8 @@ export const scrollContainer = style({ }); export const allPagesHeader = style({ + height: 100, + alignItems: 'center', padding: '48px 16px 20px 24px', overflow: 'hidden', display: 'flex', @@ -23,7 +25,7 @@ export const allPagesHeader = style({ }); export const allPagesHeaderTitle = style({ - fontSize: 'var(--affine-font-h-3)', + fontSize: 'var(--affine-font-h-5)', fontWeight: 500, color: 'var(--affine-text-secondary-color)', display: 'flex', diff --git a/packages/frontend/core/src/pages/workspace/all-page.tsx b/packages/frontend/core/src/pages/workspace/all-page.tsx index b18e4b31f0..8daf9e6093 100644 --- a/packages/frontend/core/src/pages/workspace/all-page.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page.tsx @@ -4,10 +4,9 @@ import { FloatingToolbar, NewPageButton as PureNewPageButton, OperationCell, - PageList, type PageListHandle, - PageListScrollContainer, useCollectionManager, + VirtualizedPageList, } from '@affine/component/page-list'; import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace'; import { Trans } from '@affine/i18n'; @@ -27,7 +26,6 @@ import clsx from 'clsx'; import { type PropsWithChildren, useCallback, - useEffect, useMemo, useRef, useState, @@ -145,19 +143,12 @@ const usePageOperationsRenderer = () => { const PageListFloatingToolbar = ({ selectedIds, onClose, + open, }: { + open: boolean; selectedIds: string[]; onClose: () => void; }) => { - const open = selectedIds.length > 0; - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open) { - onClose(); - } - }, - [onClose] - ); const [currentWorkspace] = useCurrentWorkspace(); const { setTrashModal } = useTrashModalHelper( currentWorkspace.blockSuiteWorkspace @@ -177,11 +168,7 @@ const PageListFloatingToolbar = ({ }, [pageMetas, selectedIds, setTrashModal]); return ( - + {{ count: selectedIds.length } as any}
- pages selected + selected } /> @@ -245,9 +232,10 @@ export const AllPage = () => { ); const [selectedPageIds, setSelectedPageIds] = useState([]); const pageListRef = useRef(null); - const containerRef = useRef(null); - const deselectAllAndToggleSelect = useCallback(() => { - setSelectedPageIds([]); + + const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); + + const hideFloatingToolbar = useCallback(() => { pageListRef.current?.toggleSelectable(); }, []); @@ -257,24 +245,7 @@ export const AllPage = () => { return selectedPageIds.filter(id => ids.includes(id)); }, [filteredPageMetas, selectedPageIds]); - const [showHeaderCreateNewPage, setShowHeaderCreateNewPage] = useState(false); - // when PageListScrollContainer scrolls above 40px, show the create new page button on header - useEffect(() => { - const container = containerRef.current; - if (container) { - const handleScroll = () => { - setTimeout(() => { - const scrollTop = container.scrollTop ?? 0; - setShowHeaderCreateNewPage(scrollTop > 40); - }); - }; - container.addEventListener('scroll', handleScroll); - return () => { - container.removeEventListener('scroll', handleScroll); - }; - } - return; - }, []); + const [hideHeaderCreateNewPage, setHideHeaderCreateNewPage] = useState(true); return (
@@ -289,7 +260,7 @@ export const AllPage = () => { size="small" className={clsx( styles.headerCreateNewButton, - !showHeaderCreateNewPage && styles.headerCreateNewButtonHidden + hideHeaderCreateNewPage && styles.headerCreateNewButtonHidden )} > @@ -297,37 +268,36 @@ export const AllPage = () => { } /> ) : null} - - - {filteredPageMetas.length > 0 ? ( - <> - - - - ) : ( - 0 ? ( + <> + } + selectedPageIds={filteredSelectedPageIds} + onSelectedPageIdsChange={setSelectedPageIds} + pages={filteredPageMetas} + rowAsLink + isPreferredEdgeless={isPreferredEdgeless} blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} + pageOperationsRenderer={pageOperationsRenderer} /> - )} - + 0} + selectedIds={filteredSelectedPageIds} + onClose={hideFloatingToolbar} + /> + + ) : ( + + )}
); }; diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index 1399f7f457..7e66cc7cd9 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -1,8 +1,7 @@ import { toast } from '@affine/component'; import { - PageList, - PageListScrollContainer, TrashOperationCell, + VirtualizedPageList, } from '@affine/component/page-list'; import { WorkspaceSubPath } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -61,30 +60,28 @@ export const TrashPage = () => { ); return ( <> -
- - {filteredPageMetas.length > 0 ? ( - - ) : ( - - )} - + + {filteredPageMetas.length > 0 ? ( + + ) : ( + + )}
); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index a8a5328ee1..078a40405e 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -394,8 +394,8 @@ "com.affine.all-pages.header": "All Pages", "com.affine.collections.header": "Collections", "com.affine.page.group-header.select-all": "Select All", - "com.affine.page.toolbar.selected_one": "<0>{{count}} page selected", - "com.affine.page.toolbar.selected_others": "<0>{{count}} page(s) selected", + "com.affine.page.group-header.clear": "Clear Selection", + "com.affine.page.toolbar.selected": "<0>{{count}} selected", "com.affine.collection.allCollections": "All Collections", "com.affine.collection.emptyCollection": "Empty Collection", "com.affine.collection.emptyCollectionDescription": "Collection is a smart folder where you can manually add pages or automatically add pages through rules.", diff --git a/tests/affine-local/e2e/all-page.spec.ts b/tests/affine-local/e2e/all-page.spec.ts index 570c59d7c6..12f2bf77cb 100644 --- a/tests/affine-local/e2e/all-page.spec.ts +++ b/tests/affine-local/e2e/all-page.spec.ts @@ -4,11 +4,11 @@ import { checkDatePicker, checkDatePickerMonth, checkFilterName, - checkPagesCount, clickDatePicker, createFirstFilter, createPageWithTag, fillDatePicker, + getPagesCount, selectDateFromDatePicker, selectMonthFromMonthPicker, selectTag, @@ -89,8 +89,7 @@ test('allow creation of filters by created time', async ({ page }) => { await clickNewPageButton(page); await clickSideBarAllPageButton(page); await waitForAllPagesLoad(page); - const pages = await page.locator('[data-testid="page-list-item"]').all(); - const pageCount = pages.length; + const pageCount = await getPagesCount(page); expect(pageCount).not.toBe(0); await createFirstFilter(page, 'Created'); await checkFilterName(page, 'after'); @@ -98,11 +97,11 @@ test('allow creation of filters by created time', async ({ page }) => { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); await checkDatePicker(page, yesterday); - await checkPagesCount(page, 1); + expect(await getPagesCount(page)).toBe(1); // change date const today = new Date(); await fillDatePicker(page, today); - await checkPagesCount(page, 0); + expect(await getPagesCount(page)).toBe(0); // change filter await page.getByTestId('filter-name').click(); await page.getByTestId('filler-tag-before').click(); @@ -110,7 +109,7 @@ test('allow creation of filters by created time', async ({ page }) => { tomorrow.setDate(tomorrow.getDate() + 1); await fillDatePicker(page, tomorrow); await checkDatePicker(page, tomorrow); - await checkPagesCount(page, pageCount); + expect(await getPagesCount(page)).toBe(pageCount); }); test('creation of filters by created time, then click date picker to modify the date', async ({ @@ -121,8 +120,7 @@ test('creation of filters by created time, then click date picker to modify the await clickNewPageButton(page); await clickSideBarAllPageButton(page); await waitForAllPagesLoad(page); - const pages = await page.locator('[data-testid="page-list-item"]').all(); - const pageCount = pages.length; + const pageCount = await getPagesCount(page); expect(pageCount).not.toBe(0); await createFirstFilter(page, 'Created'); await checkFilterName(page, 'after'); @@ -130,11 +128,11 @@ test('creation of filters by created time, then click date picker to modify the const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); await checkDatePicker(page, yesterday); - await checkPagesCount(page, 1); + expect(await getPagesCount(page)).toBe(1); // change date const today = new Date(); await selectDateFromDatePicker(page, today); - await checkPagesCount(page, 0); + expect(await getPagesCount(page)).toBe(0); // change filter await page.locator('[data-testid="filter-name"]').click(); await page.getByTestId('filler-tag-before').click(); @@ -142,7 +140,7 @@ test('creation of filters by created time, then click date picker to modify the tomorrow.setDate(tomorrow.getDate() + 1); await selectDateFromDatePicker(page, tomorrow); await checkDatePicker(page, tomorrow); - await checkPagesCount(page, pageCount); + expect(await getPagesCount(page)).toBe(pageCount); }); test('use monthpicker to modify the month of datepicker', async ({ page }) => { @@ -174,8 +172,7 @@ test('allow creation of filters by tags', async ({ page }) => { await waitForEditorLoad(page); await clickSideBarAllPageButton(page); await waitForAllPagesLoad(page); - const pages = await page.locator('[data-testid="page-list-item"]').all(); - const pageCount = pages.length; + const pageCount = await getPagesCount(page); expect(pageCount).not.toBe(0); await createFirstFilter(page, 'Tags'); await checkFilterName(page, 'is not empty'); @@ -188,12 +185,12 @@ test('allow creation of filters by tags', async ({ page }) => { await createPageWithTag(page, { title: 'Page B', tags: ['B'] }); await clickSideBarAllPageButton(page); await checkFilterName(page, 'is not empty'); - await checkPagesCount(page, pagesWithTagsCount + 2); + expect(await getPagesCount(page)).toBe(pagesWithTagsCount + 2); await changeFilter(page, 'contains all'); - await checkPagesCount(page, pageCount + 2); + expect(await getPagesCount(page)).toBe(pageCount + 2); await selectTag(page, 'A'); - await checkPagesCount(page, 1); + expect(await getPagesCount(page)).toBe(1); await changeFilter(page, 'does not contains all'); await selectTag(page, 'B'); - await checkPagesCount(page, pageCount + 1); + expect(await getPagesCount(page)).toBe(pageCount + 1); }); diff --git a/tests/affine-local/e2e/local-first-workspace-list.spec.ts b/tests/affine-local/e2e/local-first-workspace-list.spec.ts index c63f4371e5..72873219b7 100644 --- a/tests/affine-local/e2e/local-first-workspace-list.spec.ts +++ b/tests/affine-local/e2e/local-first-workspace-list.spec.ts @@ -1,4 +1,5 @@ import { test } from '@affine-test/kit/playwright'; +import { getPagesCount } from '@affine-test/kit/utils/filter'; import { openHomePage } from '@affine-test/kit/utils/load-page'; import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic'; import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; @@ -48,13 +49,11 @@ test('create one workspace in the workspace list', async ({ await page.keyboard.press('Escape'); await clickSideBarAllPageButton(page); await page.waitForTimeout(2000); - const pageList = page.locator('[data-testid=page-list-item]'); - const result = await pageList.count(); + const result = await getPagesCount(page); expect(result).toBe(13); await page.reload(); await page.waitForTimeout(4000); - const pageList1 = page.locator('[data-testid=page-list-item]'); - const result1 = await pageList1.count(); + const result1 = await getPagesCount(page); expect(result1).toBe(13); const currentWorkspace = await workspace.current(); diff --git a/tests/kit/utils/filter.ts b/tests/kit/utils/filter.ts index 2762276a76..f8f84e80ec 100644 --- a/tests/kit/utils/filter.ts +++ b/tests/kit/utils/filter.ts @@ -38,10 +38,17 @@ const dateFormat = (date: Date) => { return `${month} ${day}`; }; -export const checkPagesCount = async (page: Page, count: number) => { - expect( - (await page.locator('[data-testid="page-list-item"]').all()).length - ).toBe(count); +// fixme: there could be multiple page lists in the Page +export const getPagesCount = async (page: Page) => { + const locator = page.locator('[data-testid="virtualized-page-list"]'); + const pageListCount = await locator.count(); + + if (pageListCount === 0) { + return 0; + } + + const count = await locator.getAttribute('data-total-count'); + return count ? parseInt(count) : 0; }; export const checkDatePicker = async (page: Page, date: Date) => { diff --git a/tests/storybook/src/stories/page-list.stories.tsx b/tests/storybook/src/stories/page-list.stories.tsx index d84ba47d8a..8e6b11aab1 100644 --- a/tests/storybook/src/stories/page-list.stories.tsx +++ b/tests/storybook/src/stories/page-list.stories.tsx @@ -280,7 +280,6 @@ export const FloatingToolbarStory: StoryFn = props => { style={{ position: 'fixed', bottom: '20px', width: '100%' }} {...props} open={open} - onOpenChange={setOpen} > 10 Selected diff --git a/yarn.lock b/yarn.lock index 57b500554a..5ec48cfd38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -239,6 +239,8 @@ __metadata: fake-indexeddb: "npm:^5.0.0" foxact: "npm:^0.2.20" jotai: "npm:^2.4.3" + jotai-effect: "npm:^0.2.2" + jotai-scope: "npm:^0.4.0" lit: "npm:^2.8.0" lodash: "npm:^4.17.21" lodash-es: "npm:^4.17.21" @@ -253,6 +255,7 @@ __metadata: react-is: "npm:^18.2.0" react-paginate: "npm:^8.2.0" react-router-dom: "npm:^6.16.0" + react-virtuoso: "npm:^4.6.2" rxjs: "npm:^7.8.1" typescript: "npm:^5.2.2" uuid: "npm:^9.0.1" @@ -24429,6 +24432,25 @@ __metadata: languageName: node linkType: hard +"jotai-effect@npm:^0.2.2": + version: 0.2.2 + resolution: "jotai-effect@npm:0.2.2" + peerDependencies: + jotai: ">=2.4.3" + checksum: f74f90836b3afb203d65e4621ce9fc267b838685007cf30db2802e15088f40e9bbb4f232121a7010194562286187fe7bc488935d75a948fd256c5bb58e5c858f + languageName: node + linkType: hard + +"jotai-scope@npm:^0.4.0": + version: 0.4.0 + resolution: "jotai-scope@npm:0.4.0" + peerDependencies: + jotai: ">=2.5.0" + react: ">=17.0.0" + checksum: 398b76507570a674af3e5cbb6f45ede8e8e02014c5d416825952d21f274da6191bbb41d18d0b9bac4dd375e9b5b8c848886a13f930a9810e3de823bfe7a22137 + languageName: node + linkType: hard + "jotai@npm:^2.4.3": version: 2.4.3 resolution: "jotai@npm:2.4.3" @@ -30527,6 +30549,16 @@ __metadata: languageName: node linkType: hard +"react-virtuoso@npm:^4.6.2": + version: 4.6.2 + resolution: "react-virtuoso@npm:4.6.2" + peerDependencies: + react: ">=16 || >=17 || >= 18" + react-dom: ">=16 || >=17 || >= 18" + checksum: 770c5bc5a842c40cac780bfe6e8bfcf4a1fc79fe84438459240e33e1c347b75a5581cc0cf21035bb250591a7f09fe3eed35b94d0f47540e1ce09f2fc47337840 + languageName: node + linkType: hard + "react@npm:18.2.0, react@npm:^18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0"