mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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:
@@ -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',
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user