Skip to content

Commit 9011512

Browse files
fix(tables): keep drag targets aligned while scrolling
1 parent 68667bd commit 9011512

5 files changed

Lines changed: 116 additions & 94 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
252252

253253
return (
254254
<th
255+
data-column-drag-target={column.key}
256+
data-column-drag-group={column.workflowGroupId}
255257
className={cn(
256258
'group relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
257259
stickyLeft !== undefined && 'z-[11]',

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,8 @@ export function WorkflowGroupMetaCell({
457457
return (
458458
<th
459459
colSpan={size}
460+
data-column-drag-target={columnName}
461+
data-column-drag-group={groupId}
460462
onClick={selectGroupAndOpenConfig}
461463
onContextMenu={handleContextMenu}
462464
draggable={isDraggable}

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

Lines changed: 86 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,7 @@ export function TableGrid({
536536
const containerRef = useRef<HTMLDivElement>(null)
537537
const scrollRef = useRef<HTMLDivElement>(null)
538538
const columnDragPointerXRef = useRef<number | null>(null)
539+
const columnDragPointerYRef = useRef<number | null>(null)
539540
const columnDragScrollFrameRef = useRef<number | null>(null)
540541
const theadRef = useRef<HTMLTableSectionElement>(null)
541542
const tbodyRef = useRef<HTMLTableSectionElement>(null)
@@ -1770,21 +1771,91 @@ export function TableGrid({
17701771

17711772
const stopColumnDragAutoScroll = useCallback(() => {
17721773
columnDragPointerXRef.current = null
1774+
columnDragPointerYRef.current = null
17731775
if (columnDragScrollFrameRef.current !== null) {
17741776
cancelAnimationFrame(columnDragScrollFrameRef.current)
17751777
columnDragScrollFrameRef.current = null
17761778
}
17771779
}, [])
17781780

1779-
function startColumnDragAutoScroll(pointerX: number) {
1781+
const handleColumnDragLeave = useCallback(() => {
1782+
dropTargetColumnNameRef.current = null
1783+
setDropTargetColumnName(null)
1784+
}, [])
1785+
1786+
const updateColumnDropTarget = useCallback(
1787+
(columnName: string, side: 'left' | 'right') => {
1788+
const dragged = dragColumnNameRef.current
1789+
if (!dragged) return
1790+
1791+
const cols = schemaColumnsRef.current
1792+
const draggedGid = cols.find((c) => getColumnId(c) === dragged)?.workflowGroupId
1793+
const targetGid = cols.find((c) => getColumnId(c) === columnName)?.workflowGroupId
1794+
if (
1795+
(draggedGid && draggedGid === targetGid) ||
1796+
pinnedColumnsRef.current.includes(dragged) !== pinnedColumnsRef.current.includes(columnName)
1797+
) {
1798+
handleColumnDragLeave()
1799+
return
1800+
}
1801+
1802+
if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return
1803+
dropTargetColumnNameRef.current = columnName
1804+
dropSideRef.current = side
1805+
setDropTargetColumnName(columnName)
1806+
setDropSide(side)
1807+
},
1808+
[handleColumnDragLeave]
1809+
)
1810+
1811+
function updateColumnDropTargetAtPoint(pointerX: number, pointerY: number) {
1812+
const hoveredElement = document.elementFromPoint(pointerX, pointerY)
1813+
const header = hoveredElement?.closest<HTMLElement>('th[data-column-drag-target]')
1814+
if (!header || !theadRef.current?.contains(header)) {
1815+
handleColumnDragLeave()
1816+
return
1817+
}
1818+
1819+
const columnName = header.dataset.columnDragTarget
1820+
if (!columnName) {
1821+
handleColumnDragLeave()
1822+
return
1823+
}
1824+
1825+
const targetGroupId = header.dataset.columnDragGroup
1826+
let { left, right } = header.getBoundingClientRect()
1827+
if (targetGroupId) {
1828+
const groupHeaders = theadRef.current.querySelectorAll<HTMLElement>(
1829+
'th[data-column-drag-group]'
1830+
)
1831+
for (const groupHeader of groupHeaders) {
1832+
if (groupHeader.dataset.columnDragGroup !== targetGroupId) continue
1833+
const rect = groupHeader.getBoundingClientRect()
1834+
left = Math.min(left, rect.left)
1835+
right = Math.max(right, rect.right)
1836+
}
1837+
}
1838+
1839+
updateColumnDropTarget(columnName, pointerX < left + (right - left) / 2 ? 'left' : 'right')
1840+
}
1841+
1842+
function startColumnDragAutoScroll(pointerX: number, pointerY: number) {
17801843
columnDragPointerXRef.current = pointerX
1844+
columnDragPointerYRef.current = pointerY
17811845
if (columnDragScrollFrameRef.current !== null) return
17821846

17831847
const tick = () => {
17841848
columnDragScrollFrameRef.current = null
17851849
const scrollEl = scrollRef.current
17861850
const currentPointerX = columnDragPointerXRef.current
1787-
if (!scrollEl || currentPointerX === null || !dragColumnNameRef.current) return
1851+
const currentPointerY = columnDragPointerYRef.current
1852+
if (
1853+
!scrollEl ||
1854+
currentPointerX === null ||
1855+
currentPointerY === null ||
1856+
!dragColumnNameRef.current
1857+
)
1858+
return
17881859

17891860
const scrollRect = scrollEl.getBoundingClientRect()
17901861
const velocity = horizontalEdgeScrollVelocity({
@@ -1799,6 +1870,7 @@ export function TableGrid({
17991870
const previousScrollLeft = scrollEl.scrollLeft
18001871
scrollEl.scrollLeft += velocity
18011872
if (scrollEl.scrollLeft !== previousScrollLeft) {
1873+
updateColumnDropTargetAtPoint(currentPointerX, currentPointerY)
18021874
columnDragScrollFrameRef.current = requestAnimationFrame(tick)
18031875
}
18041876
}
@@ -1821,44 +1893,15 @@ export function TableGrid({
18211893
[stopColumnDragAutoScroll]
18221894
)
18231895

1824-
const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => {
1825-
const dragged = dragColumnNameRef.current
1826-
const cols = schemaColumnsRef.current
1827-
const targetCol = cols.find((c) => getColumnId(c) === columnName)
1828-
const targetGid = targetCol?.workflowGroupId
1829-
1830-
// Suppress drop targeting while hovering siblings of the dragged column's
1831-
// own group: reordering inside a group is meaningless (the group renders
1832-
// as a unit) and the chasing indicator just flickers.
1833-
if (dragged) {
1834-
const draggedGid = cols.find((c) => getColumnId(c) === dragged)?.workflowGroupId
1835-
if (draggedGid && draggedGid === targetGid) {
1836-
if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null)
1837-
return
1838-
}
1839-
}
1840-
1841-
// Reorder is restricted to within a single zone so a cross-zone drop
1842-
// indicator never appears for an insertion the grid would refuse.
1843-
if (dragged) {
1844-
const pinned = pinnedColumnsRef.current
1845-
if (pinned.includes(dragged) !== pinned.includes(columnName)) {
1846-
if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null)
1847-
return
1848-
}
1849-
}
1850-
1851-
// Workflow groups: skip per-`<th>` writes and let `handleScrollDragOver`
1852-
// do the bookkeeping. The scroll handler computes side from the group's
1853-
// full bounds, so it stays stable across sibling cursor moves; the per-th
1854-
// events would otherwise oscillate name + side as the cursor crosses each
1855-
// sibling's midpoint.
1856-
if (targetGid) return
1857-
1858-
if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return
1859-
setDropTargetColumnName(columnName)
1860-
setDropSide(side)
1861-
}, [])
1896+
const handleColumnDragOver = useCallback(
1897+
(columnName: string, side: 'left' | 'right') => {
1898+
const cols = schemaColumnsRef.current
1899+
const targetCol = cols.find((c) => getColumnId(c) === columnName)
1900+
if (targetCol?.workflowGroupId) return
1901+
updateColumnDropTarget(columnName, side)
1902+
},
1903+
[updateColumnDropTarget]
1904+
)
18621905

18631906
const handleColumnDragEnd = useCallback(() => {
18641907
stopColumnDragAutoScroll()
@@ -1998,11 +2041,6 @@ export function TableGrid({
19982041
setDropSide('left')
19992042
}, [stopColumnDragAutoScroll])
20002043

2001-
const handleColumnDragLeave = useCallback(() => {
2002-
dropTargetColumnNameRef.current = null
2003-
setDropTargetColumnName(null)
2004-
}, [])
2005-
20062044
function handleScrollDragOver(e: React.DragEvent) {
20072045
const draggedName = dragColumnNameRef.current
20082046
if (!draggedName) return
@@ -2014,48 +2052,9 @@ export function TableGrid({
20142052
if (pinnedColumnsRef.current.includes(draggedName)) {
20152053
stopColumnDragAutoScroll()
20162054
} else {
2017-
startColumnDragAutoScroll(e.clientX)
2018-
}
2019-
const scrollRect = scrollEl.getBoundingClientRect()
2020-
const cursorX = e.clientX - scrollRect.left + scrollEl.scrollLeft
2021-
2022-
const cols = columnsRef.current
2023-
const draggedGid = cols.find((c) => c.key === dragColumnNameRef.current)?.workflowGroupId
2024-
let left = checkboxColWidth
2025-
let i = 0
2026-
while (i < cols.length) {
2027-
const col = cols[i]
2028-
// Treat fanned-out groups as monolithic drop targets; accumulate across siblings.
2029-
// Clamp `groupSize` to remaining columns: dragover fires constantly and can
2030-
// race a column removal where the cached `groupSize` outpaces `cols.length`.
2031-
const groupSize = Math.min(col.groupSize, cols.length - i)
2032-
let groupWidth = 0
2033-
for (let j = 0; j < groupSize; j++) {
2034-
groupWidth += columnWidthsRef.current[cols[i + j].key] ?? COL_WIDTH
2035-
}
2036-
if (cursorX < left + groupWidth) {
2037-
// Inside the dragged column's own group → no-op drop, no indicator.
2038-
if (draggedGid && col.workflowGroupId === draggedGid) {
2039-
if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null)
2040-
return
2041-
}
2042-
const pinned = pinnedColumnsRef.current
2043-
const draggedName = dragColumnNameRef.current
2044-
if (draggedName && pinned.includes(draggedName) !== pinned.includes(col.key)) {
2045-
if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null)
2046-
return
2047-
}
2048-
const midX = left + groupWidth / 2
2049-
const side = cursorX < midX ? 'left' : 'right'
2050-
if (col.key !== dropTargetColumnNameRef.current || side !== dropSideRef.current) {
2051-
setDropTargetColumnName(col.key)
2052-
setDropSide(side)
2053-
}
2054-
return
2055-
}
2056-
left += groupWidth
2057-
i += groupSize
2055+
startColumnDragAutoScroll(e.clientX, e.clientY)
20582056
}
2057+
updateColumnDropTargetAtPoint(e.clientX, e.clientY)
20592058
}
20602059

20612060
function handleScrollDrop(e: React.DragEvent) {
@@ -4422,6 +4421,7 @@ export function TableGrid({
44224421
return (
44234422
<th
44244423
key={`meta-${g.startColIndex}`}
4424+
data-column-drag-target={firstCol?.key}
44254425
className={cn(
44264426
'border-[var(--border)] border-b bg-[var(--bg)] px-2 py-[5px]',
44274427
stickyLeft !== undefined && 'z-[11]',

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,31 @@ describe('horizontalEdgeScrollVelocity', () => {
4747
expect(getVelocity(500)).toBe(0)
4848
})
4949

50-
it('fails fast for invalid geometry', () => {
51-
expect(() =>
50+
it('stays still when pinned columns consume the visible viewport', () => {
51+
expect(
5252
horizontalEdgeScrollVelocity({
5353
pointerX: 100,
5454
visibleLeft: 200,
5555
visibleRight: 100,
5656
hotZone: 48,
5757
maxVelocity: 14,
5858
})
59-
).toThrow('visibleRight must be greater than visibleLeft')
59+
).toBe(0)
60+
})
61+
62+
it('uses the nearest edge when a narrow viewport would overlap both hot zones', () => {
63+
const narrowVelocity = (pointerX: number) =>
64+
horizontalEdgeScrollVelocity({
65+
pointerX,
66+
visibleLeft: 100,
67+
visibleRight: 140,
68+
hotZone: 48,
69+
maxVelocity: 14,
70+
})
71+
72+
expect(narrowVelocity(105)).toBe(-11)
73+
expect(narrowVelocity(120)).toBe(0)
74+
expect(narrowVelocity(135)).toBe(11)
6075
})
6176
})
6277

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,20 @@ export function horizontalEdgeScrollVelocity({
4848
}: HorizontalEdgeScrollVelocityInput): number {
4949
if (hotZone <= 0) throw new Error('hotZone must be greater than zero')
5050
if (maxVelocity <= 0) throw new Error('maxVelocity must be greater than zero')
51-
if (visibleRight <= visibleLeft) throw new Error('visibleRight must be greater than visibleLeft')
51+
const visibleWidth = visibleRight - visibleLeft
52+
if (visibleWidth <= 0) return 0
53+
54+
const edgeZone = Math.min(hotZone, visibleWidth / 2)
5255

5356
const distanceFromLeft = pointerX - visibleLeft
54-
if (distanceFromLeft < hotZone) {
55-
const intensity = 1 - Math.max(0, distanceFromLeft) / hotZone
57+
if (distanceFromLeft < edgeZone) {
58+
const intensity = 1 - Math.max(0, distanceFromLeft) / edgeZone
5659
return -Math.ceil(intensity * maxVelocity)
5760
}
5861

5962
const distanceFromRight = visibleRight - pointerX
60-
if (distanceFromRight < hotZone) {
61-
const intensity = 1 - Math.max(0, distanceFromRight) / hotZone
63+
if (distanceFromRight < edgeZone) {
64+
const intensity = 1 - Math.max(0, distanceFromRight) / edgeZone
6265
return Math.ceil(intensity * maxVelocity)
6366
}
6467

0 commit comments

Comments
 (0)