Keep Kanban selection active while scrolling (#2390)
This commit is contained in:
parent
1be56a53d1
commit
580e264e21
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue