From 68667bda277b675add7762f4fe78973d821818d0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 19:18:13 -0700 Subject: [PATCH 1/4] fix(tables): auto-scroll during column drag --- .../components/table-grid/table-grid.tsx | 74 +++++++++++++++++-- .../components/table-grid/utils.test.ts | 35 +++++++++ .../[tableId]/components/table-grid/utils.ts | 34 +++++++++ 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 59222045098..15c4bdf9cde 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -78,6 +78,7 @@ import { drainTargetForChip, type ExecStatusMix, expandToDisplayColumns, + horizontalEdgeScrollVelocity, isCellInSelection, moveCell, ROW_SELECTION_ALL, @@ -99,6 +100,8 @@ const EMPTY_FILTER_CONDITIONS: readonly Predicate[] = Object.freeze([]) const COL_WIDTH_MIN = 80 const COL_WIDTH_AUTO_FIT_MAX = 1000 const ROW_HEIGHT_ESTIMATE = 35 +const COLUMN_DRAG_SCROLL_HOT_ZONE_PX = 48 +const COLUMN_DRAG_SCROLL_MAX_VELOCITY_PX = 14 /** * Snapshot of grid selection state the wrapper needs to render ``. @@ -532,6 +535,8 @@ export function TableGrid({ const seededLayoutKeyRef = useRef(null) const containerRef = useRef(null) const scrollRef = useRef(null) + const columnDragPointerXRef = useRef(null) + const columnDragScrollFrameRef = useRef(null) const theadRef = useRef(null) const tbodyRef = useRef(null) const isDraggingRef = useRef(false) @@ -1763,14 +1768,59 @@ export function TableGrid({ ) }, []) - const handleColumnDragStart = useCallback((columnName: string) => { - setDragColumnName(columnName) - setSelectionAnchor(null) - setSelectionFocus(null) - setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) - setIsColumnSelection(false) + const stopColumnDragAutoScroll = useCallback(() => { + columnDragPointerXRef.current = null + if (columnDragScrollFrameRef.current !== null) { + cancelAnimationFrame(columnDragScrollFrameRef.current) + columnDragScrollFrameRef.current = null + } }, []) + function startColumnDragAutoScroll(pointerX: number) { + columnDragPointerXRef.current = pointerX + if (columnDragScrollFrameRef.current !== null) return + + const tick = () => { + columnDragScrollFrameRef.current = null + const scrollEl = scrollRef.current + const currentPointerX = columnDragPointerXRef.current + if (!scrollEl || currentPointerX === null || !dragColumnNameRef.current) return + + const scrollRect = scrollEl.getBoundingClientRect() + const velocity = horizontalEdgeScrollVelocity({ + pointerX: currentPointerX, + visibleLeft: scrollRect.left + pinnedStickyLeftEdge, + visibleRight: scrollRect.right, + hotZone: COLUMN_DRAG_SCROLL_HOT_ZONE_PX, + maxVelocity: COLUMN_DRAG_SCROLL_MAX_VELOCITY_PX, + }) + if (velocity === 0) return + + const previousScrollLeft = scrollEl.scrollLeft + scrollEl.scrollLeft += velocity + if (scrollEl.scrollLeft !== previousScrollLeft) { + columnDragScrollFrameRef.current = requestAnimationFrame(tick) + } + } + + columnDragScrollFrameRef.current = requestAnimationFrame(tick) + } + + useEffect(() => stopColumnDragAutoScroll, [stopColumnDragAutoScroll]) + + const handleColumnDragStart = useCallback( + (columnName: string) => { + stopColumnDragAutoScroll() + dragColumnNameRef.current = columnName + setDragColumnName(columnName) + setSelectionAnchor(null) + setSelectionFocus(null) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) + setIsColumnSelection(false) + }, + [stopColumnDragAutoScroll] + ) + const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { const dragged = dragColumnNameRef.current const cols = schemaColumnsRef.current @@ -1811,6 +1861,7 @@ export function TableGrid({ }, []) const handleColumnDragEnd = useCallback(() => { + stopColumnDragAutoScroll() const dragged = dragColumnNameRef.current if (!dragged) { setDragColumnName(null) @@ -1945,7 +1996,7 @@ export function TableGrid({ setDragColumnName(null) setDropTargetColumnName(null) setDropSide('left') - }, []) + }, [stopColumnDragAutoScroll]) const handleColumnDragLeave = useCallback(() => { dropTargetColumnNameRef.current = null @@ -1953,12 +2004,18 @@ export function TableGrid({ }, []) function handleScrollDragOver(e: React.DragEvent) { - if (!dragColumnNameRef.current) return + const draggedName = dragColumnNameRef.current + if (!draggedName) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const scrollEl = scrollRef.current if (!scrollEl) return + if (pinnedColumnsRef.current.includes(draggedName)) { + stopColumnDragAutoScroll() + } else { + startColumnDragAutoScroll(e.clientX) + } const scrollRect = scrollEl.getBoundingClientRect() const cursorX = e.clientX - scrollRect.left + scrollEl.scrollLeft @@ -2003,6 +2060,7 @@ export function TableGrid({ function handleScrollDrop(e: React.DragEvent) { e.preventDefault() + stopColumnDragAutoScroll() } useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts index 0dc6c3cd9b2..cfaafa1a6e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts @@ -13,6 +13,7 @@ import { canWriteRowsWithChip, chipRowCount, drainTargetForChip, + horizontalEdgeScrollVelocity, selectedColumnIds, } from './utils' @@ -25,6 +26,40 @@ function columns(count: number): DisplayColumn[] { const rowIds = (count: number) => Array.from({ length: count }, (_, i) => `r${i}`) +describe('horizontalEdgeScrollVelocity', () => { + const getVelocity = (pointerX: number) => + horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft: 140, + visibleRight: 900, + hotZone: 48, + maxVelocity: 14, + }) + + it('scrolls left when the pointer enters the visible edge after sticky columns', () => { + expect(getVelocity(140)).toBe(-14) + expect(getVelocity(164)).toBe(-7) + }) + + it('scrolls right at the opposite edge and stays still between edge zones', () => { + expect(getVelocity(876)).toBe(7) + expect(getVelocity(900)).toBe(14) + expect(getVelocity(500)).toBe(0) + }) + + it('fails fast for invalid geometry', () => { + expect(() => + horizontalEdgeScrollVelocity({ + pointerX: 100, + visibleLeft: 200, + visibleRight: 100, + hotZone: 48, + maxVelocity: 14, + }) + ).toThrow('visibleRight must be greater than visibleLeft') + }) +}) + describe('selectedColumnIds', () => { it('returns the ids the range spans', () => { expect(selectedColumnIds(columns(5), { startCol: 1, endCol: 3 })).toEqual(['c1', 'c2', 'c3']) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index b2486fcf969..feccc2c8c35 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -31,6 +31,40 @@ export type RowSelection = export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' } export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' } +interface HorizontalEdgeScrollVelocityInput { + pointerX: number + visibleLeft: number + visibleRight: number + hotZone: number + maxVelocity: number +} + +export function horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft, + visibleRight, + hotZone, + maxVelocity, +}: HorizontalEdgeScrollVelocityInput): number { + if (hotZone <= 0) throw new Error('hotZone must be greater than zero') + if (maxVelocity <= 0) throw new Error('maxVelocity must be greater than zero') + if (visibleRight <= visibleLeft) throw new Error('visibleRight must be greater than visibleLeft') + + const distanceFromLeft = pointerX - visibleLeft + if (distanceFromLeft < hotZone) { + const intensity = 1 - Math.max(0, distanceFromLeft) / hotZone + return -Math.ceil(intensity * maxVelocity) + } + + const distanceFromRight = visibleRight - pointerX + if (distanceFromRight < hotZone) { + const intensity = 1 - Math.max(0, distanceFromRight) / hotZone + return Math.ceil(intensity * maxVelocity) + } + + return 0 +} + export function rowSelectionIncludes(sel: RowSelection, id: string): boolean { if (sel.kind === 'all') return !sel.excluded?.has(id) if (sel.kind === 'some') return sel.ids.has(id) From 9011512d1308c2f834ce492c463fbf1fafe579c5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 19:30:35 -0700 Subject: [PATCH 2/4] fix(tables): keep drag targets aligned while scrolling --- .../table-grid/headers/column-header-menu.tsx | 2 + .../headers/workflow-group-meta-cell.tsx | 2 + .../components/table-grid/table-grid.tsx | 172 +++++++++--------- .../components/table-grid/utils.test.ts | 21 ++- .../[tableId]/components/table-grid/utils.ts | 13 +- 5 files changed, 116 insertions(+), 94 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index 74b6e297b7a..04025f40920 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -252,6 +252,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ return ( (null) const scrollRef = useRef(null) const columnDragPointerXRef = useRef(null) + const columnDragPointerYRef = useRef(null) const columnDragScrollFrameRef = useRef(null) const theadRef = useRef(null) const tbodyRef = useRef(null) @@ -1770,21 +1771,91 @@ export function TableGrid({ const stopColumnDragAutoScroll = useCallback(() => { columnDragPointerXRef.current = null + columnDragPointerYRef.current = null if (columnDragScrollFrameRef.current !== null) { cancelAnimationFrame(columnDragScrollFrameRef.current) columnDragScrollFrameRef.current = null } }, []) - function startColumnDragAutoScroll(pointerX: number) { + const handleColumnDragLeave = useCallback(() => { + dropTargetColumnNameRef.current = null + setDropTargetColumnName(null) + }, []) + + const updateColumnDropTarget = useCallback( + (columnName: string, side: 'left' | 'right') => { + const dragged = dragColumnNameRef.current + if (!dragged) return + + const cols = schemaColumnsRef.current + const draggedGid = cols.find((c) => getColumnId(c) === dragged)?.workflowGroupId + const targetGid = cols.find((c) => getColumnId(c) === columnName)?.workflowGroupId + if ( + (draggedGid && draggedGid === targetGid) || + pinnedColumnsRef.current.includes(dragged) !== pinnedColumnsRef.current.includes(columnName) + ) { + handleColumnDragLeave() + return + } + + if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return + dropTargetColumnNameRef.current = columnName + dropSideRef.current = side + setDropTargetColumnName(columnName) + setDropSide(side) + }, + [handleColumnDragLeave] + ) + + function updateColumnDropTargetAtPoint(pointerX: number, pointerY: number) { + const hoveredElement = document.elementFromPoint(pointerX, pointerY) + const header = hoveredElement?.closest('th[data-column-drag-target]') + if (!header || !theadRef.current?.contains(header)) { + handleColumnDragLeave() + return + } + + const columnName = header.dataset.columnDragTarget + if (!columnName) { + handleColumnDragLeave() + return + } + + const targetGroupId = header.dataset.columnDragGroup + let { left, right } = header.getBoundingClientRect() + if (targetGroupId) { + const groupHeaders = theadRef.current.querySelectorAll( + 'th[data-column-drag-group]' + ) + for (const groupHeader of groupHeaders) { + if (groupHeader.dataset.columnDragGroup !== targetGroupId) continue + const rect = groupHeader.getBoundingClientRect() + left = Math.min(left, rect.left) + right = Math.max(right, rect.right) + } + } + + updateColumnDropTarget(columnName, pointerX < left + (right - left) / 2 ? 'left' : 'right') + } + + function startColumnDragAutoScroll(pointerX: number, pointerY: number) { columnDragPointerXRef.current = pointerX + columnDragPointerYRef.current = pointerY if (columnDragScrollFrameRef.current !== null) return const tick = () => { columnDragScrollFrameRef.current = null const scrollEl = scrollRef.current const currentPointerX = columnDragPointerXRef.current - if (!scrollEl || currentPointerX === null || !dragColumnNameRef.current) return + const currentPointerY = columnDragPointerYRef.current + if ( + !scrollEl || + currentPointerX === null || + currentPointerY === null || + !dragColumnNameRef.current + ) + return const scrollRect = scrollEl.getBoundingClientRect() const velocity = horizontalEdgeScrollVelocity({ @@ -1799,6 +1870,7 @@ export function TableGrid({ const previousScrollLeft = scrollEl.scrollLeft scrollEl.scrollLeft += velocity if (scrollEl.scrollLeft !== previousScrollLeft) { + updateColumnDropTargetAtPoint(currentPointerX, currentPointerY) columnDragScrollFrameRef.current = requestAnimationFrame(tick) } } @@ -1821,44 +1893,15 @@ export function TableGrid({ [stopColumnDragAutoScroll] ) - const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { - const dragged = dragColumnNameRef.current - const cols = schemaColumnsRef.current - const targetCol = cols.find((c) => getColumnId(c) === columnName) - const targetGid = targetCol?.workflowGroupId - - // Suppress drop targeting while hovering siblings of the dragged column's - // own group: reordering inside a group is meaningless (the group renders - // as a unit) and the chasing indicator just flickers. - if (dragged) { - const draggedGid = cols.find((c) => getColumnId(c) === dragged)?.workflowGroupId - if (draggedGid && draggedGid === targetGid) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - } - - // Reorder is restricted to within a single zone so a cross-zone drop - // indicator never appears for an insertion the grid would refuse. - if (dragged) { - const pinned = pinnedColumnsRef.current - if (pinned.includes(dragged) !== pinned.includes(columnName)) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - } - - // Workflow groups: skip per-`` writes and let `handleScrollDragOver` - // do the bookkeeping. The scroll handler computes side from the group's - // full bounds, so it stays stable across sibling cursor moves; the per-th - // events would otherwise oscillate name + side as the cursor crosses each - // sibling's midpoint. - if (targetGid) return - - if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return - setDropTargetColumnName(columnName) - setDropSide(side) - }, []) + const handleColumnDragOver = useCallback( + (columnName: string, side: 'left' | 'right') => { + const cols = schemaColumnsRef.current + const targetCol = cols.find((c) => getColumnId(c) === columnName) + if (targetCol?.workflowGroupId) return + updateColumnDropTarget(columnName, side) + }, + [updateColumnDropTarget] + ) const handleColumnDragEnd = useCallback(() => { stopColumnDragAutoScroll() @@ -1998,11 +2041,6 @@ export function TableGrid({ setDropSide('left') }, [stopColumnDragAutoScroll]) - const handleColumnDragLeave = useCallback(() => { - dropTargetColumnNameRef.current = null - setDropTargetColumnName(null) - }, []) - function handleScrollDragOver(e: React.DragEvent) { const draggedName = dragColumnNameRef.current if (!draggedName) return @@ -2014,48 +2052,9 @@ export function TableGrid({ if (pinnedColumnsRef.current.includes(draggedName)) { stopColumnDragAutoScroll() } else { - startColumnDragAutoScroll(e.clientX) - } - const scrollRect = scrollEl.getBoundingClientRect() - const cursorX = e.clientX - scrollRect.left + scrollEl.scrollLeft - - const cols = columnsRef.current - const draggedGid = cols.find((c) => c.key === dragColumnNameRef.current)?.workflowGroupId - let left = checkboxColWidth - let i = 0 - while (i < cols.length) { - const col = cols[i] - // Treat fanned-out groups as monolithic drop targets; accumulate across siblings. - // Clamp `groupSize` to remaining columns: dragover fires constantly and can - // race a column removal where the cached `groupSize` outpaces `cols.length`. - const groupSize = Math.min(col.groupSize, cols.length - i) - let groupWidth = 0 - for (let j = 0; j < groupSize; j++) { - groupWidth += columnWidthsRef.current[cols[i + j].key] ?? COL_WIDTH - } - if (cursorX < left + groupWidth) { - // Inside the dragged column's own group → no-op drop, no indicator. - if (draggedGid && col.workflowGroupId === draggedGid) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - const pinned = pinnedColumnsRef.current - const draggedName = dragColumnNameRef.current - if (draggedName && pinned.includes(draggedName) !== pinned.includes(col.key)) { - if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) - return - } - const midX = left + groupWidth / 2 - const side = cursorX < midX ? 'left' : 'right' - if (col.key !== dropTargetColumnNameRef.current || side !== dropSideRef.current) { - setDropTargetColumnName(col.key) - setDropSide(side) - } - return - } - left += groupWidth - i += groupSize + startColumnDragAutoScroll(e.clientX, e.clientY) } + updateColumnDropTargetAtPoint(e.clientX, e.clientY) } function handleScrollDrop(e: React.DragEvent) { @@ -4422,6 +4421,7 @@ export function TableGrid({ return ( { expect(getVelocity(500)).toBe(0) }) - it('fails fast for invalid geometry', () => { - expect(() => + it('stays still when pinned columns consume the visible viewport', () => { + expect( horizontalEdgeScrollVelocity({ pointerX: 100, visibleLeft: 200, @@ -56,7 +56,22 @@ describe('horizontalEdgeScrollVelocity', () => { hotZone: 48, maxVelocity: 14, }) - ).toThrow('visibleRight must be greater than visibleLeft') + ).toBe(0) + }) + + it('uses the nearest edge when a narrow viewport would overlap both hot zones', () => { + const narrowVelocity = (pointerX: number) => + horizontalEdgeScrollVelocity({ + pointerX, + visibleLeft: 100, + visibleRight: 140, + hotZone: 48, + maxVelocity: 14, + }) + + expect(narrowVelocity(105)).toBe(-11) + expect(narrowVelocity(120)).toBe(0) + expect(narrowVelocity(135)).toBe(11) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index feccc2c8c35..4f3e9282d17 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -48,17 +48,20 @@ export function horizontalEdgeScrollVelocity({ }: HorizontalEdgeScrollVelocityInput): number { if (hotZone <= 0) throw new Error('hotZone must be greater than zero') if (maxVelocity <= 0) throw new Error('maxVelocity must be greater than zero') - if (visibleRight <= visibleLeft) throw new Error('visibleRight must be greater than visibleLeft') + const visibleWidth = visibleRight - visibleLeft + if (visibleWidth <= 0) return 0 + + const edgeZone = Math.min(hotZone, visibleWidth / 2) const distanceFromLeft = pointerX - visibleLeft - if (distanceFromLeft < hotZone) { - const intensity = 1 - Math.max(0, distanceFromLeft) / hotZone + if (distanceFromLeft < edgeZone) { + const intensity = 1 - Math.max(0, distanceFromLeft) / edgeZone return -Math.ceil(intensity * maxVelocity) } const distanceFromRight = visibleRight - pointerX - if (distanceFromRight < hotZone) { - const intensity = 1 - Math.max(0, distanceFromRight) / hotZone + if (distanceFromRight < edgeZone) { + const intensity = 1 - Math.max(0, distanceFromRight) / edgeZone return Math.ceil(intensity * maxVelocity) } From 9c16fa634aa71a46bdd82562ab6f6ceb39e3cc89 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 19:42:34 -0700 Subject: [PATCH 3/4] fix(tables): preserve column targets across drag surface --- .../headers/workflow-group-meta-cell.tsx | 14 ++-- .../components/table-grid/table-grid.tsx | 84 +++++++++++++------ 2 files changed, 65 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index 3a486f6b6a2..68ee553cdad 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -270,6 +270,7 @@ interface WorkflowGroupMetaCellProps { size: number startColIndex: number columnName: string + columnKey: string /** Underlying logical column — needed for the right-click options menu. */ column?: DisplayColumn workflows?: WorkflowMetadata[] @@ -323,6 +324,7 @@ export function WorkflowGroupMetaCell({ size, startColIndex, columnName, + columnKey, column, workflows, isGroupSelected, @@ -405,13 +407,13 @@ export function WorkflowGroupMetaCell({ } function handleDragStart(e: React.DragEvent) { - if (readOnly || !onDragStart || !columnName) { + if (readOnly || !onDragStart) { e.preventDefault() return } didDragRef.current = true e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', columnName) + e.dataTransfer.setData('text/plain', columnKey) const ghost = document.createElement('div') ghost.textContent = name @@ -422,17 +424,17 @@ export function WorkflowGroupMetaCell({ e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost)) - onDragStart(columnName) + onDragStart(columnKey) } function handleDragOver(e: React.DragEvent) { - if (!onDragOver || !columnName) return + if (!onDragOver) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const midX = rect.left + rect.width / 2 const side = e.clientX < midX ? 'left' : 'right' - onDragOver(columnName, side) + onDragOver(columnKey, side) } function handleDragEnd() { @@ -457,7 +459,7 @@ export function WorkflowGroupMetaCell({ return ( (null) const scrollRef = useRef(null) const columnDragPointerXRef = useRef(null) - const columnDragPointerYRef = useRef(null) const columnDragScrollFrameRef = useRef(null) const theadRef = useRef(null) const tbodyRef = useRef(null) @@ -1771,7 +1770,6 @@ export function TableGrid({ const stopColumnDragAutoScroll = useCallback(() => { columnDragPointerXRef.current = null - columnDragPointerYRef.current = null if (columnDragScrollFrameRef.current !== null) { cancelAnimationFrame(columnDragScrollFrameRef.current) columnDragScrollFrameRef.current = null @@ -1808,10 +1806,46 @@ export function TableGrid({ [handleColumnDragLeave] ) - function updateColumnDropTargetAtPoint(pointerX: number, pointerY: number) { - const hoveredElement = document.elementFromPoint(pointerX, pointerY) - const header = hoveredElement?.closest('th[data-column-drag-target]') - if (!header || !theadRef.current?.contains(header)) { + function updateColumnDropTargetAtX(pointerX: number) { + const thead = theadRef.current + const scrollEl = scrollRef.current + const headerRow = thead?.rows.item((thead?.rows.length ?? 0) - 1) + if (!thead || !scrollEl || !headerRow) { + handleColumnDragLeave() + return + } + + const headerRowRect = headerRow.getBoundingClientRect() + const headerY = headerRowRect.top + headerRowRect.height / 2 + const hoveredElement = document.elementFromPoint(pointerX, headerY) + let header = hoveredElement?.closest('th[data-column-drag-target]') ?? null + if (!header || !headerRow.contains(header)) { + const scrollRect = scrollEl.getBoundingClientRect() + const pinnedRight = Math.min(scrollRect.right, scrollRect.left + pinnedStickyLeftEdge) + let nearestDistance = Number.POSITIVE_INFINITY + header = null + + for (const candidate of headerRow.querySelectorAll( + 'th[data-column-drag-target]' + )) { + const candidateName = candidate.dataset.columnDragTarget + if (!candidateName) continue + + const rect = candidate.getBoundingClientRect() + const isPinned = pinnedColumnsRef.current.includes(candidateName) + const left = Math.max(rect.left, isPinned ? scrollRect.left : pinnedRight) + const right = Math.min(rect.right, isPinned ? pinnedRight : scrollRect.right) + if (right <= left) continue + + const distance = pointerX < left ? left - pointerX : pointerX > right ? pointerX - right : 0 + if (distance < nearestDistance) { + nearestDistance = distance + header = candidate + } + } + } + + if (!header) { handleColumnDragLeave() return } @@ -1825,9 +1859,7 @@ export function TableGrid({ const targetGroupId = header.dataset.columnDragGroup let { left, right } = header.getBoundingClientRect() if (targetGroupId) { - const groupHeaders = theadRef.current.querySelectorAll( - 'th[data-column-drag-group]' - ) + const groupHeaders = thead.querySelectorAll('th[data-column-drag-group]') for (const groupHeader of groupHeaders) { if (groupHeader.dataset.columnDragGroup !== targetGroupId) continue const rect = groupHeader.getBoundingClientRect() @@ -1839,23 +1871,15 @@ export function TableGrid({ updateColumnDropTarget(columnName, pointerX < left + (right - left) / 2 ? 'left' : 'right') } - function startColumnDragAutoScroll(pointerX: number, pointerY: number) { + function startColumnDragAutoScroll(pointerX: number) { columnDragPointerXRef.current = pointerX - columnDragPointerYRef.current = pointerY if (columnDragScrollFrameRef.current !== null) return const tick = () => { columnDragScrollFrameRef.current = null const scrollEl = scrollRef.current const currentPointerX = columnDragPointerXRef.current - const currentPointerY = columnDragPointerYRef.current - if ( - !scrollEl || - currentPointerX === null || - currentPointerY === null || - !dragColumnNameRef.current - ) - return + if (!scrollEl || currentPointerX === null || !dragColumnNameRef.current) return const scrollRect = scrollEl.getBoundingClientRect() const velocity = horizontalEdgeScrollVelocity({ @@ -1870,7 +1894,7 @@ export function TableGrid({ const previousScrollLeft = scrollEl.scrollLeft scrollEl.scrollLeft += velocity if (scrollEl.scrollLeft !== previousScrollLeft) { - updateColumnDropTargetAtPoint(currentPointerX, currentPointerY) + updateColumnDropTargetAtX(currentPointerX) columnDragScrollFrameRef.current = requestAnimationFrame(tick) } } @@ -2052,9 +2076,9 @@ export function TableGrid({ if (pinnedColumnsRef.current.includes(draggedName)) { stopColumnDragAutoScroll() } else { - startColumnDragAutoScroll(e.clientX, e.clientY) + startColumnDragAutoScroll(e.clientX) } - updateColumnDropTargetAtPoint(e.clientX, e.clientY) + updateColumnDropTargetAtX(e.clientX) } function handleScrollDrop(e: React.DragEvent) { @@ -4332,7 +4356,12 @@ export function TableGrid({ {headerGroups.map((g) => { const firstCol = displayColumns[g.startColIndex] - const stickyLeft = firstCol ? pinnedOffsets.get(firstCol.key) : undefined + if (!firstCol) { + throw new Error( + `Missing display column for header group at index ${g.startColIndex}` + ) + } + const stickyLeft = pinnedOffsets.get(firstCol.key) if (g.kind === 'workflow') { const lastCol = displayColumns[g.startColIndex + g.size - 1] return ( @@ -4341,7 +4370,8 @@ export function TableGrid({ workflowId={g.workflowId} size={g.size} startColIndex={g.startColIndex} - columnName={firstCol?.name ?? ''} + columnName={firstCol.name} + columnKey={firstCol.key} column={firstCol} workflows={workflows} isGroupSelected={ @@ -4410,18 +4440,18 @@ export function TableGrid({ onDragLeave={ userPermissions.canEdit ? handleColumnDragLeave : undefined } - isPinned={firstCol ? pinnedColumnSet.has(firstCol.key) : false} + isPinned={pinnedColumnSet.has(firstCol.key)} onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined} stickyLeft={stickyLeft} isLastPinned={lastCol?.key === lastPinnedColKey} /> ) } - const isLastFrz = firstCol?.key === lastPinnedColKey + const isLastFrz = firstCol.key === lastPinnedColKey return ( Date: Fri, 14 Aug 2026 19:51:58 -0700 Subject: [PATCH 4/4] fix(tables): align workflow group drop indicators --- .../[tableId]/components/table-grid/table-grid.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index a87ebde559a..c2a2e36e3a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -1850,7 +1850,7 @@ export function TableGrid({ return } - const columnName = header.dataset.columnDragTarget + let columnName = header.dataset.columnDragTarget if (!columnName) { handleColumnDragLeave() return @@ -1859,6 +1859,15 @@ export function TableGrid({ const targetGroupId = header.dataset.columnDragGroup let { left, right } = header.getBoundingClientRect() if (targetGroupId) { + const targetColumn = columnsRef.current.find((column) => column.key === columnName) + const groupStart = targetColumn + ? columnsRef.current[targetColumn.groupStartColIndex] + : undefined + if (!groupStart || groupStart.workflowGroupId !== targetGroupId) { + throw new Error(`Missing rendered start column for workflow group ${targetGroupId}`) + } + columnName = groupStart.key + const groupHeaders = thead.querySelectorAll('th[data-column-drag-group]') for (const groupHeader of groupHeaders) { if (groupHeader.dataset.columnDragGroup !== targetGroupId) continue