Skip to content

Commit d7323fd

Browse files
feat(ui): allow dragging on row gutters
1 parent 24a6086 commit d7323fd

2 files changed

Lines changed: 79 additions & 5 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ export interface DataRowProps {
4040
onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void
4141
onCellMouseEnter: (rowIndex: number, colIndex: number) => void
4242
isRowChecked: boolean
43+
/** Keyboard (space/enter) toggle of the row checkbox. */
4344
onRowToggle: (rowIndex: number, shiftKey: boolean) => void
45+
/** Pointer-down on the gutter — toggles the row and arms gutter drag-select. */
46+
onRowMouseDown: (rowIndex: number, shiftKey: boolean) => void
47+
/** Pointer entering the gutter cell — extends an in-progress gutter drag. */
48+
onRowMouseEnter: (rowIndex: number) => void
4449
/** Number of workflow cells in this row currently in a running/queued state. */
4550
runningCount: number
4651
/** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */
@@ -115,6 +120,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
115120
prev.onCellMouseEnter !== next.onCellMouseEnter ||
116121
prev.isRowChecked !== next.isRowChecked ||
117122
prev.onRowToggle !== next.onRowToggle ||
123+
prev.onRowMouseDown !== next.onRowMouseDown ||
124+
prev.onRowMouseEnter !== next.onRowMouseEnter ||
118125
prev.runningCount !== next.runningCount ||
119126
prev.hasWorkflowColumns !== next.hasWorkflowColumns ||
120127
prev.numDivWidth !== next.numDivWidth ||
@@ -161,6 +168,8 @@ export const DataRow = React.memo(function DataRow({
161168
onCellMouseDown,
162169
onCellMouseEnter,
163170
onRowToggle,
171+
onRowMouseDown,
172+
onRowMouseEnter,
164173
runningCount,
165174
hasWorkflowColumns,
166175
numDivWidth,
@@ -207,7 +216,10 @@ export const DataRow = React.memo(function DataRow({
207216

208217
return (
209218
<tr onContextMenu={(e) => onContextMenu(e, row)}>
210-
<td className={cn(CELL_CHECKBOX, 'cursor-pointer')}>
219+
<td
220+
className={cn(CELL_CHECKBOX, 'cursor-pointer')}
221+
onMouseEnter={() => onRowMouseEnter(rowIndex)}
222+
>
211223
{isLeftEdgeSelected && (
212224
<div
213225
className={cn(
@@ -236,7 +248,7 @@ export const DataRow = React.memo(function DataRow({
236248
style={{ width: numDivWidth }}
237249
onMouseDown={(e) => {
238250
if (e.button !== 0) return
239-
onRowToggle(rowIndex, e.shiftKey)
251+
onRowMouseDown(rowIndex, e.shiftKey)
240252
}}
241253
onKeyDown={(event) =>
242254
handleKeyboardActivation(event, () => onRowToggle(rowIndex, event.shiftKey))

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,16 @@ export function TableGrid({
309309
const tbodyRef = useRef<HTMLTableSectionElement>(null)
310310
const isDraggingRef = useRef(false)
311311
const suppressFocusScrollRef = useRef(false)
312+
/**
313+
* Row-gutter drag-to-select. `isRowDraggingRef` is the active flag (kept
314+
* separate from the cell-drag `isDraggingRef` so the two don't cross-fire),
315+
* `rowDragAnchorRef` is the row index the drag started on, and
316+
* `rowDragBaseRef` is the materialized selection captured before the drag so
317+
* the swept range is unioned onto whatever was already selected.
318+
*/
319+
const isRowDraggingRef = useRef(false)
320+
const rowDragAnchorRef = useRef<number | null>(null)
321+
const rowDragBaseRef = useRef<Set<string> | null>(null)
312322

313323
const {
314324
tableData,
@@ -1058,6 +1068,44 @@ export function TableGrid({
10581068
scrollRef.current?.focus({ preventScroll: true })
10591069
}, [])
10601070

1071+
/** Selects every row between the drag anchor and `rowIndex`, unioned onto the base. */
1072+
const extendRowDragTo = useCallback((rowIndex: number) => {
1073+
const anchor = rowDragAnchorRef.current
1074+
if (anchor === null) return
1075+
const currentRows = rowsRef.current
1076+
const next = new Set(rowDragBaseRef.current ?? [])
1077+
const from = Math.min(anchor, rowIndex)
1078+
const to = Math.max(anchor, rowIndex)
1079+
for (let i = from; i <= to; i++) {
1080+
const r = currentRows[i]
1081+
if (r) next.add(r.id)
1082+
}
1083+
setRowSelection(next.size === 0 ? ROW_SELECTION_NONE : { kind: 'some', ids: next })
1084+
}, [])
1085+
1086+
const handleRowMouseDown = useCallback(
1087+
(rowIndex: number, shiftKey: boolean) => {
1088+
// Capture the selection before the click mutates it so a drag unions the
1089+
// swept range onto the prior selection rather than the toggled result.
1090+
rowDragBaseRef.current = rowSelectionMaterialize(rowSelectionRef.current, rowsRef.current)
1091+
handleRowToggle(rowIndex, shiftKey)
1092+
// Shift-click extends from the last checkbox row — leave ranging to that
1093+
// path and don't begin a drag.
1094+
if (shiftKey) return
1095+
isRowDraggingRef.current = true
1096+
rowDragAnchorRef.current = rowIndex
1097+
},
1098+
[handleRowToggle]
1099+
)
1100+
1101+
const handleRowMouseEnter = useCallback(
1102+
(rowIndex: number) => {
1103+
if (!isRowDraggingRef.current || rowDragAnchorRef.current === null) return
1104+
extendRowDragTo(rowIndex)
1105+
},
1106+
[extendRowDragTo]
1107+
)
1108+
10611109
const handleClearSelection = useCallback(() => {
10621110
setSelectionAnchor(null)
10631111
setSelectionFocus(null)
@@ -1530,6 +1578,9 @@ export function TableGrid({
15301578
useEffect(() => {
15311579
const handleMouseUp = () => {
15321580
isDraggingRef.current = false
1581+
isRowDraggingRef.current = false
1582+
rowDragAnchorRef.current = null
1583+
rowDragBaseRef.current = null
15331584
}
15341585
document.addEventListener('mouseup', handleMouseUp)
15351586
return () => document.removeEventListener('mouseup', handleMouseUp)
@@ -1561,6 +1612,15 @@ export function TableGrid({
15611612
if (pointerX === null || pointerY === null) return
15621613
const target = document.elementFromPoint(pointerX, pointerY)
15631614
if (!target) return
1615+
if (isRowDraggingRef.current) {
1616+
// The gutter cell carries no coords; read the row index off any data
1617+
// cell in the same `<tr>` and extend the swept row range.
1618+
const cell = (target as HTMLElement).closest('tr')?.querySelector('td[data-row]')
1619+
const rowIndex = Number.parseInt(cell?.getAttribute('data-row') ?? '', 10)
1620+
if (Number.isNaN(rowIndex)) return
1621+
extendRowDragTo(rowIndex)
1622+
return
1623+
}
15641624
const td = (target as HTMLElement).closest('td[data-row][data-col]') as HTMLElement | null
15651625
if (!td) return
15661626
const rowIndex = Number.parseInt(td.getAttribute('data-row') ?? '', 10)
@@ -1572,7 +1632,7 @@ export function TableGrid({
15721632
const tick = () => {
15731633
rafId = null
15741634
const el = scrollRef.current
1575-
if (!isDraggingRef.current || !el || pointerY === null) return
1635+
if ((!isDraggingRef.current && !isRowDraggingRef.current) || !el || pointerY === null) return
15761636
const rect = el.getBoundingClientRect()
15771637
const distFromTop = pointerY - rect.top
15781638
const distFromBottom = rect.bottom - pointerY
@@ -1592,7 +1652,7 @@ export function TableGrid({
15921652
}
15931653

15941654
const handleMove = (e: MouseEvent) => {
1595-
if (!isDraggingRef.current) return
1655+
if (!isDraggingRef.current && !isRowDraggingRef.current) return
15961656
pointerX = e.clientX
15971657
pointerY = e.clientY
15981658
if (rafId === null) rafId = requestAnimationFrame(tick)
@@ -1614,7 +1674,7 @@ export function TableGrid({
16141674
document.removeEventListener('mouseup', handleStop)
16151675
handleStop()
16161676
}
1617-
}, [])
1677+
}, [extendRowDragTo])
16181678

16191679
useEffect(() => {
16201680
// Skip during transient empty-rows state (initial load of a new sort/filter
@@ -3525,6 +3585,8 @@ export function TableGrid({
35253585
onCellMouseEnter={handleCellMouseEnter}
35263586
isRowChecked={rowSelectionIncludes(rowSelection, row.id)}
35273587
onRowToggle={handleRowToggle}
3588+
onRowMouseDown={handleRowMouseDown}
3589+
onRowMouseEnter={handleRowMouseEnter}
35283590
runningCount={runningByRowId[row.id] ?? 0}
35293591
hasWorkflowColumns={hasWorkflowColumns}
35303592
numDivWidth={numDivWidth}

0 commit comments

Comments
 (0)