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)