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 (
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,6 +459,8 @@ export function WorkflowGroupMetaCell({
return (
| `.
@@ -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,54 +1768,176 @@ 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
+ }
}, [])
- 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
+ const handleColumnDragLeave = useCallback(() => {
+ dropTargetColumnNameRef.current = null
+ setDropTargetColumnName(null)
+ }, [])
+
+ const updateColumnDropTarget = useCallback(
+ (columnName: string, side: 'left' | 'right') => {
+ const dragged = dragColumnNameRef.current
+ if (!dragged) return
- // 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 cols = schemaColumnsRef.current
const draggedGid = cols.find((c) => getColumnId(c) === dragged)?.workflowGroupId
- if (draggedGid && draggedGid === targetGid) {
- if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null)
+ 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 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
+ }
+ }
}
- // 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
+ if (!header) {
+ handleColumnDragLeave()
+ return
+ }
+
+ let columnName = header.dataset.columnDragTarget
+ if (!columnName) {
+ handleColumnDragLeave()
+ return
+ }
+
+ 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
+ const rect = groupHeader.getBoundingClientRect()
+ left = Math.min(left, rect.left)
+ right = Math.max(right, rect.right)
}
}
- // 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
+ updateColumnDropTarget(columnName, pointerX < left + (right - left) / 2 ? 'left' : 'right')
+ }
- if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return
- setDropTargetColumnName(columnName)
- setDropSide(side)
- }, [])
+ 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) {
+ updateColumnDropTargetAtX(currentPointerX)
+ 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 cols = schemaColumnsRef.current
+ const targetCol = cols.find((c) => getColumnId(c) === columnName)
+ if (targetCol?.workflowGroupId) return
+ updateColumnDropTarget(columnName, side)
+ },
+ [updateColumnDropTarget]
+ )
const handleColumnDragEnd = useCallback(() => {
+ stopColumnDragAutoScroll()
const dragged = dragColumnNameRef.current
if (!dragged) {
setDragColumnName(null)
@@ -1945,64 +2072,27 @@ export function TableGrid({
setDragColumnName(null)
setDropTargetColumnName(null)
setDropSide('left')
- }, [])
-
- const handleColumnDragLeave = useCallback(() => {
- dropTargetColumnNameRef.current = null
- setDropTargetColumnName(null)
- }, [])
+ }, [stopColumnDragAutoScroll])
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
- 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
+ if (pinnedColumnsRef.current.includes(draggedName)) {
+ stopColumnDragAutoScroll()
+ } else {
+ startColumnDragAutoScroll(e.clientX)
}
+ updateColumnDropTargetAtX(e.clientX)
}
function handleScrollDrop(e: React.DragEvent) {
e.preventDefault()
+ stopColumnDragAutoScroll()
}
useEffect(() => {
@@ -4275,7 +4365,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 (
@@ -4284,7 +4379,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={
@@ -4353,17 +4449,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 (
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('stays still when pinned columns consume the visible viewport', () => {
+ expect(
+ horizontalEdgeScrollVelocity({
+ pointerX: 100,
+ visibleLeft: 200,
+ visibleRight: 100,
+ hotZone: 48,
+ maxVelocity: 14,
+ })
+ ).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)
+ })
+})
+
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..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
@@ -31,6 +31,43 @@ 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')
+ const visibleWidth = visibleRight - visibleLeft
+ if (visibleWidth <= 0) return 0
+
+ const edgeZone = Math.min(hotZone, visibleWidth / 2)
+
+ const distanceFromLeft = pointerX - visibleLeft
+ if (distanceFromLeft < edgeZone) {
+ const intensity = 1 - Math.max(0, distanceFromLeft) / edgeZone
+ return -Math.ceil(intensity * maxVelocity)
+ }
+
+ const distanceFromRight = visibleRight - pointerX
+ if (distanceFromRight < edgeZone) {
+ const intensity = 1 - Math.max(0, distanceFromRight) / edgeZone
+ 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)
| |