Skip to content

Commit

Permalink
feat: add items to optimize virtual list (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
linxianxi authored Jan 26, 2024
1 parent 104f2a9 commit bbb929d
Show file tree
Hide file tree
Showing 12 changed files with 245 additions and 56 deletions.
6 changes: 3 additions & 3 deletions docs/examples/basic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ export default () => {
const [value, setValue] = useState<string[]>([]);
const [mode, setMode] = useState<'add' | 'remove' | 'reverse'>('add');
const [selectStartRange, setSelectStartRange] = useState<'all' | 'inside' | 'outside'>('all');
const [enable, setEnable] = useState(true);
const [disabled, setDisabled] = useState(false);
const [rule, setRule] = useState<'collision' | 'inclusion'>('collision');

return (
<div>
<Descriptions
column={1}
items={[
{ label: 'enable', children: <Switch checked={enable} onChange={setEnable} /> },
{ label: 'disabled', children: <Switch checked={disabled} onChange={setDisabled} /> },
{
label: 'selectStartRange',
children: (
Expand Down Expand Up @@ -91,7 +91,7 @@ export default () => {
/>

<Selectable
disabled={!enable}
disabled={disabled}
mode={mode}
value={value}
dragContainer={() => document.getElementById('drag-container') as HTMLElement}
Expand Down
2 changes: 0 additions & 2 deletions docs/examples/sort.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ export default () => {
<Selectable
value={value}
disabled={!!activeId}
getContainer={() => document.querySelector('.container') as HTMLElement}
onEnd={(selectingValue, { added, removed }) => {
const result = value.concat(added).filter((i) => !removed.includes(i));
setValue(result);
Expand All @@ -128,7 +127,6 @@ export default () => {
padding: 20,
border: '1px solid #ccc',
}}
className="container"
>
{items.map((i) => (
<Item
Expand Down
3 changes: 2 additions & 1 deletion docs/examples/virtual-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export default () => {
return (
<Selectable
value={value}
getContainer={() => document.querySelector('.container') as HTMLElement}
items={list}
scrollContainer={() => document.querySelector('.container') as HTMLElement}
onEnd={(selectingValue, { added, removed }) => {
const result = value.concat(added).filter((i) => !removed.includes(i));
setValue(result);
Expand Down
1 change: 1 addition & 0 deletions docs/guides/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ nav: Get Started
| value | Current selected option | (string \| number)[] | - |
| disabled | Whether to disable | boolean | false |
| mode | Selection mode | `add` \| `remove` \| `reverse` | `add` |
| items | The collection value of all items, only the virtual list needs to be passed | (string \| number)[] | - |
| selectStartRange | Where to start with box selection | `all` \| `inside` \| `outside` | `all` |
| scrollContainer | Specify the scrolling container. After setting, the container needs to set `position` because the selection box is `absolute` and needs to be positioned relative to the container. | () => HTMLElement |
| dragContainer | Specify the container that can start dragging. If `scrollContainer` is set, please do not set it because the two should be equal in a scrollable container. | () => HTMLElement | scrollContainer |
Expand Down
1 change: 1 addition & 0 deletions docs/guides/api.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ nav: 快速上手
| value | 受控已选择的值 | (string \| number)[] | - |
| disabled | 是否禁用 | boolean | false |
| mode | 模式 | `add` \| `remove` \| `reverse` | `add` |
| items | 全部 item 的集合值,只有虚拟列表时要传 | (string \| number)[] | - |
| selectStartRange | 从哪里可以开始进行框选 | `all` \| `inside` \| `outside` | `all` |
| scrollContainer | 指定滚动的容器,设置后,容器需要设置 `position`,因为选择框是 `absolute` 要相对于容器定位 | () => HTMLElement |
| dragContainer | 指定可以开始拖拽的容器, 如果设置了 `scrollContainer` 请不要设置,因为在可滚动容器中这两个应该相等 | () => HTMLElement | scrollContainer |
Expand Down
90 changes: 73 additions & 17 deletions src/Selectable.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { SelectableContext } from './context';
import { SelectableContext, UnmountItemsInfoType } from './context';
import useContainer from './hooks/useContainer';
import useEvent from './hooks/useEvent';
import useLatest from './hooks/useLatest';
import useMergedState from './hooks/useMergedState';
import useScroll from './hooks/useScroll';
import { getClientXY } from './utils';
import { getClientXY, isInRange } from './utils';

export interface SelectableProps<T> {
defaultValue?: T[];
value?: T[];
/** support virtual */
items?: T[];
disabled?: boolean;
children?: React.ReactNode;
mode?: 'add' | 'remove' | 'reverse';
Expand All @@ -35,6 +37,7 @@ function Selectable<T extends string | number>(
{
defaultValue,
value: propsValue,
items,
disabled,
mode = 'add',
children,
Expand All @@ -59,6 +62,8 @@ function Selectable<T extends string | number>(
const [value, setValue] = useMergedState(defaultValue || [], {
value: propsValue,
});
const unmountItemsInfo = useRef<UnmountItemsInfoType<T>>(new Map());
const scrollInfo = useRef({ scrollTop: 0, scrollLeft: 0 });
const [isCanceled, setIsCanceled] = useState(false);

const scrollContainer = useContainer(propsScrollContainer || getContainer);
Expand All @@ -73,7 +78,9 @@ function Selectable<T extends string | number>(
const left = Math.max(0, Math.min(startCoords.x, moveCoords.x));
const width = isDragging ? Math.abs(startCoords.x - Math.max(0, moveCoords.x)) : 0;
const height = isDragging ? Math.abs(startCoords.y - Math.max(0, moveCoords.y)) : 0;
const boxRect = { top, left, width, height };
const boxRect = useMemo(() => ({ top, left, width, height }), [top, left, width, height]);

const virtual = !!items;

if (process.env.NODE_ENV === 'development' && getContainer) {
console.error(
Expand All @@ -88,14 +95,23 @@ function Selectable<T extends string | number>(
},
}));

const onScroll = () => {
const onScroll = (e: Event) => {
if (isDraggingRef.current && scrollContainer) {
const target = e.target as HTMLElement;
scrollInfo.current = { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft };

const containerRect = scrollContainer.getBoundingClientRect();
const x = moveClient.current.x - containerRect.left + scrollContainer.scrollLeft;
const y = moveClient.current.y - containerRect.top + scrollContainer.scrollTop;
const x = Math.min(
moveClient.current.x - containerRect.left + scrollContainer.scrollLeft,
scrollContainer.scrollWidth,
);
const y = Math.min(
moveClient.current.y - containerRect.top + scrollContainer.scrollTop,
scrollContainer.scrollHeight,
);
setMoveCoords({
x: Math.min(x, scrollContainer.scrollWidth),
y: Math.min(y, scrollContainer.scrollHeight),
x,
y,
});
}
};
Expand All @@ -104,12 +120,35 @@ function Selectable<T extends string | number>(
onStart?.();
});

const handleEnd = useEvent((newValue: T[]) => {
const handleEnd = useEvent(() => {
if (onEnd) {
if (virtual) {
unmountItemsInfo.current.forEach((info, item) => {
if (items.includes(item)) {
const inRange = isInRange(
{
width: info.rect.width,
height: info.rect.height,
top: info.rect.top + info.scrollTop - scrollInfo.current.scrollTop,
left: info.rect.left + info.scrollLeft - scrollInfo.current.scrollLeft,
},
info.rule,
scrollContainer,
boxRect,
);
if (inRange && !info.disabled) {
selectingValue.current.push(item);
} else {
selectingValue.current = selectingValue.current.filter((i) => i !== item);
}
}
});
}

const added: T[] = [];
const removed: T[] = [];

newValue.forEach((i) => {
selectingValue.current.forEach((i) => {
if (value?.includes(i)) {
if (mode === 'remove' || mode === 'reverse') {
removed.push(i);
Expand All @@ -120,7 +159,8 @@ function Selectable<T extends string | number>(
}
}
});
onEnd(newValue, { added, removed });

onEnd(selectingValue.current, { added, removed });
}
});

Expand Down Expand Up @@ -148,11 +188,14 @@ function Selectable<T extends string | number>(
const { clientX, clientY } = getClientXY(e);
moveClient.current = { x: clientX, y: clientY };
const { left, top } = scrollContainer.getBoundingClientRect();
const x = clientX - left + scrollContainer.scrollLeft;
const y = clientY - top + scrollContainer.scrollTop;
const x = Math.min(
clientX - left + scrollContainer.scrollLeft,
scrollContainer.scrollWidth,
);
const y = Math.min(clientY - top + scrollContainer.scrollTop, scrollContainer.scrollHeight);
setMoveCoords({
x: Math.min(x, scrollContainer.scrollWidth),
y: Math.min(y, scrollContainer.scrollHeight),
x,
y,
});
smoothScroll(e, scrollContainer);

Expand Down Expand Up @@ -187,7 +230,7 @@ function Selectable<T extends string | number>(
if (isDraggingRef.current) {
cancelScroll();
setValue(selectingValue.current);
handleEnd(selectingValue.current);
handleEnd();
}
reset();
};
Expand Down Expand Up @@ -238,8 +281,21 @@ function Selectable<T extends string | number>(
scrollContainer,
startTarget,
startInside,
unmountItemsInfo,
scrollInfo,
virtual,
}),
[value, isDragging, top, left, width, height, mode, scrollContainer, startTarget],
[
value,
isDragging,
boxRect,
mode,
scrollContainer,
startTarget,
unmountItemsInfo,
scrollInfo,
virtual,
],
);

return (
Expand Down
29 changes: 25 additions & 4 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import React, { useContext } from 'react';

interface ISelectableContext {
selectingValue: React.MutableRefObject<(string | number)[]>;
type ValueType = string | number;

export type UnmountItemsInfoType<T> = Map<
T,
{
rect: DOMRect;
scrollTop: number;
scrollLeft: number;
rule: 'collision' | 'inclusion';
disabled?: boolean;
}
>;

interface ISelectableContext<T> {
selectingValue: React.MutableRefObject<T[]>;
boxRect: { top: number; left: number; width: number; height: number };
isDragging: boolean;
value: (string | number)[] | undefined;
value: T[] | undefined;
mode: 'add' | 'remove' | 'reverse';
scrollContainer: HTMLElement | null;
startTarget: HTMLElement | null;
startInside: React.MutableRefObject<boolean>;
unmountItemsInfo: React.MutableRefObject<UnmountItemsInfoType<T>>;
scrollInfo: React.MutableRefObject<{
scrollTop: number;
scrollLeft: number;
}>;
virtual: boolean;
}

export const SelectableContext = React.createContext<ISelectableContext>({} as ISelectableContext);
export const SelectableContext = React.createContext<ISelectableContext<ValueType>>(
{} as ISelectableContext<ValueType>,
);

export const useSelectableContext = () => {
const context = useContext(SelectableContext);
Expand Down
49 changes: 44 additions & 5 deletions src/hooks/useSelectable.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSelectableContext } from '../context';
import { isInRange } from '../utils';
import useUpdateEffect from './useUpdateEffect';

export default function useSelectable({
value,
Expand All @@ -20,10 +21,19 @@ export default function useSelectable({
startInside,
startTarget,
selectingValue,
unmountItemsInfo,
scrollInfo,
virtual,
} = useSelectableContext();
const node = useRef<HTMLElement | null>(null);
const rect = useRef<DOMRect>();

const inRange = isInRange(node.current, rule, scrollContainer, boxRect);
const [inRange, setInRange] = useState(false);

useEffect(() => {
rect.current = node.current?.getBoundingClientRect();
setInRange(isInRange(rect.current, rule, scrollContainer, boxRect));
}, [rect.current, rule, scrollContainer, boxRect]);

const isSelected = contextValue.includes(value);

Expand All @@ -33,6 +43,10 @@ export default function useSelectable({

const isAdding = isSelecting && !isSelected && (mode === 'add' || mode === 'reverse');

const setNodeRef = useCallback((ref: HTMLElement | null) => {
node.current = ref;
}, []);

useEffect(() => {
if (startTarget && !startInside.current) {
const contain = node.current?.contains(startTarget);
Expand All @@ -52,9 +66,34 @@ export default function useSelectable({
}
}, [isSelecting]);

const setNodeRef = useCallback((ref: HTMLElement | null) => {
node.current = ref;
}, []);
// collect item unmount information when virtual
useEffect(() => {
if (virtual) {
unmountItemsInfo.current.delete(value);

return () => {
if (rect.current) {
unmountItemsInfo.current.set(value, {
rule,
rect: rect.current,
disabled,
scrollLeft: scrollInfo.current.scrollLeft,
scrollTop: scrollInfo.current.scrollTop,
});
}
};
}
}, [virtual]);

// update disabled when virtual and disabled changed
useUpdateEffect(() => {
if (virtual) {
const info = unmountItemsInfo.current.get(value);
if (info) {
unmountItemsInfo.current.set(value, { ...info, disabled });
}
}
}, [virtual, disabled]);

return {
setNodeRef,
Expand Down
25 changes: 25 additions & 0 deletions src/hooks/useUpdateEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react';

function useFirstMountState(): boolean {
const isFirst = useRef(true);

if (isFirst.current) {
isFirst.current = false;

return true;
}

return isFirst.current;
}

const useUpdateEffect: typeof useEffect = (effect, deps) => {
const isFirstMount = useFirstMountState();

useEffect(() => {
if (!isFirstMount) {
return effect();
}
}, deps);
};

export default useUpdateEffect;
Loading

0 comments on commit bbb929d

Please sign in to comment.