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>
This commit is contained in:
nitin
2025-05-26 15:28:22 +05:30
committed by GitHub
parent 621a779526
commit 524a1d78d2
24 changed files with 1244 additions and 100 deletions

View File

@ -31,6 +31,7 @@ import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/Mod
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
@ -45,10 +46,13 @@ const StyledContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
min-height: 100%;
position: relative;
`;
const StyledColumnContainer = styled.div`
display: flex;
& > *:not(:first-of-type) {
border-left: 1px solid ${({ theme }) => theme.border.color.light};
}
@ -57,12 +61,14 @@ const StyledColumnContainer = styled.div`
const StyledContainerContainer = styled.div`
display: flex;
flex-direction: column;
min-height: calc(100% - ${({ theme }) => theme.spacing(2)});
height: min-content;
`;
const StyledBoardContentContainer = styled.div`
display: flex;
flex-direction: column;
height: calc(100% - 48px);
flex: 1;
`;
export const RecordBoard = () => {
@ -233,13 +239,18 @@ export const RecordBoard = () => {
))}
</StyledColumnContainer>
</DragDropContext>
<DragSelect
selectableItemsContainerRef={boardRef}
onDragSelectionEnd={handleDragSelectionEnd}
onDragSelectionChange={setRecordAsSelected}
onDragSelectionStart={handleDragSelectionStart}
scrollWrapperComponentInstanceId={`scroll-wrapper-record-board-${recordBoardId}`}
selectionBoundaryClass={
RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS
}
/>
</StyledContainer>
<DragSelect
dragSelectable={boardRef}
onDragSelectionEnd={handleDragSelectionEnd}
onDragSelectionChange={setRecordAsSelected}
onDragSelectionStart={handleDragSelectionStart}
/>
</StyledBoardContentContainer>
</StyledContainerContainer>
</ScrollWrapper>

View File

@ -14,6 +14,8 @@ const StyledColumn = styled.div`
flex-direction: column;
max-width: 200px;
min-width: 200px;
min-height: 100%;
flex: 1;
padding: ${({ theme }) => theme.spacing(2)};
padding-top: 0px;
position: relative;

View File

@ -16,6 +16,7 @@ import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hook
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
@ -90,7 +91,9 @@ export const RecordIndexContainerGater = () => {
/>
<RecordIndexPageHeader />
<PageBody>
<StyledIndexContainer>
<StyledIndexContainer
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
>
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
<RecordIndexContainer />
</StyledIndexContainer>

View File

@ -76,6 +76,7 @@ export const RecordTable = () => {
handleDragSelectionEnd={handleDragSelectionEnd}
setRowSelected={setRowSelected}
hasRecordGroups={hasRecordGroups}
recordTableId={recordTableId}
/>
)}
</>

View File

@ -5,8 +5,9 @@ import { RecordTableNoRecordGroupBody } from '@/object-record/record-table/recor
import { RecordTableRecordGroupsBody } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody';
import { RecordTableHeader } from '@/object-record/record-table/record-table-header/components/RecordTableHeader';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import styled from '@emotion/styled';
import { useState } from 'react';
import { useRef, useState } from 'react';
const StyledTableWithPointerEvents = styled(StyledTable)<{
isDragging: boolean;
@ -16,12 +17,20 @@ const StyledTableWithPointerEvents = styled(StyledTable)<{
}
`;
const StyledTableContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`;
export interface RecordTableContentProps {
tableBodyRef: React.RefObject<HTMLTableElement>;
handleDragSelectionStart: () => void;
handleDragSelectionEnd: () => void;
setRowSelected: (rowId: string, selected: boolean) => void;
hasRecordGroups: boolean;
recordTableId: string;
}
export const RecordTableContent = ({
@ -30,8 +39,10 @@ export const RecordTableContent = ({
handleDragSelectionEnd,
setRowSelected,
hasRecordGroups,
recordTableId,
}: RecordTableContentProps) => {
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleDragStart = () => {
setIsDragging(true);
@ -44,7 +55,7 @@ export const RecordTableContent = ({
};
return (
<>
<StyledTableContainer ref={containerRef}>
<StyledTableWithPointerEvents ref={tableBodyRef} isDragging={isDragging}>
<RecordTableHeader />
{hasRecordGroups ? (
@ -56,11 +67,13 @@ export const RecordTableContent = ({
<RecordTableStickyBottomEffect />
</StyledTableWithPointerEvents>
<DragSelect
dragSelectable={tableBodyRef}
selectableItemsContainerRef={containerRef}
onDragSelectionStart={handleDragStart}
onDragSelectionChange={setRowSelected}
onDragSelectionEnd={handleDragEnd}
scrollWrapperComponentInstanceId={`record-table-scroll-${recordTableId}`}
selectionBoundaryClass={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
/>
</>
</StyledTableContainer>
);
};

View File

@ -32,7 +32,7 @@ export const RecordTableCellCheckbox = () => {
return (
<StyledRecordTableTd isSelected={isSelected} hasRightBorder={false}>
<StyledContainer onClick={handleClick}>
<StyledContainer onClick={handleClick} data-select-disable>
<Checkbox hoverable checked={isSelected} />
</StyledContainer>
</StyledRecordTableTd>

View File

@ -16,6 +16,7 @@ import { tableColumnsComponentState } from '@/object-record/record-table/states/
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { PointerEventListener } from '@/ui/utilities/pointer-event/types/PointerEventListener';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
@ -138,16 +139,19 @@ export const RecordTableHeaderCell = ({
const { handleColumnsChange } = useTableColumns();
const handleResizeHandlerStart = useCallback((positionX: number) => {
setInitialPointerPositionX(positionX);
}, []);
const handleResizeHandlerStart = useCallback<PointerEventListener>(
({ x }) => {
setInitialPointerPositionX(x);
},
[],
);
const [iconVisibility, setIconVisibility] = useState(false);
const handleResizeHandlerMove = useCallback(
(positionX: number) => {
const handleResizeHandlerMove = useCallback<PointerEventListener>(
({ x }) => {
if (!initialPointerPositionX) return;
setResizeFieldOffset(positionX - initialPointerPositionX);
setResizeFieldOffset(x - initialPointerPositionX);
},
[setResizeFieldOffset, initialPointerPositionX],
);

View File

@ -82,7 +82,7 @@ export const RecordTableHeaderCheckboxColumn = () => {
<StyledColumnHeaderCell
isFirstRowActiveOrFocused={isFirstRowActiveOrFocused}
>
<StyledContainer>
<StyledContainer data-select-disable>
<Checkbox
hoverable
checked={checked}