Skip to content

Commit

Permalink
rename selectionAndAnchor to selection for simplicity
Browse files Browse the repository at this point in the history
  • Loading branch information
severo committed Jan 20, 2025
1 parent 7396902 commit 6aea297
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 220 deletions.
91 changes: 45 additions & 46 deletions src/HighTable.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { ReactNode, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { DataFrame, Row, asyncRows } from './dataframe.js'
import { SelectionAndAnchor, areAllSelected, extendFromAnchor, isSelected, toggleAll, toggleIndex } from './selection.js'
import { Selection, areAllSelected, extendFromAnchor, isSelected, toggleAll, toggleIndex } from './selection.js'
import { OrderBy } from './sort.js'
import TableHeader, { cellStyle } from './TableHeader.js'
export { AsyncRow, DataFrame, ResolvablePromise, Row, arrayDataFrame, asyncRows, awaitRow, awaitRows, resolvablePromise, resolvableRow, sortableDataFrame, wrapPromise } from './dataframe.js'
export { rowCache } from './rowCache.js'
export { SelectionAndAnchor } from './selection.js'
export type { Selection } from './selection.js'
export { Selection } from './selection.js'
export { OrderBy } from './sort.js'
export { HighTable }

Expand Down Expand Up @@ -75,8 +74,8 @@ export interface TableProps {
onError?: (error: Error) => void
orderBy?: OrderBy // order by column. If undefined, the table is unordered, the sort elements are hidden and the interactions are disabled.
onOrderByChange?: (orderBy: OrderBy) => void // callback to call when a user interaction changes the order. The interactions are disabled if undefined.
selectionAndAnchor?: SelectionAndAnchor // selection and anchor rows. If undefined, the selection is hidden and the interactions are disabled.
onSelectionAndAnchorChange?: (selectionAndAnchor: SelectionAndAnchor) => void // callback to call when a user interaction changes the selection. The interactions are disabled if undefined.
selection?: Selection // selection and anchor rows. If undefined, the selection is hidden and the interactions are disabled.
onSelectionChange?: (selection: Selection) => void // callback to call when a user interaction changes the selection. The interactions are disabled if undefined.
}

function rowLabel(rowIndex?: number): string {
Expand All @@ -101,8 +100,8 @@ export default function HighTable({
focus = true,
orderBy: propOrderBy,
onOrderByChange: propOnOrderByChange,
selectionAndAnchor: propSelection,
onSelectionAndAnchorChange: propOnSelectionAndAnchorChange,
selection: propSelection,
onSelectionChange: propOnSelectionChange,
onDoubleClickCell,
onMouseDownCell,
onError = console.error,
Expand Down Expand Up @@ -173,76 +172,76 @@ export default function HighTable({

/**
* Four modes:
* - controlled (selection and onSelectionAndAnchorChange are defined): the parent controls the selection and receives the user interactions. No local state.
* - controlled read-only (selection is defined, onSelectionAndAnchorChange is undefined): the parent controls the selection and the user interactions are disabled. No local state.
* - uncontrolled (selection is undefined, onSelectionAndAnchorChange is defined): the component controls the selection and the user interactions. Local state.
* - disabled (selection and onSelectionAndAnchorChange are undefined): the selection is hidden and the user interactions are disabled. No local state.
* - controlled (selection and onSelectionChange are defined): the parent controls the selection and receives the user interactions. No local state.
* - controlled read-only (selection is defined, onSelectionChange is undefined): the parent controls the selection and the user interactions are disabled. No local state.
* - uncontrolled (selection is undefined, onSelectionChange is defined): the component controls the selection and the user interactions. Local state.
* - disabled (selection and onSelectionChange are undefined): the selection is hidden and the user interactions are disabled. No local state.
*/
const [initialSelection] = useState<SelectionAndAnchor | undefined>(propSelection)
const [localSelection, setLocalSelection] = useState<SelectionAndAnchor | undefined>({ selection: [], anchor: undefined })
const [initialSelection] = useState<Selection | undefined>(propSelection)
const [localSelection, setLocalSelection] = useState<Selection | undefined>({ ranges: [], anchor: undefined })
const isSelectionControlled = initialSelection !== undefined
const enableSelectionInteractions = propOnSelectionAndAnchorChange !== undefined
let selectionAndAnchor: SelectionAndAnchor | undefined
const enableSelectionInteractions = propOnSelectionChange !== undefined
let selection: Selection | undefined
if (isSelectionControlled) {
if (propSelection === undefined) {
console.warn('The component selection is controlled (is has no local state) because "selection" was initially defined. "selection" cannot be set to undefined now (it is set back to the initial value).')
selectionAndAnchor = initialSelection
selection = initialSelection
} else {
selectionAndAnchor = propSelection
selection = propSelection
}
} else {
if (propSelection !== undefined) {
console.warn('The component selection is uncontrolled (it only has a local state) because "selection" was initially undefined. "selection" cannot be set to a value now and is ignored.')
}
if (propOnSelectionAndAnchorChange === undefined) {
// The component selection is disabled because onSelectionAndAnchorChange is undefined. If you want to enable selection, you must provide onSelectionAndAnchorChange
selectionAndAnchor = undefined
if (propOnSelectionChange === undefined) {
// The component selection is disabled because onSelectionChange is undefined. If you want to enable selection, you must provide onSelectionChange
selection = undefined
} else {
selectionAndAnchor = localSelection
selection = localSelection
}
}
const onSelectionAndAnchorChange = useCallback((selectionAndAnchor: SelectionAndAnchor) => {
const onSelectionChange = useCallback((selection: Selection) => {
if (!enableSelectionInteractions) {
return
}
propOnSelectionAndAnchorChange?.(selectionAndAnchor)
propOnSelectionChange?.(selection)
if (!isSelectionControlled) {
setLocalSelection(selectionAndAnchor)
setLocalSelection(selection)
}
}, [propOnSelectionAndAnchorChange, isSelectionControlled, enableSelectionInteractions])
}, [propOnSelectionChange, isSelectionControlled, enableSelectionInteractions])

const showSelection = selectionAndAnchor !== undefined
const showSelection = selection !== undefined
const showSelectionControls = showSelection && enableSelectionInteractions
const getOnSelectAllRows = useCallback(() => {
if (!selectionAndAnchor || !onSelectionAndAnchorChange) return
const { selection } = selectionAndAnchor
return () => onSelectionAndAnchorChange({
selection: toggleAll({ selection, length: data.numRows }),
if (!selection || !onSelectionChange) return
const { ranges } = selection
return () => onSelectionChange({
ranges: toggleAll({ ranges, length: data.numRows }),
anchor: undefined,
})
}, [onSelectionAndAnchorChange, data.numRows, selectionAndAnchor])
}, [onSelectionChange, data.numRows, selection])
const getOnSelectRowClick = useCallback((tableIndex: number) => {
if (!selectionAndAnchor || !onSelectionAndAnchorChange) return
const { selection, anchor } = selectionAndAnchor
if (!selection || !onSelectionChange) return
const { ranges, anchor } = selection
return (event: React.MouseEvent) => {
const useAnchor = event.shiftKey && selectionAndAnchor.anchor !== undefined
const useAnchor = event.shiftKey && selection.anchor !== undefined
if (useAnchor) {
onSelectionAndAnchorChange({ selection: extendFromAnchor({ selection, anchor, index: tableIndex }), anchor })
onSelectionChange({ ranges: extendFromAnchor({ ranges, anchor, index: tableIndex }), anchor })
} else {
onSelectionAndAnchorChange({ selection: toggleIndex({ selection, index: tableIndex }), anchor: tableIndex })
onSelectionChange({ ranges: toggleIndex({ ranges, index: tableIndex }), anchor: tableIndex })
}
}
}, [onSelectionAndAnchorChange, selectionAndAnchor])
}, [onSelectionChange, selection])
const allRowsSelected = useMemo(() => {
if (!selectionAndAnchor) return false
const { selection } = selectionAndAnchor
return areAllSelected({ selection, length: data.numRows })
}, [selectionAndAnchor, data.numRows])
if (!selection) return false
const { ranges } = selection
return areAllSelected({ ranges, length: data.numRows })
}, [selection, data.numRows])
const isRowSelected = useCallback((tableIndex: number) => {
if (!selectionAndAnchor) return undefined
const { selection } = selectionAndAnchor
return isSelected({ selection, index: tableIndex })
}, [selectionAndAnchor])
if (!selection) return undefined
const { ranges } = selection
return isSelected({ ranges, index: tableIndex })
}, [selection])

const offsetTopRef = useRef(0)

Expand All @@ -261,7 +260,7 @@ export default function HighTable({
dispatch({ type: 'DATA_CHANGED', data })
// if uncontrolled, reset the selection (otherwise, it's the responsibility of the parent to do it if the data changes)
if (!isSelectionControlled) {
onSelectionAndAnchorChange?.({ selection: [], anchor: undefined })
onSelectionChange?.({ ranges: [], anchor: undefined })
}
}

Expand Down
116 changes: 58 additions & 58 deletions src/selection.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/**
* A selection is an array of ordered and non-overlapping ranges.
* A selection is modelled as an array of ordered and non-overlapping ranges.
* The ranges are separated, ie. the end of one range is strictly less than the start of the next range.
*/
export type Selection = Array<Range>
export type Ranges = Array<Range>

export interface SelectionAndAnchor {
selection: Selection // rows selection. The values are indexes of the virtual table (sorted rows), and thus depend on the order.
export interface Selection {
ranges: Ranges // rows selection. The values are indexes of the virtual table (sorted rows), and thus depend on the order.
anchor?: number // anchor row used as a reference for shift+click selection. It's a virtual table index (sorted), and thus depends on the order.
}

Expand All @@ -24,162 +24,162 @@ export function isValidRange(range: Range): boolean {
&& range.end > range.start
}

export function isValidSelection(selection: Selection): boolean {
if (selection.length === 0) {
export function areValidRanges(ranges: Ranges): boolean {
if (ranges.length === 0) {
return true
}
if (selection.some(range => !isValidRange(range))) {
if (ranges.some(range => !isValidRange(range))) {
return false
}
for (let i = 0; i < selection.length - 1; i++) {
if (selection[i].end >= selection[i + 1].start) {
for (let i = 0; i < ranges.length - 1; i++) {
if (ranges[i].end >= ranges[i + 1].start) {
return false
}
}
return true
}

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

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

export function toggleAll({ selection, length }: { selection: Selection, length: number }): Selection {
if (!isValidSelection(selection)) {
throw new Error('Invalid selection')
export function toggleAll({ ranges, length }: { ranges: Ranges, length: number }): Ranges {
if (!areValidRanges(ranges)) {
throw new Error('Invalid ranges')
}
if (length && !isValidIndex(length)) {
throw new Error('Invalid length')
}
if (areAllSelected({ selection, length })) {
if (areAllSelected({ ranges, 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')
export function selectRange({ ranges, range }: { ranges: Ranges, range: Range }): Ranges {
if (!areValidRanges(ranges)) {
throw new Error('Invalid ranges')
}
if (!isValidRange(range)) {
throw new Error('Invalid range')
}
const newSelection: Selection = []
const newRanges: Ranges = []
const { start, end } = range
let rangeIndex = 0

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

// 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)
while (rangeIndex < ranges.length && ranges[rangeIndex].start <= end) {
range.start = Math.min(range.start, ranges[rangeIndex].start)
range.end = Math.max(range.end, ranges[rangeIndex].end)
rangeIndex++
}
newSelection.push(range)
newRanges.push(range)

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

return newSelection
return newRanges
}

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

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

// 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 })
while (rangeIndex < ranges.length && ranges[rangeIndex].start < end) {
if (ranges[rangeIndex].start < start) {
newRanges.push({ start: ranges[rangeIndex].start, end: start })
}
if (selection[rangeIndex].end > end) {
newSelection.push({ start: end, end: selection[rangeIndex].end })
if (ranges[rangeIndex].end > end) {
newRanges.push({ start: end, end: ranges[rangeIndex].end })
}
rangeIndex++
}

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

return newSelection
return newRanges
}

/**
* 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')
export function extendFromAnchor({ ranges, anchor, index }: { ranges: Ranges, anchor?: number, index: number }): Ranges {
if (!areValidRanges(ranges)) {
throw new Error('Invalid ranges')
}
if (anchor === undefined) {
// no anchor to start the range, no operation
return selection
return ranges
}
if (!isValidIndex(anchor) || !isValidIndex(index)) {
throw new Error('Invalid index')
}
if (anchor === index) {
// no operation
return selection
return ranges
}
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 })) {
if (isSelected({ ranges, index: anchor })) {
// select the rest of the range
return selectRange({ selection, range })
return selectRange({ ranges, range })
} else {
// unselect the rest of the range
return unselectRange({ selection, range })
return unselectRange({ ranges, range })
}
}

export function toggleIndex({ selection, index }: { selection: Selection, index: number }): Selection {
export function toggleIndex({ ranges, index }: { ranges: Ranges, index: number }): Ranges {
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 })
return isSelected({ ranges, index }) ? unselectRange({ ranges, range }) : selectRange({ ranges, range })
}
Loading

0 comments on commit 6aea297

Please sign in to comment.