Summary: This diff adds a new menu option to the Logs plugin which allows for enabling/disabling the automatic filtering of search terms in the search history as user types. Additionally, fixed bug regarding the search term that's used is not actually showing correctly. This is caused by the search value being set in the input search bar instead of the autoComplete component level Differential Revision: D37555209 fbshipit-source-id: 8a76299f7e1a4b0a3adfa6a4dd883d17f1e7a442
753 lines
21 KiB
TypeScript
753 lines
21 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @format
|
|
*/
|
|
|
|
import type {DataTableColumn} from './DataTable';
|
|
import {Percentage} from '../../utils/widthUtils';
|
|
import {MutableRefObject, Reducer} from 'react';
|
|
import {DataSource, DataSourceVirtualizer} from '../../data-source/index';
|
|
import produce, {castDraft, immerable, original} from 'immer';
|
|
import {theme} from '../theme';
|
|
|
|
export type OnColumnResize = (id: string, size: number | Percentage) => void;
|
|
export type Sorting<T = any> = {
|
|
key: keyof T;
|
|
direction: Exclude<SortDirection, undefined>;
|
|
};
|
|
export type SearchHighlightSetting = {
|
|
highlightEnabled: boolean;
|
|
color: string;
|
|
};
|
|
|
|
export type SortDirection = 'asc' | 'desc' | undefined;
|
|
|
|
export type Selection = {items: ReadonlySet<number>; current: number};
|
|
|
|
const emptySelection: Selection = {
|
|
items: new Set(),
|
|
current: -1,
|
|
};
|
|
|
|
const MAX_HISTORY = 1000;
|
|
|
|
type PersistedState = {
|
|
/** Active search value */
|
|
search: string;
|
|
useRegex: boolean;
|
|
filterSearchHistory: boolean;
|
|
/** current selection, describes the index index in the datasources's current output (not window!) */
|
|
selection: {current: number; items: number[]};
|
|
/** The currently applicable sorting, if any */
|
|
sorting: Sorting | undefined;
|
|
/** The default columns, but normalized */
|
|
columns: Pick<
|
|
DataTableColumn,
|
|
'key' | 'width' | 'filters' | 'visible' | 'inversed'
|
|
>[];
|
|
scrollOffset: number;
|
|
autoScroll: boolean;
|
|
searchHistory: string[];
|
|
highlightSearchSetting: SearchHighlightSetting;
|
|
};
|
|
|
|
type Action<Name extends string, Args = {}> = {type: Name} & Args;
|
|
|
|
type DataManagerActions<T> =
|
|
/** Reset the current table preferences, including column widths an visibility, back to the default */
|
|
| Action<'reset'>
|
|
/** Disable the current column filters */
|
|
| Action<'resetFilters'>
|
|
/** Resizes the column with the given key to the given width */
|
|
| Action<'resizeColumn', {column: keyof T; width: number | Percentage}>
|
|
/** Sort by the given column. This toggles statefully between ascending, descending, none (insertion order of the data source) */
|
|
| Action<'sortColumn', {column: keyof T; direction: SortDirection}>
|
|
/** Show / hide the given column */
|
|
| Action<'toggleColumnVisibility', {column: keyof T}>
|
|
| Action<'setSearchValue', {value: string; addToHistory: boolean}>
|
|
| Action<
|
|
'selectItem',
|
|
{
|
|
nextIndex: number | ((currentIndex: number) => number);
|
|
addToSelection?: boolean;
|
|
allowUnselect?: boolean;
|
|
}
|
|
>
|
|
| Action<
|
|
'selectItemById',
|
|
{
|
|
id: string;
|
|
addToSelection?: boolean;
|
|
}
|
|
>
|
|
| Action<
|
|
'addRangeToSelection',
|
|
{
|
|
start: number;
|
|
end: number;
|
|
allowUnselect?: boolean;
|
|
}
|
|
>
|
|
| Action<'clearSelection', {}>
|
|
/** Changing column filters */
|
|
| Action<
|
|
'addColumnFilter',
|
|
{column: keyof T; value: string; disableOthers?: boolean}
|
|
>
|
|
| Action<'removeColumnFilter', {column: keyof T; index: number}>
|
|
| Action<'toggleColumnFilter', {column: keyof T; index: number}>
|
|
| Action<'setColumnFilterInverse', {column: keyof T; inversed: boolean}>
|
|
| Action<'setColumnFilterFromSelection', {column: keyof T}>
|
|
| Action<'appliedInitialScroll'>
|
|
| Action<'toggleUseRegex'>
|
|
| Action<'toggleAutoScroll'>
|
|
| Action<'setAutoScroll', {autoScroll: boolean}>
|
|
| Action<'toggleSearchValue'>
|
|
| Action<'clearSearchHistory'>
|
|
| Action<'toggleHighlightSearch'>
|
|
| Action<'setSearchHighlightColor', {color: string}>
|
|
| Action<'toggleFilterSearchHistory'>;
|
|
|
|
type DataManagerConfig<T> = {
|
|
dataSource: DataSource<T, T[keyof T]>;
|
|
defaultColumns: DataTableColumn<T>[];
|
|
scope: string;
|
|
onSelect: undefined | ((item: T | undefined, items: T[]) => void);
|
|
virtualizerRef: MutableRefObject<DataSourceVirtualizer | undefined>;
|
|
autoScroll?: boolean;
|
|
enablePersistSettings?: boolean;
|
|
};
|
|
|
|
export type DataManagerState<T> = {
|
|
config: DataManagerConfig<T>;
|
|
usesWrapping: boolean;
|
|
storageKey: string;
|
|
initialOffset: number;
|
|
columns: DataTableColumn[];
|
|
sorting: Sorting<T> | undefined;
|
|
selection: Selection;
|
|
useRegex: boolean;
|
|
filterSearchHistory: boolean;
|
|
autoScroll: boolean;
|
|
searchValue: string;
|
|
/** Used to remember the record entry to lookup when user presses ctrl */
|
|
previousSearchValue: string;
|
|
searchHistory: string[];
|
|
highlightSearchSetting: SearchHighlightSetting;
|
|
};
|
|
|
|
export type DataTableReducer<T> = Reducer<
|
|
DataManagerState<T>,
|
|
DataManagerActions<T>
|
|
>;
|
|
export type DataTableDispatch<T = any> = React.Dispatch<DataManagerActions<T>>;
|
|
|
|
export const dataTableManagerReducer = produce<
|
|
DataManagerState<any>,
|
|
[DataManagerActions<any>]
|
|
>(function (draft, action) {
|
|
const config = original(draft.config)!;
|
|
switch (action.type) {
|
|
case 'reset': {
|
|
draft.columns = computeInitialColumns(config.defaultColumns);
|
|
draft.sorting = undefined;
|
|
draft.searchValue = '';
|
|
draft.selection = castDraft(emptySelection);
|
|
break;
|
|
}
|
|
case 'resetFilters': {
|
|
draft.columns.forEach((c) =>
|
|
c.filters?.forEach((f) => (f.enabled = false)),
|
|
);
|
|
draft.searchValue = '';
|
|
break;
|
|
}
|
|
case 'resizeColumn': {
|
|
const {column, width} = action;
|
|
const col = draft.columns.find((c) => c.key === column)!;
|
|
col.width = width;
|
|
break;
|
|
}
|
|
case 'sortColumn': {
|
|
const {column, direction} = action;
|
|
if (direction === undefined) {
|
|
draft.sorting = undefined;
|
|
} else {
|
|
draft.sorting = {key: column, direction};
|
|
}
|
|
break;
|
|
}
|
|
case 'toggleColumnVisibility': {
|
|
const {column} = action;
|
|
const col = draft.columns.find((c) => c.key === column)!;
|
|
col.visible = !col.visible;
|
|
break;
|
|
}
|
|
case 'setSearchValue': {
|
|
draft.searchValue = action.value;
|
|
draft.previousSearchValue = '';
|
|
if (
|
|
action.addToHistory &&
|
|
action.value &&
|
|
!draft.searchHistory.includes(action.value)
|
|
) {
|
|
draft.searchHistory.unshift(action.value);
|
|
// FIFO if history too large
|
|
if (draft.searchHistory.length > MAX_HISTORY) {
|
|
draft.searchHistory.length = MAX_HISTORY;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 'toggleSearchValue': {
|
|
if (draft.searchValue) {
|
|
draft.previousSearchValue = draft.searchValue;
|
|
draft.searchValue = '';
|
|
} else {
|
|
draft.searchValue = draft.previousSearchValue;
|
|
draft.previousSearchValue = '';
|
|
}
|
|
break;
|
|
}
|
|
case 'clearSearchHistory': {
|
|
draft.searchHistory = [];
|
|
break;
|
|
}
|
|
case 'toggleUseRegex': {
|
|
draft.useRegex = !draft.useRegex;
|
|
break;
|
|
}
|
|
case 'toggleFilterSearchHistory': {
|
|
draft.filterSearchHistory = !draft.filterSearchHistory;
|
|
break;
|
|
}
|
|
case 'selectItem': {
|
|
const {nextIndex, addToSelection, allowUnselect} = action;
|
|
draft.selection = castDraft(
|
|
computeSetSelection(
|
|
draft.selection,
|
|
nextIndex,
|
|
addToSelection,
|
|
allowUnselect,
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
case 'selectItemById': {
|
|
const {id, addToSelection} = action;
|
|
// TODO: fix that this doesn't jumpt selection if items are shifted! sorting is swapped etc
|
|
const idx = config.dataSource.getIndexOfKey(id);
|
|
if (idx !== -1) {
|
|
draft.selection = castDraft(
|
|
computeSetSelection(draft.selection, idx, addToSelection),
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case 'addRangeToSelection': {
|
|
const {start, end, allowUnselect} = action;
|
|
draft.selection = castDraft(
|
|
computeAddRangeToSelection(draft.selection, start, end, allowUnselect),
|
|
);
|
|
break;
|
|
}
|
|
case 'clearSelection': {
|
|
draft.selection = castDraft(emptySelection);
|
|
break;
|
|
}
|
|
case 'addColumnFilter': {
|
|
addColumnFilter(
|
|
draft.columns,
|
|
action.column,
|
|
action.value,
|
|
action.disableOthers,
|
|
);
|
|
break;
|
|
}
|
|
case 'removeColumnFilter': {
|
|
draft.columns
|
|
.find((c) => c.key === action.column)!
|
|
.filters?.splice(action.index, 1);
|
|
break;
|
|
}
|
|
case 'toggleColumnFilter': {
|
|
const f = draft.columns.find((c) => c.key === action.column)!.filters![
|
|
action.index
|
|
];
|
|
f.enabled = !f.enabled;
|
|
break;
|
|
}
|
|
case 'setColumnFilterInverse': {
|
|
draft.columns.find((c) => c.key === action.column)!.inversed =
|
|
action.inversed;
|
|
break;
|
|
}
|
|
case 'setColumnFilterFromSelection': {
|
|
const items = getSelectedItems(
|
|
config.dataSource as DataSource<any>,
|
|
draft.selection,
|
|
);
|
|
items.forEach((item, index) => {
|
|
addColumnFilter(
|
|
draft.columns,
|
|
action.column,
|
|
getValueAtPath(item, String(action.column)),
|
|
index === 0, // remove existing filters before adding the first
|
|
);
|
|
});
|
|
break;
|
|
}
|
|
case 'appliedInitialScroll': {
|
|
draft.initialOffset = 0;
|
|
break;
|
|
}
|
|
case 'toggleAutoScroll': {
|
|
draft.autoScroll = !draft.autoScroll;
|
|
break;
|
|
}
|
|
case 'setAutoScroll': {
|
|
draft.autoScroll = action.autoScroll;
|
|
break;
|
|
}
|
|
case 'toggleHighlightSearch': {
|
|
draft.highlightSearchSetting.highlightEnabled =
|
|
!draft.highlightSearchSetting.highlightEnabled;
|
|
break;
|
|
}
|
|
case 'setSearchHighlightColor': {
|
|
if (draft.highlightSearchSetting.color !== action.color) {
|
|
draft.highlightSearchSetting.color = action.color;
|
|
}
|
|
break;
|
|
}
|
|
default: {
|
|
throw new Error('Unknown action ' + (action as any).type);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Public only imperative convienience API for DataTable
|
|
*/
|
|
export type DataTableManager<T> = {
|
|
reset(): void;
|
|
resetFilters(): void;
|
|
selectItem(
|
|
index: number | ((currentSelection: number) => number),
|
|
addToSelection?: boolean,
|
|
allowUnselect?: boolean,
|
|
): void;
|
|
addRangeToSelection(
|
|
start: number,
|
|
end: number,
|
|
allowUnselect?: boolean,
|
|
): void;
|
|
selectItemById(id: string, addToSelection?: boolean): void;
|
|
clearSelection(): void;
|
|
getSelectedItem(): T | undefined;
|
|
getSelectedItems(): readonly T[];
|
|
toggleColumnVisibility(column: keyof T): void;
|
|
sortColumn(column: keyof T, direction?: SortDirection): void;
|
|
setSearchValue(value: string, addToHistory?: boolean): void;
|
|
dataSource: DataSource<T, T[keyof T]>;
|
|
toggleSearchValue(): void;
|
|
toggleHighlightSearch(): void;
|
|
setSearchHighlightColor(color: string): void;
|
|
};
|
|
|
|
export function createDataTableManager<T>(
|
|
dataSource: DataSource<T, T[keyof T]>,
|
|
dispatch: DataTableDispatch<T>,
|
|
stateRef: MutableRefObject<DataManagerState<T>>,
|
|
): DataTableManager<T> {
|
|
return {
|
|
reset() {
|
|
dispatch({type: 'reset'});
|
|
},
|
|
resetFilters() {
|
|
dispatch({type: 'resetFilters'});
|
|
},
|
|
selectItem(index: number, addToSelection = false, allowUnselect = false) {
|
|
dispatch({
|
|
type: 'selectItem',
|
|
nextIndex: index,
|
|
addToSelection,
|
|
allowUnselect,
|
|
});
|
|
},
|
|
selectItemById(id, addToSelection = false) {
|
|
dispatch({type: 'selectItemById', id, addToSelection});
|
|
},
|
|
addRangeToSelection(start, end, allowUnselect = false) {
|
|
dispatch({type: 'addRangeToSelection', start, end, allowUnselect});
|
|
},
|
|
clearSelection() {
|
|
dispatch({type: 'clearSelection'});
|
|
},
|
|
getSelectedItem() {
|
|
return getSelectedItem(dataSource, stateRef.current.selection);
|
|
},
|
|
getSelectedItems() {
|
|
return getSelectedItems(dataSource, stateRef.current.selection);
|
|
},
|
|
toggleColumnVisibility(column) {
|
|
dispatch({type: 'toggleColumnVisibility', column});
|
|
},
|
|
sortColumn(column, direction) {
|
|
dispatch({type: 'sortColumn', column, direction});
|
|
},
|
|
setSearchValue(value, addToHistory = false) {
|
|
dispatch({type: 'setSearchValue', value, addToHistory});
|
|
},
|
|
toggleSearchValue() {
|
|
dispatch({type: 'toggleSearchValue'});
|
|
},
|
|
toggleHighlightSearch() {
|
|
dispatch({type: 'toggleHighlightSearch'});
|
|
},
|
|
setSearchHighlightColor(color) {
|
|
dispatch({type: 'setSearchHighlightColor', color});
|
|
},
|
|
dataSource,
|
|
};
|
|
}
|
|
|
|
export function createInitialState<T>(
|
|
config: DataManagerConfig<T>,
|
|
): DataManagerState<T> {
|
|
// by default a table is considered to be identical if plugins, and default column names are the same
|
|
const storageKey = `${config.scope}:DataTable:${config.defaultColumns
|
|
.map((c) => c.key)
|
|
.join(',')}`;
|
|
const prefs = config.enablePersistSettings
|
|
? loadStateFromStorage(storageKey)
|
|
: undefined;
|
|
let initialColumns = computeInitialColumns(config.defaultColumns);
|
|
if (prefs) {
|
|
// merge prefs with the default column config
|
|
initialColumns = produce(initialColumns, (draft) => {
|
|
prefs.columns.forEach((pref) => {
|
|
const existing = draft.find((c) => c.key === pref.key);
|
|
if (existing) {
|
|
Object.assign(existing, pref);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
const res: DataManagerState<T> = {
|
|
config,
|
|
storageKey,
|
|
initialOffset: prefs?.scrollOffset ?? 0,
|
|
usesWrapping: config.defaultColumns.some((col) => col.wrap),
|
|
columns: initialColumns,
|
|
sorting: prefs?.sorting,
|
|
selection: prefs?.selection
|
|
? {
|
|
current: prefs!.selection.current,
|
|
items: new Set(prefs!.selection.items),
|
|
}
|
|
: emptySelection,
|
|
searchValue: prefs?.search ?? '',
|
|
previousSearchValue: '',
|
|
searchHistory: prefs?.searchHistory ?? [],
|
|
useRegex: prefs?.useRegex ?? false,
|
|
filterSearchHistory: prefs?.filterSearchHistory ?? true,
|
|
autoScroll: prefs?.autoScroll ?? config.autoScroll ?? false,
|
|
highlightSearchSetting: prefs?.highlightSearchSetting ?? {
|
|
highlightEnabled: false,
|
|
color: theme.searchHighlightBackground.yellow,
|
|
},
|
|
};
|
|
// @ts-ignore
|
|
res.config[immerable] = false; // optimization: never proxy anything in config
|
|
Object.freeze(res.config);
|
|
return res;
|
|
}
|
|
|
|
function addColumnFilter<T>(
|
|
columns: DataTableColumn<T>[],
|
|
columnId: keyof T,
|
|
value: string,
|
|
disableOthers: boolean = false,
|
|
): void {
|
|
const column = columns.find((c) => c.key === columnId)!;
|
|
const filterValue = String(value).toLowerCase();
|
|
const existing = column.filters!.find((c) => c.value === filterValue);
|
|
if (existing) {
|
|
existing.enabled = true;
|
|
} else {
|
|
column.filters!.push({
|
|
label: String(value),
|
|
value: filterValue,
|
|
enabled: true,
|
|
});
|
|
}
|
|
if (disableOthers) {
|
|
column.filters!.forEach((c) => {
|
|
if (c.value !== filterValue) {
|
|
c.enabled = false;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export function getSelectedItem<T>(
|
|
dataSource: DataSource<T, T[keyof T]>,
|
|
selection: Selection,
|
|
): T | undefined {
|
|
return selection.current < 0
|
|
? undefined
|
|
: dataSource.view.get(selection.current);
|
|
}
|
|
|
|
export function getSelectedItems<T>(
|
|
dataSource: DataSource<T, T[keyof T]>,
|
|
selection: Selection,
|
|
): T[] {
|
|
return [...selection.items]
|
|
.sort((a, b) => a - b) // https://stackoverflow.com/a/15765283/1983583
|
|
.map((i) => dataSource.view.get(i))
|
|
.filter(Boolean) as any[];
|
|
}
|
|
|
|
export function savePreferences(
|
|
state: DataManagerState<any>,
|
|
scrollOffset: number,
|
|
) {
|
|
if (!state.config.scope || !state.config.enablePersistSettings) {
|
|
return;
|
|
}
|
|
const prefs: PersistedState = {
|
|
search: state.searchValue,
|
|
useRegex: state.useRegex,
|
|
filterSearchHistory: state.filterSearchHistory,
|
|
selection: {
|
|
current: state.selection.current,
|
|
items: Array.from(state.selection.items),
|
|
},
|
|
sorting: state.sorting,
|
|
columns: state.columns.map((c) => ({
|
|
key: c.key,
|
|
width: c.width,
|
|
filters: c.filters,
|
|
visible: c.visible,
|
|
inversed: c.inversed,
|
|
})),
|
|
scrollOffset,
|
|
autoScroll: state.autoScroll,
|
|
searchHistory: state.searchHistory,
|
|
highlightSearchSetting: state.highlightSearchSetting,
|
|
};
|
|
localStorage.setItem(state.storageKey, JSON.stringify(prefs));
|
|
}
|
|
|
|
function loadStateFromStorage(storageKey: string): PersistedState | undefined {
|
|
if (!storageKey) {
|
|
return undefined;
|
|
}
|
|
const state = localStorage.getItem(storageKey);
|
|
if (!state) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return JSON.parse(state) as PersistedState;
|
|
} catch (e) {
|
|
// forget about this state
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function computeInitialColumns(
|
|
columns: DataTableColumn<any>[],
|
|
): DataTableColumn<any>[] {
|
|
const visibleColumnCount = columns.filter((c) => c.visible !== false).length;
|
|
const columnsWithoutWidth = columns.filter(
|
|
(c) => c.visible !== false && c.width === undefined,
|
|
).length;
|
|
|
|
return columns.map((c) => ({
|
|
...c,
|
|
width:
|
|
c.width ??
|
|
// if the width is not set, and there are multiple columns with unset widths,
|
|
// there will be multiple columns ith the same flex weight (1), meaning that
|
|
// they will all resize a best fits in a specifc row.
|
|
// To address that we distribute space equally
|
|
// (this need further fine tuning in the future as with a subset of fixed columns width can become >100%)
|
|
(columnsWithoutWidth > 1
|
|
? `${Math.floor(100 / visibleColumnCount)}%`
|
|
: undefined),
|
|
filters:
|
|
c.filters?.map((f) => ({
|
|
...f,
|
|
predefined: true,
|
|
})) ?? [],
|
|
visible: c.visible !== false,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* A somewhat primitive and unsafe way to access nested fields an object.
|
|
* @param obj keys should only be strings
|
|
* @param keyPath dotted string path, e.g foo.bar
|
|
* @returns value at the key path
|
|
*/
|
|
|
|
export function getValueAtPath(obj: Record<string, any>, keyPath: string): any {
|
|
let res = obj;
|
|
for (const key of keyPath.split('.')) {
|
|
if (res == null) {
|
|
return null;
|
|
} else {
|
|
res = res[key];
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
export function computeDataTableFilter(
|
|
searchValue: string,
|
|
useRegex: boolean,
|
|
columns: DataTableColumn[],
|
|
) {
|
|
const searchString = searchValue.toLowerCase();
|
|
const searchRegex = useRegex ? safeCreateRegExp(searchValue) : undefined;
|
|
// the columns with an active filter are those that have filters defined,
|
|
// with at least one enabled
|
|
const filteringColumns = columns.filter((c) =>
|
|
c.filters?.some((f) => f.enabled),
|
|
);
|
|
|
|
if (searchValue === '' && !filteringColumns.length) {
|
|
// unset
|
|
return undefined;
|
|
}
|
|
|
|
return function dataTableFilter(item: any) {
|
|
for (const column of filteringColumns) {
|
|
const rowMatchesFilter = column.filters!.some(
|
|
(f) =>
|
|
f.enabled &&
|
|
String(getValueAtPath(item, column.key))
|
|
.toLowerCase()
|
|
.includes(f.value),
|
|
);
|
|
if (column.inversed && rowMatchesFilter) {
|
|
return false;
|
|
}
|
|
if (!column.inversed && !rowMatchesFilter) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
//free search all top level keys as well as any (nested) columns in the table
|
|
const nestedColumns = columns
|
|
.map((col) => col.key)
|
|
.filter((path) => path.includes('.'));
|
|
|
|
return [...Object.keys(item), ...nestedColumns]
|
|
.map((key) => getValueAtPath(item, key))
|
|
.filter((val) => typeof val !== 'object')
|
|
.some((v) => {
|
|
return searchRegex
|
|
? searchRegex.test(String(v))
|
|
: String(v).toLowerCase().includes(searchString);
|
|
});
|
|
};
|
|
}
|
|
|
|
export function safeCreateRegExp(source: string): RegExp | undefined {
|
|
try {
|
|
return new RegExp(source);
|
|
} catch (_e) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function computeSetSelection(
|
|
base: Selection,
|
|
nextIndex: number | ((currentIndex: number) => number),
|
|
addToSelection?: boolean,
|
|
allowUnselect?: boolean,
|
|
): Selection {
|
|
const newIndex =
|
|
typeof nextIndex === 'number' ? nextIndex : nextIndex(base.current);
|
|
// special case: toggle existing selection off
|
|
if (
|
|
!addToSelection &&
|
|
allowUnselect &&
|
|
base.items.size === 1 &&
|
|
base.current === newIndex
|
|
) {
|
|
return emptySelection;
|
|
}
|
|
if (newIndex < 0) {
|
|
return emptySelection;
|
|
}
|
|
if (base.current < 0 || !addToSelection) {
|
|
return {
|
|
current: newIndex,
|
|
items: new Set([newIndex]),
|
|
};
|
|
} else {
|
|
const lowest = Math.min(base.current, newIndex);
|
|
const highest = Math.max(base.current, newIndex);
|
|
return {
|
|
current: newIndex,
|
|
items: addIndicesToMultiSelection(base.items, lowest, highest),
|
|
};
|
|
}
|
|
}
|
|
|
|
export function computeAddRangeToSelection(
|
|
base: Selection,
|
|
start: number,
|
|
end: number,
|
|
allowUnselect?: boolean,
|
|
): Selection {
|
|
// special case: unselectiong a single item with the selection
|
|
if (start === end && allowUnselect) {
|
|
if (base?.items.has(start)) {
|
|
const copy = new Set(base.items);
|
|
copy.delete(start);
|
|
const current = [...copy];
|
|
if (current.length === 0) {
|
|
return emptySelection;
|
|
}
|
|
return {
|
|
items: copy,
|
|
current: current[current.length - 1], // back to the last selected one
|
|
};
|
|
}
|
|
// intentional fall-through
|
|
}
|
|
|
|
// N.B. start and end can be reverted if selecting backwards
|
|
const lowest = Math.min(start, end);
|
|
const highest = Math.max(start, end);
|
|
const current = end;
|
|
|
|
return {
|
|
items: addIndicesToMultiSelection(base.items, lowest, highest),
|
|
current,
|
|
};
|
|
}
|
|
|
|
function addIndicesToMultiSelection(
|
|
base: ReadonlySet<number>,
|
|
lowest: number,
|
|
highest: number,
|
|
): ReadonlySet<number> {
|
|
const copy = new Set(base);
|
|
for (let i = lowest; i <= highest; i++) {
|
|
copy.add(i);
|
|
}
|
|
return copy;
|
|
}
|