Skip to content

Commit

Permalink
feat: support custom rule (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
linxianxi authored Jan 26, 2024
1 parent 41542ac commit dc4cd07
Show file tree
Hide file tree
Showing 10 changed files with 190 additions and 28 deletions.
1 change: 0 additions & 1 deletion docs/examples/cancel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export default () => {
ref={selectableRef}
dragContainer={() => document.getElementById('drag-container') as HTMLElement}
onEnd={(selectingValue, { added, removed }) => {
console.log('onEnd');
const result = value.concat(added).filter((i) => !removed.includes(i));
setValue(result);
}}
Expand Down
116 changes: 116 additions & 0 deletions docs/examples/circle-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useRef, useState } from 'react';
import Selectable, { useSelectable } from 'react-selectable-box';

const list: string[] = [];
for (let i = 0; i < 200; i++) {
list.push(String(i));
}

const checkCircleCollision = (
circle: { x: number; y: number; radius: number },
rect: { x: number; y: number; width: number; height: number },
) => {
// The point closest to the center of the rectangle
// 矩形距离圆心最近的点
let targetPoint = { x: 0, y: 0 };
if (circle.x > rect.x + rect.width) {
// If the circle is to the right of the rectangle
// 如果圆形在矩形的右边
targetPoint.x = rect.x + rect.width;
} else if (circle.x < rect.x) {
// If the circle is to the left of the rectangle
// 如果圆形在矩形的左边
targetPoint.x = rect.x;
} else {
// The x in the center of the circle is in the middle of the rectangle
// 圆形中心的x在矩形中间
targetPoint.x = circle.x;
}
if (circle.y > rect.y + rect.height) {
// If the circle is below the rectangle
// 如果圆形在矩形的下边
targetPoint.y = rect.y + rect.height;
} else if (circle.y < rect.y) {
// If the circle is on top of the rectangle
// 如果圆形在矩形的上边
targetPoint.y = rect.y;
} else {
// The y of the center of the circle is in the middle of the rectangle
// 圆形中心的y在矩形中间
targetPoint.y = circle.y;
}

return (
Math.sqrt(Math.pow(targetPoint.x - circle.x, 2) + Math.pow(targetPoint.y - circle.y, 2)) <
circle.radius
);
};

const itemSize = 50;

const Item = ({ value }: { value: string }) => {
const itemRef = useRef<HTMLDivElement | null>(null);
const { setNodeRef, isSelected, isAdding, isRemoving } = useSelectable({
value,
rule: (_, boxPosition) => {
if (!itemRef.current) {
return false;
}
const itemRect = itemRef.current.getBoundingClientRect();
const radius = itemSize / 2;
return checkCircleCollision(
{ x: itemRect.x + radius, y: itemRect.y + radius, radius },
{
x: boxPosition.left,
y: boxPosition.top,
width: boxPosition.width,
height: boxPosition.height,
},
);
},
});

return (
<div
ref={(ref) => {
setNodeRef(ref);
itemRef.current = ref;
}}
style={{
width: itemSize,
height: itemSize,
borderRadius: '50%',
border: isAdding ? '1px solid #1677ff' : undefined,
background: isRemoving ? 'red' : isSelected ? '#1677ff' : '#ccc',
}}
/>
);
};

export default () => {
const [value, setValue] = useState<string[]>([]);

return (
<Selectable
value={value}
onEnd={(selectingValue, { added, removed }) => {
const result = value.concat(added).filter((i) => !removed.includes(i));
setValue(result);
}}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 20,
padding: 20,
border: '1px solid #ccc',
}}
>
{list.map((i) => (
<Item key={i} value={i} />
))}
</div>
</Selectable>
);
};
10 changes: 5 additions & 5 deletions docs/guides/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ const { setNodeRef, isSelected, isAdding, isRemoving, isSelecting, isDragging }
});
```

| Property | Description | Type | Default |
| -------- | ------------------------------------------- | -------------------------- | ----------- |
| value | The value of the current selectable element | string \| number | - |
| disabled | Whether to disable | boolean | false |
| rule | Selection rule | `collision` \| `inclusion` | `collision` |
| Property | Description | Type | Default |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| value | The value of the current selectable element | string \| number | - |
| disabled | Whether to disable | boolean | false |
| rule | Selection rule, collision, inclusion or customization. When customizing, the `boxPosition` is relative to the position of `scrollContainer` | `collision` \| `inclusion` \| ( boxElement: HTMLDivElement, boxPosition: { left: number; top: number; width: number; height: number }) => boolean | `collision` |

| Property | 说明 | 类型 |
| ----------- | -------------------------------------- | -------------------------------------- |
Expand Down
10 changes: 5 additions & 5 deletions docs/guides/api.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ const { setNodeRef, isSelected, isAdding, isRemoving, isSelecting, isDragging }
});
```

| 参数 | 说明 | 类型 | 默认值 |
| -------- | -------------------- | -------------------------- | ----------- |
| value | 当前可框选元素的值 | string \| number | - |
| disabled | 是否禁用 | boolean | false |
| rule | 选中规则,碰撞或包含 | `collision` \| `inclusion` | `collision` |
| 参数 | 说明 | 类型 | 默认值 |
| -------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| value | 当前可框选元素的值 | string \| number | - |
| disabled | 是否禁用 | boolean | false |
| rule | 选中规则,碰撞、包含或自定义,自定义时的 `boxPosition` 是相对于 `scrollContainer` 的位置 | `collision` \| `inclusion` \| ( boxElement: HTMLDivElement, boxPosition: { left: number; top: number; width: number; height: number }) => boolean | `collision` |

| 参数 | 说明 | 类型 |
| ----------- | ------------------ | -------------------------------------- |
Expand Down
11 changes: 11 additions & 0 deletions docs/guides/circle-item.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
title: customize rul(circle item)
group:
title: examples
order: 1
order: 10
---

### Use rule to customize collision rules

<code src="../examples/circle-item.tsx"></code>
11 changes: 11 additions & 0 deletions docs/guides/circle-item.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
title: 自定义规则(圆形 Item)
group:
title: 示例
order: 1
order: 10
---

### 使用 rule 自定义碰撞规则

<code src="../examples/circle-item.tsx"></code>
15 changes: 10 additions & 5 deletions src/Selectable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function Selectable<T extends string | number>(
const [value, setValue] = useMergedState(defaultValue || [], {
value: propsValue,
});
const boxRef = useRef<HTMLDivElement | null>(null);
const unmountItemsInfo = useRef<UnmountItemsInfoType<T>>(new Map());
const scrollInfo = useRef({ scrollTop: 0, scrollLeft: 0 });
const [isCanceled, setIsCanceled] = useState(false);
Expand All @@ -78,7 +79,7 @@ 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 = useMemo(() => ({ top, left, width, height }), [top, left, width, height]);
const boxPosition = useMemo(() => ({ top, left, width, height }), [top, left, width, height]);

const virtual = !!items;

Expand Down Expand Up @@ -126,15 +127,16 @@ function Selectable<T extends string | number>(
unmountItemsInfo.current.forEach((info, item) => {
if (items.includes(item)) {
const inRange = isInRange(
info.rule,
{
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,
boxPosition,
boxRef,
);
if (inRange && !info.disabled) {
selectingValue.current.push(item);
Expand Down Expand Up @@ -276,25 +278,27 @@ function Selectable<T extends string | number>(
value,
selectingValue,
isDragging,
boxRect,
boxPosition,
mode,
scrollContainer,
startTarget,
startInside,
unmountItemsInfo,
scrollInfo,
virtual,
boxRef,
}),
[
value,
isDragging,
boxRect,
boxPosition,
mode,
scrollContainer,
startTarget,
unmountItemsInfo,
scrollInfo,
virtual,
boxRef,
],
);

Expand All @@ -305,6 +309,7 @@ function Selectable<T extends string | number>(
scrollContainer &&
createPortal(
<div
ref={boxRef}
className={boxClassName}
style={{
position: 'absolute',
Expand Down
13 changes: 11 additions & 2 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,28 @@ import React, { useContext } from 'react';

type ValueType = string | number;

export type Rule =
| 'collision'
| 'inclusion'
| ((
boxElement: HTMLDivElement,
boxPosition: { left: number; top: number; width: number; height: number },
) => boolean);

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

interface ISelectableContext<T> {
selectingValue: React.MutableRefObject<T[]>;
boxRect: { top: number; left: number; width: number; height: number };
boxPosition: { top: number; left: number; width: number; height: number };
isDragging: boolean;
value: T[] | undefined;
mode: 'add' | 'remove' | 'reverse';
Expand All @@ -28,6 +36,7 @@ interface ISelectableContext<T> {
scrollLeft: number;
}>;
virtual: boolean;
boxRef: React.MutableRefObject<HTMLDivElement | null>;
}

export const SelectableContext = React.createContext<ISelectableContext<ValueType>>(
Expand Down
22 changes: 13 additions & 9 deletions src/hooks/useSelectable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSelectableContext } from '../context';
import { Rule, useSelectableContext } from '../context';
import { isInRange } from '../utils';
import useUpdateEffect from './useUpdateEffect';

Expand All @@ -10,12 +10,12 @@ export default function useSelectable({
}: {
value: string | number;
disabled?: boolean;
rule?: 'collision' | 'inclusion';
rule?: Rule;
}) {
const {
mode,
scrollContainer,
boxRect,
boxPosition,
isDragging,
value: contextValue = [],
startInside,
Expand All @@ -24,16 +24,20 @@ export default function useSelectable({
unmountItemsInfo,
scrollInfo,
virtual,
boxRef,
} = useSelectableContext();
const node = useRef<HTMLElement | null>(null);
const rect = useRef<DOMRect>();
const rectRef = useRef<DOMRect>();

const [inRange, setInRange] = useState(false);

useEffect(() => {
rect.current = node.current?.getBoundingClientRect();
setInRange(isInRange(rect.current, rule, scrollContainer, boxRect));
}, [rect.current, rule, scrollContainer, boxRect]);
if (isDragging) {
const nodeRect = node.current?.getBoundingClientRect();
rectRef.current = nodeRect;
setInRange(isInRange(rule, nodeRect, scrollContainer, boxPosition, boxRef));
}
}, [rectRef.current, rule, scrollContainer, boxPosition, isDragging]);

const isSelected = contextValue.includes(value);

Expand Down Expand Up @@ -72,10 +76,10 @@ export default function useSelectable({
unmountItemsInfo.current.delete(value);

return () => {
if (rect.current) {
if (rectRef.current) {
unmountItemsInfo.current.set(value, {
rule,
rect: rect.current,
rect: rectRef.current,
disabled,
scrollLeft: scrollInfo.current.scrollLeft,
scrollTop: scrollInfo.current.scrollTop,
Expand Down
9 changes: 8 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Rule } from './context';

export const getClientXY = (e: MouseEvent | TouchEvent) => {
const obj = 'touches' in e ? e.touches[0] : e;
return {
Expand All @@ -7,11 +9,16 @@ export const getClientXY = (e: MouseEvent | TouchEvent) => {
};

export const isInRange = (
rule: Rule,
rect: { left: number; top: number; width: number; height: number } | undefined,
rule: 'collision' | 'inclusion',
scrollContainer: HTMLElement | null,
boxRect: { top: number; left: number; width: number; height: number },
boxRef: React.MutableRefObject<HTMLDivElement | null>,
) => {
if (typeof rule === 'function' && boxRef.current) {
return rule(boxRef.current, boxRect);
}

if (!rect || !scrollContainer) {
return false;
}
Expand Down

0 comments on commit dc4cd07

Please sign in to comment.