feat(core): optimize the shift multi-selection function of doc list (#6675)

close TOV-701

https://github.com/toeverything/AFFiNE/assets/102217452/8813e603-1cc2-469f-a7c1-b18e49a14871
This commit is contained in:
JimmFly
2024-04-24 07:56:13 +00:00
parent 6525c99631
commit 350fec5397
7 changed files with 153 additions and 12 deletions

View File

@@ -137,6 +137,7 @@ export const dateCell = style({
flexShrink: 0, flexShrink: 0,
flexWrap: 'nowrap', flexWrap: 'nowrap',
padding: '0 8px', padding: '0 8px',
userSelect: 'none',
}); });
export const actionsCellWrapper = style({ export const actionsCellWrapper = style({
display: 'flex', display: 'flex',

View File

@@ -4,10 +4,15 @@ import { TagService } from '@affine/core/modules/tag';
import { useDraggable } from '@dnd-kit/core'; import { useDraggable } from '@dnd-kit/core';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import { useCallback, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link'; import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link';
import { selectionStateAtom, useAtom } from '../scoped-atoms'; import {
anchorIndexAtom,
rangeIdsAtom,
selectionStateAtom,
useAtom,
} from '../scoped-atoms';
import type { DraggableTitleCellData, PageListItemProps } from '../types'; import type { DraggableTitleCellData, PageListItemProps } from '../types';
import { useAllDocDisplayProperties } from '../use-all-doc-display-properties'; import { useAllDocDisplayProperties } from '../use-all-doc-display-properties';
import { ColWrapper, formatDate, stopPropagation } from '../utils'; import { ColWrapper, formatDate, stopPropagation } from '../utils';
@@ -167,6 +172,7 @@ export const PageListItem = (props: PageListItemProps) => {
pageId={props.pageId} pageId={props.pageId}
draggable={props.draggable} draggable={props.draggable}
isDragging={isDragging} isDragging={isDragging}
pageIds={props.pageIds || []}
> >
<ColWrapper flex={9}> <ColWrapper flex={9}>
<ColWrapper <ColWrapper
@@ -227,6 +233,7 @@ export const PageListItem = (props: PageListItemProps) => {
type PageListWrapperProps = PropsWithChildren< type PageListWrapperProps = PropsWithChildren<
Pick<PageListItemProps, 'to' | 'pageId' | 'onClick' | 'draggable'> & { Pick<PageListItemProps, 'to' | 'pageId' | 'onClick' | 'draggable'> & {
isDragging: boolean; isDragging: boolean;
pageIds: string[];
} }
>; >;
@@ -234,26 +241,84 @@ function PageListItemWrapper({
to, to,
isDragging, isDragging,
pageId, pageId,
pageIds,
onClick, onClick,
children, children,
draggable, draggable,
}: PageListWrapperProps) { }: PageListWrapperProps) {
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
const [anchorIndex, setAnchorIndex] = useAtom(anchorIndexAtom);
const [rangeIds, setRangeIds] = useAtom(rangeIdsAtom);
const handleShiftClick = useCallback(
(currentIndex: number) => {
if (anchorIndex === undefined) {
setAnchorIndex(currentIndex);
onClick?.();
return;
}
const lowerIndex = Math.min(anchorIndex, currentIndex);
const upperIndex = Math.max(anchorIndex, currentIndex);
const newRangeIds = pageIds.slice(lowerIndex, upperIndex + 1);
const currentSelected = selectionState.selectedIds || [];
// Set operations
const setRange = new Set(rangeIds);
const newSelected = new Set(
currentSelected.filter(id => !setRange.has(id)).concat(newRangeIds)
);
selectionState.onSelectedIdsChange?.([...newSelected]);
setRangeIds(newRangeIds);
},
[
anchorIndex,
onClick,
pageIds,
selectionState,
setAnchorIndex,
rangeIds,
setRangeIds,
]
);
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (!selectionState.selectable) { if (!selectionState.selectable) {
return false; return false;
} }
stopPropagation(e); stopPropagation(e);
const currentIndex = pageIds.indexOf(pageId);
if (e.shiftKey) { if (e.shiftKey) {
setSelectionActive(true); if (!selectionState.selectionActive) {
onClick?.(); setSelectionActive(true);
setAnchorIndex(currentIndex);
onClick?.();
return true;
}
handleShiftClick(currentIndex);
return true; return true;
} else {
setAnchorIndex(undefined);
setRangeIds([]);
onClick?.();
return false;
} }
onClick?.();
return false;
}, },
[onClick, selectionState.selectable, setSelectionActive] [
handleShiftClick,
onClick,
pageId,
pageIds,
selectionState.selectable,
selectionState.selectionActive,
setAnchorIndex,
setRangeIds,
setSelectionActive,
]
); );
const commonProps = useMemo( const commonProps = useMemo(
@@ -269,6 +334,28 @@ function PageListItemWrapper({
[pageId, draggable, onClick, to, isDragging, handleClick] [pageId, draggable, onClick, to, isDragging, handleClick]
); );
useEffect(() => {
if (selectionState.selectionActive) {
// listen for shift key up
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
setAnchorIndex(undefined);
setRangeIds([]);
}
};
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keyup', handleKeyUp);
};
}
return;
}, [
selectionState.selectionActive,
setAnchorIndex,
setRangeIds,
setSelectionActive,
]);
if (to) { if (to) {
return ( return (
<WorkbenchLink {...commonProps} to={to}> <WorkbenchLink {...commonProps} to={to}>

View File

@@ -120,6 +120,7 @@ export const tagLabel = style({
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
userSelect: 'none',
}); });
export const tagRemove = style({ export const tagRemove = style({

View File

@@ -23,6 +23,7 @@ import { PagePreview } from './page-content-preview';
import * as styles from './page-group.css'; import * as styles from './page-group.css';
import { import {
groupCollapseStateAtom, groupCollapseStateAtom,
groupsAtom,
listPropsAtom, listPropsAtom,
selectionStateAtom, selectionStateAtom,
useAtom, useAtom,
@@ -224,13 +225,21 @@ const listsPropsAtom = selectAtom(
export const PageListItemRenderer = (item: ListItem) => { export const PageListItemRenderer = (item: ListItem) => {
const props = useAtomValue(listsPropsAtom); const props = useAtomValue(listsPropsAtom);
const { selectionActive } = useAtomValue(selectionStateAtom); const { selectionActive } = useAtomValue(selectionStateAtom);
const groups = useAtomValue(groupsAtom);
const pageItems = groups.flatMap(group => group.items).map(item => item.id);
const page = item as DocMeta; const page = item as DocMeta;
return ( return (
<PageListItem <PageListItem
{...pageMetaToListItemProp(page, { {...pageMetaToListItemProp(
...props, page,
selectable: !!selectionActive, {
})} ...props,
selectable: !!selectionActive,
},
pageItems
)}
/> />
); );
}; };
@@ -298,7 +307,8 @@ const UnifiedPageIcon = ({
function pageMetaToListItemProp( function pageMetaToListItemProp(
item: DocMeta, item: DocMeta,
props: RequiredProps<DocMeta> props: RequiredProps<DocMeta>,
pageIds?: string[]
): PageListItemProps { ): PageListItemProps {
const toggleSelection = props.onSelectedIdsChange const toggleSelection = props.onSelectedIdsChange
? () => { ? () => {
@@ -318,6 +328,7 @@ function pageMetaToListItemProp(
: undefined; : undefined;
const itemProps: PageListItemProps = { const itemProps: PageListItemProps = {
pageId: item.id, pageId: item.id,
pageIds,
title: <PageTitle id={item.id} />, title: <PageTitle id={item.id} />,
preview: ( preview: (
<PagePreview docCollection={props.docCollection} pageId={item.id} /> <PagePreview docCollection={props.docCollection} pageId={item.id} />

View File

@@ -22,6 +22,10 @@ export const listPropsAtom = atom<
// 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 anchorIndexAtom = atom<number | undefined>(undefined);
export const rangeIdsAtom = atom<string[]>([]);
export const selectionStateAtom = atom( export const selectionStateAtom = atom(
get => { get => {
const baseAtom = selectAtom( const baseAtom = selectAtom(

View File

@@ -23,6 +23,7 @@ export type TagMeta = {
// using type instead of interface to make it Record compatible // using type instead of interface to make it Record compatible
export type PageListItemProps = { export type PageListItemProps = {
pageId: string; pageId: string;
pageIds?: string[];
icon: JSX.Element; icon: JSX.Element;
title: ReactNode; // using ReactNode to allow for rich content rendering title: ReactNode; // using ReactNode to allow for rich content rendering
preview?: ReactNode; // using ReactNode to allow for rich content rendering preview?: ReactNode; // using ReactNode to allow for rich content rendering

View File

@@ -309,3 +309,39 @@ test('select display properties to hide bodyNotes', async ({ page }) => {
await page.locator('[data-testid="property-bodyNotes"]').click(); await page.locator('[data-testid="property-bodyNotes"]').click();
await expect(cell).toBeVisible(); await expect(cell).toBeVisible();
}); });
test('select three pages with shiftKey and delete', async ({ page }) => {
await openHomePage(page);
await waitForEditorLoad(page);
await clickNewPageButton(page);
await clickNewPageButton(page);
await clickNewPageButton(page);
await clickSideBarAllPageButton(page);
await waitForAllPagesLoad(page);
const pageCount = await getPagesCount(page);
await page.keyboard.down('Shift');
await page.locator('[data-testid="page-list-item"]').nth(0).click();
await page.locator('[data-testid="page-list-item"]').nth(2).click();
await page.keyboard.up('Shift');
// the floating popover should appear
await expect(page.locator('[data-testid="floating-toolbar"]')).toBeVisible();
await expect(page.locator('[data-testid="floating-toolbar"]')).toHaveText(
'3 doc(s) selected'
);
// click delete button
await page.locator('[data-testid="list-toolbar-delete"]').click();
// the confirm dialog should appear
await expect(page.getByText('Delete 3 docs?')).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
// check the page count again
await page.waitForTimeout(300);
expect(await getPagesCount(page)).toBe(pageCount - 3);
});