diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts b/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts index 3a328f869f..1560cbfd24 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts +++ b/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts @@ -137,6 +137,7 @@ export const dateCell = style({ flexShrink: 0, flexWrap: 'nowrap', padding: '0 8px', + userSelect: 'none', }); export const actionsCellWrapper = style({ display: 'flex', diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx index 30bca37237..cc04b80c35 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx @@ -4,10 +4,15 @@ import { TagService } from '@affine/core/modules/tag'; import { useDraggable } from '@dnd-kit/core'; import { useLiveData, useService } from '@toeverything/infra'; 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 { selectionStateAtom, useAtom } from '../scoped-atoms'; +import { + anchorIndexAtom, + rangeIdsAtom, + selectionStateAtom, + useAtom, +} from '../scoped-atoms'; import type { DraggableTitleCellData, PageListItemProps } from '../types'; import { useAllDocDisplayProperties } from '../use-all-doc-display-properties'; import { ColWrapper, formatDate, stopPropagation } from '../utils'; @@ -167,6 +172,7 @@ export const PageListItem = (props: PageListItemProps) => { pageId={props.pageId} draggable={props.draggable} isDragging={isDragging} + pageIds={props.pageIds || []} > { type PageListWrapperProps = PropsWithChildren< Pick & { isDragging: boolean; + pageIds: string[]; } >; @@ -234,26 +241,84 @@ function PageListItemWrapper({ to, isDragging, pageId, + pageIds, onClick, children, draggable, }: PageListWrapperProps) { 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( (e: React.MouseEvent) => { if (!selectionState.selectable) { return false; } stopPropagation(e); + const currentIndex = pageIds.indexOf(pageId); + if (e.shiftKey) { - setSelectionActive(true); - onClick?.(); + if (!selectionState.selectionActive) { + setSelectionActive(true); + setAnchorIndex(currentIndex); + onClick?.(); + return true; + } + handleShiftClick(currentIndex); 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( @@ -269,6 +334,28 @@ function PageListItemWrapper({ [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) { return ( diff --git a/packages/frontend/core/src/components/page-list/docs/page-tags.css.ts b/packages/frontend/core/src/components/page-list/docs/page-tags.css.ts index 86823180b4..f5bea7ae0f 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-tags.css.ts +++ b/packages/frontend/core/src/components/page-list/docs/page-tags.css.ts @@ -120,6 +120,7 @@ export const tagLabel = style({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + userSelect: 'none', }); export const tagRemove = style({ diff --git a/packages/frontend/core/src/components/page-list/page-group.tsx b/packages/frontend/core/src/components/page-list/page-group.tsx index c24128b514..7375f9f817 100644 --- a/packages/frontend/core/src/components/page-list/page-group.tsx +++ b/packages/frontend/core/src/components/page-list/page-group.tsx @@ -23,6 +23,7 @@ import { PagePreview } from './page-content-preview'; import * as styles from './page-group.css'; import { groupCollapseStateAtom, + groupsAtom, listPropsAtom, selectionStateAtom, useAtom, @@ -224,13 +225,21 @@ const listsPropsAtom = selectAtom( export const PageListItemRenderer = (item: ListItem) => { const props = useAtomValue(listsPropsAtom); const { selectionActive } = useAtomValue(selectionStateAtom); + const groups = useAtomValue(groupsAtom); + const pageItems = groups.flatMap(group => group.items).map(item => item.id); + const page = item as DocMeta; return ( ); }; @@ -298,7 +307,8 @@ const UnifiedPageIcon = ({ function pageMetaToListItemProp( item: DocMeta, - props: RequiredProps + props: RequiredProps, + pageIds?: string[] ): PageListItemProps { const toggleSelection = props.onSelectedIdsChange ? () => { @@ -318,6 +328,7 @@ function pageMetaToListItemProp( : undefined; const itemProps: PageListItemProps = { pageId: item.id, + pageIds, title: , preview: ( diff --git a/packages/frontend/core/src/components/page-list/scoped-atoms.tsx b/packages/frontend/core/src/components/page-list/scoped-atoms.tsx index aea5724210..aa5b7b2ada 100644 --- a/packages/frontend/core/src/components/page-list/scoped-atoms.tsx +++ b/packages/frontend/core/src/components/page-list/scoped-atoms.tsx @@ -22,6 +22,10 @@ export const listPropsAtom = atom< // whether or not the table is in selection mode (showing selection checkbox & selection floating bar) const selectionActiveAtom = atom(false); +export const anchorIndexAtom = atom(undefined); + +export const rangeIdsAtom = atom([]); + export const selectionStateAtom = atom( get => { const baseAtom = selectAtom( diff --git a/packages/frontend/core/src/components/page-list/types.ts b/packages/frontend/core/src/components/page-list/types.ts index 211de0ec5c..8f036a5cc8 100644 --- a/packages/frontend/core/src/components/page-list/types.ts +++ b/packages/frontend/core/src/components/page-list/types.ts @@ -23,6 +23,7 @@ export type TagMeta = { // using type instead of interface to make it Record compatible export type PageListItemProps = { pageId: string; + pageIds?: string[]; icon: JSX.Element; title: ReactNode; // using ReactNode to allow for rich content rendering preview?: ReactNode; // using ReactNode to allow for rich content rendering diff --git a/tests/affine-local/e2e/all-page.spec.ts b/tests/affine-local/e2e/all-page.spec.ts index ed01130417..1b81f8dd3c 100644 --- a/tests/affine-local/e2e/all-page.spec.ts +++ b/tests/affine-local/e2e/all-page.spec.ts @@ -309,3 +309,39 @@ test('select display properties to hide bodyNotes', async ({ page }) => { await page.locator('[data-testid="property-bodyNotes"]').click(); 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); +});