Keep Kanban selection active while scrolling (#2390)

This commit is contained in:
Neil 2026-05-19 20:51:03 -07:00 committed by GitHub
parent 1be56a53d1
commit 580e264e21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 405 additions and 9 deletions

View File

@ -138,7 +138,10 @@ export default function WorkspaceKanbanStatusLane({
</Tooltip>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-2 scrollbar-sleek">
<div
data-workspace-board-lane-scroll=""
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-2 scrollbar-sleek"
>
{items.length > 0 ? (
<div className="space-y-2">
{items.map((worktree) => {

View File

@ -1,9 +1,13 @@
/* eslint-disable max-lines -- Why: marquee selection coordinates pointer capture, lane-scroll refresh, auto-scroll, preview cleanup, and final commit against one drag state. Splitting those phases would make the interaction easier to desynchronize. */
import React, { useCallback, useEffect, useRef } from 'react'
import {
clearPreviewSelection,
getAreaSelectionAutoScrollDelta,
getAreaSelectionCardIds,
getAreaSelectionCardRects,
getAreaSelectionRect,
getAreaSelectionScrollContainer,
getAreaSelectionScrollStartContentYByElement,
isScrollbarPointerDown,
setOverlayRect,
shouldIgnoreAreaSelectionStart,
@ -21,10 +25,12 @@ type AreaSelectionDragState = {
baseAnchorId: string | null
boardRect: DOMRect
cardRects: readonly AreaSelectionCardRect[]
scrollStartContentYByElement: ReadonlyMap<HTMLElement, number>
previewIds: Set<string>
finalAreaIds: string[]
started: boolean
frameId: number | null
scrollFrameId: number | null
}
type UpdateSelectionForArea = (
@ -79,6 +85,9 @@ export function useWorkspaceKanbanAreaSelection({
if (state?.frameId !== null && state?.frameId !== undefined) {
window.cancelAnimationFrame(state.frameId)
}
if (state?.scrollFrameId !== null && state?.scrollFrameId !== undefined) {
window.cancelAnimationFrame(state.scrollFrameId)
}
if (state) {
clearPreviewSelection(state.cardRects, state.previewIds)
}
@ -132,7 +141,10 @@ export function useWorkspaceKanbanAreaSelection({
height: clippedBottom - clippedTop
})
const areaIds = getAreaSelectionCardIds(state.cardRects, viewportRect)
const areaIds = getAreaSelectionCardIds(state.cardRects, viewportRect, {
scrollStartContentYByElement: state.scrollStartContentYByElement,
currentY: state.currentY
})
state.finalAreaIds = areaIds
updatePreviewSelection(
state.cardRects,
@ -143,6 +155,18 @@ export function useWorkspaceKanbanAreaSelection({
)
}, [overlayRef])
const refreshAreaSelectionMeasurements = useCallback(() => {
const state = dragRef.current
const board = boardRef.current
if (!state || !board) {
return
}
clearPreviewSelection(state.cardRects, state.previewIds)
state.boardRect = board.getBoundingClientRect()
state.cardRects = getAreaSelectionCardRects(board)
}, [boardRef])
const scheduleAreaSelectionDragFlush = useCallback(() => {
const state = dragRef.current
if (!state || state.frameId !== null) {
@ -153,6 +177,46 @@ export function useWorkspaceKanbanAreaSelection({
state.frameId = window.requestAnimationFrame(flushAreaSelectionDrag)
}, [flushAreaSelectionDrag])
const runAreaSelectionAutoScroll = useCallback(() => {
const state = dragRef.current
const board = boardRef.current
if (!state || !board) {
return
}
state.scrollFrameId = null
const scrollContainer = getAreaSelectionScrollContainer(board, state.currentX, state.currentY)
if (!scrollContainer) {
return
}
const rect = scrollContainer.getBoundingClientRect()
const scrollDelta = getAreaSelectionAutoScrollDelta({
pointerY: state.currentY,
containerTop: rect.top,
containerBottom: rect.bottom,
scrollTop: scrollContainer.scrollTop,
scrollHeight: scrollContainer.scrollHeight,
clientHeight: scrollContainer.clientHeight
})
if (scrollDelta === 0) {
return
}
scrollContainer.scrollTop += scrollDelta
refreshAreaSelectionMeasurements()
scheduleAreaSelectionDragFlush()
state.scrollFrameId = window.requestAnimationFrame(runAreaSelectionAutoScroll)
}, [boardRef, refreshAreaSelectionMeasurements, scheduleAreaSelectionDragFlush])
const scheduleAreaSelectionAutoScroll = useCallback(() => {
const state = dragRef.current
if (!state || state.scrollFrameId !== null) {
return
}
state.scrollFrameId = window.requestAnimationFrame(runAreaSelectionAutoScroll)
}, [runAreaSelectionAutoScroll])
const finishAreaSelectionDrag = useCallback(
(event: PointerEvent) => {
const state = dragRef.current
@ -165,6 +229,10 @@ export function useWorkspaceKanbanAreaSelection({
window.cancelAnimationFrame(state.frameId)
state.frameId = null
}
if (state.scrollFrameId !== null) {
window.cancelAnimationFrame(state.scrollFrameId)
state.scrollFrameId = null
}
flushAreaSelectionDrag()
if (shouldCommitWorkspaceKanbanAreaSelection(state)) {
updateSelectionForAreaRef.current(
@ -211,10 +279,15 @@ export function useWorkspaceKanbanAreaSelection({
baseAnchorId: selectionAnchorId,
boardRect: board.getBoundingClientRect(),
cardRects: getAreaSelectionCardRects(board),
scrollStartContentYByElement: getAreaSelectionScrollStartContentYByElement(
board,
event.clientY
),
previewIds: new Set(),
finalAreaIds: [],
started: false,
frameId: null
frameId: null,
scrollFrameId: null
}
event.preventDefault()
},
@ -236,6 +309,7 @@ export function useWorkspaceKanbanAreaSelection({
state.currentY = event.clientY
event.preventDefault()
scheduleAreaSelectionDragFlush()
scheduleAreaSelectionAutoScroll()
}
const handlePointerUp = (event: PointerEvent): void => {
@ -246,16 +320,43 @@ export function useWorkspaceKanbanAreaSelection({
finishAreaSelectionDrag(event)
}
const handleScroll = (event: Event): void => {
const state = dragRef.current
if (!state) {
return
}
const board = boardRef.current
const target = event.target
if (board && target instanceof Node && !board.contains(target)) {
return
}
// Why: lane scrolling changes every card's viewport rect while the drag
// is still active. Refresh before the next hit-test so selection follows
// the scrolled content instead of stale pointer-down measurements.
refreshAreaSelectionMeasurements()
scheduleAreaSelectionDragFlush()
}
document.addEventListener('pointermove', handlePointerMove, true)
document.addEventListener('pointerup', handlePointerUp, true)
document.addEventListener('pointercancel', handlePointerUp, true)
document.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('pointermove', handlePointerMove, true)
document.removeEventListener('pointerup', handlePointerUp, true)
document.removeEventListener('pointercancel', handlePointerUp, true)
document.removeEventListener('scroll', handleScroll, true)
cancelAreaSelectionDrag()
}
}, [cancelAreaSelectionDrag, finishAreaSelectionDrag, open, scheduleAreaSelectionDragFlush])
}, [
boardRef,
cancelAreaSelectionDrag,
finishAreaSelectionDrag,
open,
refreshAreaSelectionMeasurements,
scheduleAreaSelectionAutoScroll,
scheduleAreaSelectionDragFlush
])
return { handleAreaSelectionPointerDown }
}

View File

@ -9,9 +9,37 @@ export type AreaSelectionCardRect = {
id: string
element: HTMLElement
rect: DOMRect
scrollContainer: HTMLElement | null
contentRect: AreaSelectionCardContentRect | null
}
type AreaSelectionCardContentRect = {
top: number
bottom: number
containerTop: number
scrollTop: number
}
type AreaSelectionAutoScrollParams = {
pointerY: number
containerTop: number
containerBottom: number
scrollTop: number
scrollHeight: number
clientHeight: number
edgeSize?: number
maxDelta?: number
}
type AreaSelectionCardIdOptions = {
scrollStartContentYByElement?: ReadonlyMap<HTMLElement, number>
currentY?: number
}
const AREA_SELECTED_ATTR = 'data-workspace-board-card-area-selected'
export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]'
export const AREA_SELECTION_AUTO_SCROLL_EDGE_SIZE = 48
export const AREA_SELECTION_AUTO_SCROLL_MAX_DELTA = 22
export function getAreaSelectionRect(
startX: number,
@ -68,13 +96,37 @@ export function isScrollbarPointerDown(
export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] {
const cardRects: AreaSelectionCardRect[] = []
const seen = new Set<string>()
const scrollMetrics = new Map<HTMLElement, { containerTop: number; scrollTop: number }>()
const cards = board.querySelectorAll<HTMLElement>('[data-workspace-board-card-id]')
for (const card of cards) {
const id = card.dataset.workspaceBoardCardId
if (!id || seen.has(id)) {
continue
}
cardRects.push({ id, element: card, rect: card.getBoundingClientRect() })
const rect = card.getBoundingClientRect()
const scrollContainer = card.closest<HTMLElement>(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR)
let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined
if (scrollContainer && !metrics) {
metrics = {
containerTop: scrollContainer.getBoundingClientRect().top,
scrollTop: scrollContainer.scrollTop
}
scrollMetrics.set(scrollContainer, metrics)
}
cardRects.push({
id,
element: card,
rect,
scrollContainer,
contentRect: metrics
? {
top: rect.top - metrics.containerTop + metrics.scrollTop,
bottom: rect.bottom - metrics.containerTop + metrics.scrollTop,
containerTop: metrics.containerTop,
scrollTop: metrics.scrollTop
}
: null
})
seen.add(id)
}
return cardRects
@ -82,22 +134,112 @@ export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCard
export function getAreaSelectionCardIds(
cardRects: readonly AreaSelectionCardRect[],
selectionRect: AreaSelectionRect
selectionRect: AreaSelectionRect,
options: AreaSelectionCardIdOptions = {}
): string[] {
const ids: string[] = []
for (const card of cardRects) {
if (
const horizontalHit =
selectionRect.left <= card.rect.right &&
selectionRect.left + selectionRect.width >= card.rect.left &&
selectionRect.left + selectionRect.width >= card.rect.left
if (!horizontalHit) {
continue
}
const startContentY = card.scrollContainer
? options.scrollStartContentYByElement?.get(card.scrollContainer)
: undefined
let verticalHit =
selectionRect.top <= card.rect.bottom &&
selectionRect.top + selectionRect.height >= card.rect.top
) {
if (startContentY !== undefined && card.contentRect && options.currentY !== undefined) {
const currentContentY =
options.currentY - card.contentRect.containerTop + card.contentRect.scrollTop
// Why: during lane scroll, viewport Y changes but the marquee range is
// anchored to the content positions the user dragged across.
verticalHit =
Math.min(startContentY, currentContentY) <= card.contentRect.bottom &&
Math.max(startContentY, currentContentY) >= card.contentRect.top
}
if (verticalHit) {
ids.push(card.id)
}
}
return ids
}
export function getAreaSelectionScrollStartContentYByElement(
board: HTMLElement,
pointerY: number
): Map<HTMLElement, number> {
const startContentYByElement = new Map<HTMLElement, number>()
const containers = board.querySelectorAll<HTMLElement>(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR)
for (const element of containers) {
const rect = element.getBoundingClientRect()
startContentYByElement.set(element, pointerY - rect.top + element.scrollTop)
}
return startContentYByElement
}
export function getAreaSelectionAutoScrollDelta({
pointerY,
containerTop,
containerBottom,
scrollTop,
scrollHeight,
clientHeight,
edgeSize = AREA_SELECTION_AUTO_SCROLL_EDGE_SIZE,
maxDelta = AREA_SELECTION_AUTO_SCROLL_MAX_DELTA
}: AreaSelectionAutoScrollParams): number {
const maxScrollTop = Math.max(0, scrollHeight - clientHeight)
if (maxScrollTop <= 0) {
return 0
}
const topDistance = containerTop + edgeSize - pointerY
if (topDistance > 0 && scrollTop > 0) {
const ratio = Math.min(1, topDistance / edgeSize)
return -Math.min(scrollTop, Math.max(1, Math.ceil(ratio * maxDelta)))
}
const bottomDistance = pointerY - (containerBottom - edgeSize)
if (bottomDistance > 0 && scrollTop < maxScrollTop) {
const ratio = Math.min(1, bottomDistance / edgeSize)
return Math.min(maxScrollTop - scrollTop, Math.max(1, Math.ceil(ratio * maxDelta)))
}
return 0
}
export function getAreaSelectionScrollContainer(
board: HTMLElement,
pointerX: number,
pointerY: number
): HTMLElement | null {
const containers = board.querySelectorAll<HTMLElement>(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR)
let nearest: { element: HTMLElement; distance: number } | null = null
for (const element of containers) {
const rect = element.getBoundingClientRect()
if (pointerX < rect.left || pointerX > rect.right) {
continue
}
const distance =
pointerY < rect.top
? rect.top - pointerY
: pointerY > rect.bottom
? pointerY - rect.bottom
: 0
if (distance > AREA_SELECTION_AUTO_SCROLL_EDGE_SIZE * 2) {
continue
}
if (!nearest || distance < nearest.distance) {
nearest = { element, distance }
}
}
return nearest?.element ?? null
}
export function setOverlayRect(overlay: HTMLElement | null, rect: AreaSelectionRect | null): void {
if (!overlay || !rect) {
overlay?.classList.add('hidden')

View File

@ -1,4 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
getAreaSelectionAutoScrollDelta,
getAreaSelectionCardIds,
type AreaSelectionCardRect
} from './workspace-kanban-area-selection-dom'
import { shouldCommitWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection'
describe('workspace kanban area selection finish', () => {
@ -29,3 +34,148 @@ describe('workspace kanban area selection finish', () => {
).toBe(true)
})
})
describe('workspace kanban area selection auto-scroll', () => {
it('scrolls down near the bottom edge while more lane content is available', () => {
expect(
getAreaSelectionAutoScrollDelta({
pointerY: 585,
containerTop: 100,
containerBottom: 600,
scrollTop: 40,
scrollHeight: 1200,
clientHeight: 500
})
).toBeGreaterThan(0)
})
it('scrolls up near the top edge while content exists above', () => {
expect(
getAreaSelectionAutoScrollDelta({
pointerY: 112,
containerTop: 100,
containerBottom: 600,
scrollTop: 40,
scrollHeight: 1200,
clientHeight: 500
})
).toBeLessThan(0)
})
it('does not scroll when the pointer is away from the edges or at scroll limits', () => {
expect(
getAreaSelectionAutoScrollDelta({
pointerY: 350,
containerTop: 100,
containerBottom: 600,
scrollTop: 40,
scrollHeight: 1200,
clientHeight: 500
})
).toBe(0)
expect(
getAreaSelectionAutoScrollDelta({
pointerY: 585,
containerTop: 100,
containerBottom: 600,
scrollTop: 700,
scrollHeight: 1200,
clientHeight: 500
})
).toBe(0)
})
})
describe('workspace kanban area selection scrolled content hit-testing', () => {
it('keeps cards selected after lane scroll moves them above the viewport marquee', () => {
const scrollContainer = {} as HTMLElement
const cards: AreaSelectionCardRect[] = [
{
id: 'top-card',
element: {} as HTMLElement,
rect: makeRect({ left: 20, top: 20, right: 220, bottom: 70 }),
scrollContainer,
contentRect: {
top: 120,
bottom: 170,
containerTop: 100,
scrollTop: 200
}
},
{
id: 'below-current-pointer',
element: {} as HTMLElement,
rect: makeRect({ left: 20, top: 600, right: 220, bottom: 650 }),
scrollContainer,
contentRect: {
top: 700,
bottom: 750,
containerTop: 100,
scrollTop: 200
}
}
]
expect(
getAreaSelectionCardIds(
cards,
{
left: 0,
top: 230,
width: 260,
height: 350
},
{
scrollStartContentYByElement: new Map([[scrollContainer, 130]]),
currentY: 580
}
)
).toEqual(['top-card'])
})
it('falls back to viewport hit-testing for cards outside lane scrollers', () => {
expect(
getAreaSelectionCardIds(
[
{
id: 'visible-card',
element: {} as HTMLElement,
rect: makeRect({ left: 20, top: 250, right: 220, bottom: 300 }),
scrollContainer: null,
contentRect: null
}
],
{
left: 0,
top: 230,
width: 260,
height: 350
}
)
).toEqual(['visible-card'])
})
})
function makeRect({
left,
top,
right,
bottom
}: {
left: number
top: number
right: number
bottom: number
}): DOMRect {
return {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
x: left,
y: top,
toJSON: () => ({})
} as DOMRect
}