Skip to content

Commit

Permalink
Select rows range with shift (#20)
Browse files Browse the repository at this point in the history
* add selectRange, unselectRange, extendToBound, and refactor toggleIndex

* add ability to select a range with shift+click

* remove unnecessary parentheses

* add missing spaces
  • Loading branch information
severo authored Jan 7, 2025
1 parent 51f7621 commit 2c1e1d2
Show file tree
Hide file tree
Showing 3 changed files with 191 additions and 65 deletions.
31 changes: 23 additions & 8 deletions src/HighTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ReactNode, useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
import { AsyncRow, DataFrame, Row, asyncRows } from './dataframe.js'
import { Selection, areAllSelected, isSelected, toggleAll, toggleIndex } from './selection.js'
import { Selection, areAllSelected, extendFromAnchor, isSelected, toggleAll, toggleIndex } from './selection.js'
import TableHeader, { cellStyle } from './TableHeader.js'
export {
AsyncRow,
Expand Down Expand Up @@ -42,6 +42,7 @@ type State = {
rows: AsyncRow[]
orderBy?: string
selection: Selection
anchor?: number // anchor row index for selection, the first element when selecting a range
}

type Action =
Expand All @@ -50,7 +51,7 @@ type Action =
| { type: 'SET_COLUMN_WIDTHS', columnWidths: Array<number | undefined> }
| { type: 'SET_ORDER', orderBy: string | undefined }
| { type: 'DATA_CHANGED' }
| { type: 'SET_SELECTION', selection: Selection }
| { type: 'SET_SELECTION', selection: Selection, anchor?: number }

function reducer(state: State, action: Action): State {
switch (action.type) {
Expand All @@ -73,13 +74,13 @@ function reducer(state: State, action: Action): State {
if (state.orderBy === action.orderBy) {
return state
} else {
return { ...state, orderBy: action.orderBy, rows: [], selection: [] }
return { ...state, orderBy: action.orderBy, rows: [], selection: [], anchor: undefined }
}
}
case 'DATA_CHANGED':
return { ...state, invalidate: true, hasCompleteRow: false, selection: [] }
return { ...state, invalidate: true, hasCompleteRow: false, selection: [], anchor: undefined }
case 'SET_SELECTION':
return { ...state, selection: action.selection }
return { ...state, selection: action.selection, anchor: action.anchor }
default:
return state
}
Expand Down Expand Up @@ -111,7 +112,7 @@ export default function HighTable({
}: TableProps) {
const [state, dispatch] = useReducer(reducer, initialState)

const { columnWidths, startIndex, rows, orderBy, invalidate, hasCompleteRow, selection } = state
const { anchor, columnWidths, startIndex, rows, orderBy, invalidate, hasCompleteRow, selection } = state
const offsetTopRef = useRef(0)

const scrollRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -277,6 +278,20 @@ export default function HighTable({
/// TODO(SL): improve rows typing
}, [rows, startIndex])


const onRowNumberClick = useCallback(({ useAnchor, index }: {useAnchor: boolean, index: number}) => {
if (!selectable) return false
if (useAnchor) {
const newSelection = extendFromAnchor({ selection, anchor, index })
// did not throw: we can set the anchor (keep the same)
dispatch({ type: 'SET_SELECTION', selection: newSelection, anchor })
} else {
const newSelection = toggleIndex({ selection, index })
// did not throw: we can set the anchor
dispatch({ type: 'SET_SELECTION', selection: newSelection, anchor: index })
}
}, [selection, anchor])

// add empty pre and post rows to fill the viewport
const prePadding = Array.from({ length: Math.min(padding, startIndex) }, () => [])
const postPadding = Array.from({
Expand Down Expand Up @@ -320,7 +335,7 @@ export default function HighTable({
)}
{rows.map((row, rowIndex) =>
<tr key={startIndex + rowIndex} title={rowError(row, rowIndex)} className={isSelected({ selection, index: rowNumber(rowIndex) }) ? 'selected' : ''}>
<td style={cornerStyle} onClick={() => selectable && dispatch({ type: 'SET_SELECTION', selection: toggleIndex({ selection, index: rowNumber(rowIndex) }) })}>
<td style={cornerStyle} onClick={event => onRowNumberClick({ useAnchor: event.shiftKey, index: rowNumber(rowIndex) })}>
<span>{rowNumber(rowIndex).toLocaleString()}</span>
<input type='checkbox' checked={isSelected({ selection, index: rowNumber(rowIndex) })} />
</td>
Expand All @@ -340,7 +355,7 @@ export default function HighTable({
</table>
</div>
</div>
<div className='table-corner' style={cornerStyle} onClick={() => selectable && dispatch({ type: 'SET_SELECTION', selection: toggleAll({ selection, length: rows.length }) })}>
<div className='table-corner' style={cornerStyle} onClick={() => selectable && dispatch({ type: 'SET_SELECTION', selection: toggleAll({ selection, length: rows.length }), anchor: undefined })}>
<span>&nbsp;</span>
<input type='checkbox' checked={areAllSelected({ selection, length: rows.length })} />
</div>
Expand Down
158 changes: 102 additions & 56 deletions src/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,62 +34,63 @@ export function isValidSelection(selection: Selection): boolean {
return true
}

export function toggleIndex({ selection, index }: {selection: Selection, index: number}): Selection {
export function isSelected({ selection, index }: { selection: Selection, index: number }): boolean {
if (!isValidIndex(index)) {
throw new Error('Invalid index')
}
if (!isValidSelection(selection)) {
throw new Error('Invalid selection')
}
return selection.some(range => range.start <= index && index < range.end)
}

if (selection.length === 0) {
return [{ start: index, end: index + 1 }]
export function areAllSelected({ selection, length }: { selection: Selection, length: number }): boolean {
if (!isValidSelection(selection)) {
throw new Error('Invalid selection')
}
if (length && !isValidIndex(length)) {
throw new Error('Invalid length')
}
return selection.length === 1 && selection[0].start === 0 && selection[0].end === length
}

export function toggleAll({ selection, length }: { selection: Selection, length: number }): Selection {
if (!isValidSelection(selection)) {
throw new Error('Invalid selection')
}
if (length && !isValidIndex(length)) {
throw new Error('Invalid length')
}
if (areAllSelected({ selection, length })) {
return []
}
return [{ start: 0, end: length }]
}

export function selectRange({ selection, range }: { selection: Selection, range: Range }): Selection {
if (!isValidSelection(selection)) {
throw new Error('Invalid selection')
}
if (!isValidRange(range)) {
throw new Error('Invalid range')
}
const newSelection: Selection = []
const { start, end } = range
let rangeIndex = 0

// copy the ranges before the index
while (rangeIndex < selection.length && selection[rangeIndex].end < index) {
// copy the ranges before the new range
while (rangeIndex < selection.length && selection[rangeIndex].end < start) {
newSelection.push({ ...selection[rangeIndex] })
rangeIndex++
}

if (rangeIndex < selection.length && selection[rangeIndex].start <= index + 1) {
// the index affects one or two ranges
const { start, end } = selection[rangeIndex]
if (start === index + 1) {
// prepend the range with the index
newSelection.push({ start: index, end })
} else if (end === index) {
// two cases:
if (rangeIndex + 1 < selection.length && selection[rangeIndex + 1].start === index + 1) {
// merge with following range
newSelection.push({ start, end: selection[rangeIndex + 1].end })
rangeIndex ++ // remove the following range
} else {
// extend the range to the index
newSelection.push({ start, end: index + 1 })
}
} else {
// the index is inside the range, and must be removed
if (start === index) {
if (end > index + 1) {
newSelection.push({ start: index + 1, end })
}
// else: the range is removed
} else if (end === index + 1) {
newSelection.push({ start, end: index })
} else {
newSelection.push({ start, end: index })
newSelection.push({ start: index + 1, end })
}
}
// merge with the new range
while (rangeIndex < selection.length && selection[rangeIndex].start <= end) {
range.start = Math.min(range.start, selection[rangeIndex].start)
range.end = Math.max(range.end, selection[rangeIndex].end)
rangeIndex++
} else {
// insert a new range for the index
newSelection.push({ start: index, end: index + 1 })
}
newSelection.push(range)

// copy the remaining ranges
while (rangeIndex < selection.length) {
Expand All @@ -100,35 +101,80 @@ export function toggleIndex({ selection, index }: {selection: Selection, index:
return newSelection
}

export function isSelected({ selection, index }: {selection: Selection, index: number}): boolean {
if (!isValidIndex(index)) {
throw new Error('Invalid index')
}
export function unselectRange({ selection, range }: { selection: Selection, range: Range }): Selection {
if (!isValidSelection(selection)) {
throw new Error('Invalid selection')
}
return selection.some(range => range.start <= index && index < range.end)
}
if (!isValidRange(range)) {
throw new Error('Invalid range')
}
const newSelection: Selection = []
const { start, end } = range
let rangeIndex = 0

export function areAllSelected({ selection, length }: { selection: Selection, length: number }): boolean {
if (!isValidSelection(selection)) {
throw new Error('Invalid selection')
// copy the ranges before the new range
while (rangeIndex < selection.length && selection[rangeIndex].end < start) {
newSelection.push({ ...selection[rangeIndex] })
rangeIndex++
}
if (length && !isValidIndex(length)) {
throw new Error('Invalid length')

// split the ranges intersecting with the new range
while (rangeIndex < selection.length && selection[rangeIndex].start < end) {
if (selection[rangeIndex].start < start) {
newSelection.push({ start: selection[rangeIndex].start, end: start })
}
if (selection[rangeIndex].end > end) {
newSelection.push({ start: end, end: selection[rangeIndex].end })
}
rangeIndex++
}
return selection.length === 1 && selection[0].start === 0 && selection[0].end === length

// copy the remaining ranges
while (rangeIndex < selection.length) {
newSelection.push({ ...selection[rangeIndex] })
rangeIndex++
}

return newSelection
}

export function toggleAll({ selection, length }: { selection: Selection, length: number }): Selection {
/**
* Extend selection state from anchor to index (selecting or unselecting the range).
* Both bounds are inclusive.
* It will handle the shift+click behavior. anchor is the first index clicked, index is the last index clicked.
*/
export function extendFromAnchor({ selection, anchor, index }: { selection: Selection, anchor?: number, index: number }): Selection {
if (!isValidSelection(selection)) {
throw new Error('Invalid selection')
}
if (length && !isValidIndex(length)) {
throw new Error('Invalid length')
if (anchor === undefined) {
// no anchor to start the range, no operation
return selection
}
if (areAllSelected({ selection, length })) {
return []
if (!isValidIndex(anchor) || !isValidIndex(index)) {
throw new Error('Invalid index')
}
return [{ start: 0, end: length }]
if (anchor === index) {
// no operation
return selection
}
const range = anchor < index ? { start: anchor, end: index + 1 } : { start: index, end: anchor + 1 }
if (!isValidRange(range)) {
throw new Error('Invalid range')
}
if (isSelected({ selection, index: anchor })) {
// select the rest of the range
return selectRange({ selection, range })
} else {
// unselect the rest of the range
return unselectRange({ selection, range })
}
}

export function toggleIndex({ selection, index }: { selection: Selection, index: number }): Selection {
if (!isValidIndex(index)) {
throw new Error('Invalid index')
}
const range = { start: index, end: index + 1 }
return isSelected({ selection, index }) ? unselectRange({ selection, range }) : selectRange({ selection, range })
}
67 changes: 66 additions & 1 deletion test/selection.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest'
import { areAllSelected, isSelected, isValidIndex, isValidRange, isValidSelection, toggleAll, toggleIndex } from '../src/selection.js'
import { areAllSelected, extendFromAnchor, isSelected, isValidIndex, isValidRange, isValidSelection, selectRange, toggleAll, toggleIndex, unselectRange } from '../src/selection.js'

describe('an index', () => {
test('is a positive integer', () => {
Expand Down Expand Up @@ -143,3 +143,68 @@ describe('toggleAll', () => {
expect(() => toggleAll({ selection: [], length: -1 })).toThrow('Invalid length')
})
})

describe('selectRange', () => {
test('should throw an error if the range is invalid', () => {
expect(() => selectRange({ selection: [], range: { start: -1, end: 0 } })).toThrow('Invalid range')
})
test('should throw an error if the selection is invalid', () => {
expect(() => selectRange({ selection: [{ start: 1, end: 0 }], range: { start: -1, end: 0 } })).toThrow('Invalid selection')
})
test('should add a new range if outside and separated from existing ranges', () => {
expect(selectRange({ selection: [], range: { start: 0, end: 1 } })).toEqual([{ start: 0, end: 1 }])
expect(selectRange({ selection: [{ start: 0, end: 1 }, { start: 4, end: 5 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }])
})
test('should merge with the previous and/or following ranges if adjacent', () => {
expect(selectRange({ selection: [{ start: 0, end: 1 }], range: { start: 1, end: 2 } })).toEqual([{ start: 0, end: 2 }])
expect(selectRange({ selection: [{ start: 1, end: 2 }], range: { start: 0, end: 1 } })).toEqual([{ start: 0, end: 2 }])
expect(selectRange({ selection: [{ start: 0, end: 1 }, { start: 2, end: 3 }], range: { start: 1, end: 2 } })).toEqual([{ start: 0, end: 3 }])
})
})

describe('unselectRange', () => {
test('should throw an error if the range is invalid', () => {
expect(() => unselectRange({ selection: [], range: { start: -1, end: 0 } })).toThrow('Invalid range')
})
test('should throw an error if the selection is invalid', () => {
expect(() => unselectRange({ selection: [{ start: 1, end: 0 }], range: { start: -1, end: 0 } })).toThrow('Invalid selection')
})
test('should remove the range if it exists', () => {
expect(unselectRange({ selection: [{ start: 0, end: 1 }], range: { start: 0, end: 1 } })).toEqual([])
expect(unselectRange({ selection: [{ start: 0, end: 1 }, { start: 2, end: 3 }], range: { start: 0, end: 1 } })).toEqual([{ start: 2, end: 3 }])
expect(unselectRange({ selection: [{ start: 0, end: 1 }, { start: 2, end: 3 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }])
})
test('should split the range if it is inside', () => {
expect(unselectRange({ selection: [{ start: 0, end: 3 }], range: { start: 1, end: 2 } })).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }])
})
test('should do nothing if the range does not intersect with the selection', () => {
expect(unselectRange({ selection: [{ start: 0, end: 1 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }])
expect(unselectRange({ selection: [{ start: 0, end: 1 }, { start: 4, end: 5 }], range: { start: 2, end: 3 } })).toEqual([{ start: 0, end: 1 }, { start: 4, end: 5 }])
})
})

describe('extendFromAnchor', () => {
test('should throw an error if the selection is invalid', () => {
expect(() => extendFromAnchor({ selection: [{ start: 1, end: 0 }], anchor: 0, index: 1 })).toThrow('Invalid selection')
})
test('does nothing if the anchor is undefined', () => {
expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], index: 1 })).toEqual([{ start: 0, end: 1 }])
})
test('does nothing if the anchor and the index are the same', () => {
expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], anchor: 0, index: 0 })).toEqual([{ start: 0, end: 1 }])
})
test('should throw an error if the anchor or the index are invalid', () => {
expect(() => extendFromAnchor({ selection: [], anchor: -1, index: 0 })).toThrow('Invalid index')
expect(() => extendFromAnchor({ selection: [], anchor: 0, index: -1 })).toThrow('Invalid index')
})
test('should select the range between the bounds (inclusive) if anchor was selected', () => {
expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], anchor: 0, index: 1 })).toEqual([{ start: 0, end: 2 }])
expect(extendFromAnchor({ selection: [{ start: 1, end: 2 }], anchor: 1, index: 0 })).toEqual([{ start: 0, end: 2 }])
expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }, { start: 3, end: 4 }], anchor: 0, index: 5 })).toEqual([{ start: 0, end: 6 }])
})
test('should unselect the range between the bounds (inclusive) if anchor was not selected', () => {
expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], anchor: 2, index: 3 })).toEqual([{ start: 0, end: 1 }])
expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }], anchor: 2, index: 0 })).toEqual([])
expect(extendFromAnchor({ selection: [{ start: 0, end: 1 }, { start: 3, end: 4 }], anchor: 2, index: 3 })).toEqual([{ start: 0, end: 1 }])
})
})

0 comments on commit 2c1e1d2

Please sign in to comment.