524a1d78d2
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>
251 lines
7.0 KiB
TypeScript
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}
|
|
/>
|
|
)
|
|
);
|
|
};
|