Files
twenty/packages/twenty-front/src/modules/ui/utilities/drag-select/components/DragSelect.tsx
T
nitin 524a1d78d2 Refactor drag selection: Replace external library with custom implementation and add auto-scroll (#12134)
Closes #12076
Closes #11764

Replaced the `@air/react-drag-to-select` library with a custom
implementation to get better control over the selection behavior and add
auto-scroll functionality.

**What changed:**
- Removed external drag selection dependency 
- Built custom drag selection from scratch using pointer events --
@charlesBochet
- Added auto-scroll when dragging near container edges
- Fixed boundary detection so selection stays within intended areas
- Added proper `data-select-disable` support for checkboxes and other
non-selectable elements

The new implementation gives us full control over the selection logic
and eliminates the external dependency while adding the auto-scroll
feature that was **not** requested 😂

**Auto Scroll**



https://github.com/user-attachments/assets/3509966d-5b6e-4f6c-a77a-f9a2bf26049f



related to #12076 


https://github.com/user-attachments/assets/2837f80e-728c-4739-a0e2-b8d7bc83a21a

**Also fixed:**
- Record board column height not extending to the bottom (styling issue
I found while working on this)

before:

<img width="1512" alt="Screenshot 2025-05-19 at 23 58 54"
src="https://github.com/user-attachments/assets/602b310f-7ef6-44f6-99e9-da5ff59b31d3"
/>

after:

<img width="1512" alt="Screenshot 2025-05-19 at 23 56 40"
src="https://github.com/user-attachments/assets/1d0ecb5c-49e0-4f03-be3b-154a6f16a7a4"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2025-05-26 11:58:22 +02:00

251 lines
7.0 KiB
TypeScript

import styled from '@emotion/styled';
import { RefObject, useCallback, useState } from 'react';
import { useDragSelectWithAutoScroll } from '@/ui/utilities/drag-select/hooks/useDragSelectWithAutoScroll';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { useDragSelect } from '../hooks/useDragSelect';
import { SelectionBox } from '../types/SelectionBox';
import { isValidSelectionStart } from '../utils/selectionBoxValidation';
type DragSelectProps = {
selectableItemsContainerRef: RefObject<HTMLElement>;
onDragSelectionChange: (id: string, selected: boolean) => void;
onDragSelectionStart?: (event: MouseEvent | TouchEvent) => void;
onDragSelectionEnd?: (event: MouseEvent | TouchEvent) => void;
scrollWrapperComponentInstanceId?: string;
selectionBoundaryClass?: string;
};
type Position = {
x: number;
y: number;
};
const StyledDragSelection = styled.div<SelectionBox>`
position: absolute;
z-index: 99;
opacity: 0.2;
border: 1px solid ${({ theme }) => theme.color.blue10};
background: ${({ theme }) => theme.color.blue30};
top: ${({ top }) => top}px;
left: ${({ left }) => left}px;
width: ${({ width }) => width}px;
height: ${({ height }) => height}px;
`;
export const DragSelect = ({
selectableItemsContainerRef,
onDragSelectionChange,
onDragSelectionStart,
onDragSelectionEnd,
scrollWrapperComponentInstanceId,
selectionBoundaryClass,
}: DragSelectProps) => {
const { isDragSelectionStartEnabled } = useDragSelect();
const [isDragging, setIsDragging] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const boxesIntersect = useCallback(
(boxA: SelectionBox, boxB: SelectionBox) =>
boxA.left <= boxB.left + boxB.width &&
boxA.left + boxA.width >= boxB.left &&
boxA.top <= boxB.top + boxB.height &&
boxA.top + boxA.height >= boxB.top,
[],
);
const { handleAutoScroll } = useDragSelectWithAutoScroll({
scrollWrapperComponentInstanceId,
});
const [startPoint, setStartPoint] = useState<Position | null>(null);
const [endPoint, setEndPoint] = useState<Position | null>(null);
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
const getPositionRelativeToContainer = useCallback(
(x: number, y: number) => {
const containerRect =
selectableItemsContainerRef.current?.getBoundingClientRect();
if (!containerRect) {
return { x, y };
}
return { x: x - containerRect.left, y: y - containerRect.top };
},
[selectableItemsContainerRef],
);
useTrackPointer({
onMouseDown: ({ x, y, event }) => {
const { x: relativeX, y: relativeY } = getPositionRelativeToContainer(
x,
y,
);
if (shouldStartSelecting(event.target)) {
setIsDragging(true);
setIsSelecting(false);
setStartPoint({
x: relativeX,
y: relativeY,
});
setEndPoint({
x: relativeX,
y: relativeY,
});
setSelectionBox({
top: relativeY,
left: relativeX,
width: 0,
height: 0,
});
}
event.preventDefault();
},
onMouseMove: ({ x, y, event }) => {
if (isDragging) {
const { x: relativeX, y: relativeY } = getPositionRelativeToContainer(
x,
y,
);
if (
!isDefined(startPoint) ||
!isDefined(endPoint) ||
!isDefined(selectionBox)
) {
return;
}
const newEndPoint = { ...endPoint };
newEndPoint.x = relativeX;
newEndPoint.y = relativeY;
if (!isDeeplyEqual(newEndPoint, endPoint)) {
setEndPoint(newEndPoint);
const newSelectionBox = {
top: Math.min(startPoint.y, newEndPoint.y),
left: Math.min(startPoint.x, newEndPoint.x),
width: Math.abs(newEndPoint.x - startPoint.x),
height: Math.abs(newEndPoint.y - startPoint.y),
};
if (isValidSelectionStart(newSelectionBox)) {
if (!isSelecting) {
setIsSelecting(true);
onDragSelectionStart?.(event);
}
setSelectionBox(newSelectionBox);
} else if (isSelecting) {
setSelectionBox(newSelectionBox);
}
}
if (isSelecting && isDefined(selectionBox)) {
const scrollAwareBox = {
...selectionBox,
top: selectionBox.top + window.scrollY,
left: selectionBox.left + window.scrollX,
};
Array.from(
selectableItemsContainerRef.current?.querySelectorAll(
'[data-selectable-id]',
) ?? [],
).forEach((item) => {
const id = item.getAttribute('data-selectable-id');
if (!isDefined(id)) {
return;
}
const itemBox = item.getBoundingClientRect();
const { x: boxX, y: boxY } = getPositionRelativeToContainer(
itemBox.left,
itemBox.top,
);
if (
boxesIntersect(scrollAwareBox, {
width: itemBox.width,
height: itemBox.height,
top: boxY,
left: boxX,
})
) {
onDragSelectionChange(id, true);
} else {
onDragSelectionChange(id, false);
}
});
}
handleAutoScroll(x, y);
}
},
onMouseUp: ({ event }) => {
if (isSelecting) {
onDragSelectionEnd?.(event);
}
setIsDragging(false);
setIsSelecting(false);
},
});
const shouldStartSelecting = useCallback(
(target: EventTarget | null) => {
if (!isDragSelectionStartEnabled()) {
return false;
}
if (!(target instanceof Node)) {
return false;
}
const selectionBoundaryElement = selectionBoundaryClass
? (selectableItemsContainerRef.current?.closest(
`.${selectionBoundaryClass}`,
) ?? selectableItemsContainerRef.current)
: selectableItemsContainerRef.current;
if (!selectionBoundaryElement?.contains(target)) {
return false;
}
if (target instanceof HTMLElement || target instanceof SVGElement) {
let el = target;
while (el.parentElement && !el.dataset.selectDisable) {
el = el.parentElement;
}
if (el.dataset.selectDisable === 'true') {
return false;
}
}
return true;
},
[
isDragSelectionStartEnabled,
selectableItemsContainerRef,
selectionBoundaryClass,
],
);
return (
isDragging &&
isSelecting &&
isDefined(selectionBox) && (
<StyledDragSelection
top={selectionBox.top}
left={selectionBox.left}
width={selectionBox.width}
height={selectionBox.height}
/>
)
);
};