// @ts-strict-ignore import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef, type Dispatch, type ReactElement, type MouseEvent, } from 'react'; import { useSelector } from 'react-redux'; import { type State } from 'loot-core/src/client/state-types'; import { listen } from 'loot-core/src/platform/client/fetch'; import * as undo from 'loot-core/src/platform/client/undo'; import { type UndoState } from 'loot-core/src/server/undo'; import { isNonProductionEnvironment } from 'loot-core/src/shared/environment'; type Range<T> = { start: T; end: T | null }; type Item = { id: string }; function iterateRange(range: Range<number>, func: (i: number) => void): void { const from = Math.min(range.start, range.end); const to = Math.max(range.start, range.end); for (let i = from; i <= to; i++) { func(i); } } type SelectedState = { selectedRange: Range<string> | null; selectedItems: Set<string>; }; type WithOptionalMouseEvent = { event?: MouseEvent; }; type SelectAction = { type: 'select'; id: string; } & WithOptionalMouseEvent; type SelectNoneAction = { type: 'select-none'; } & WithOptionalMouseEvent; type SelectAllAction = { type: 'select-all'; ids?: string[]; } & WithOptionalMouseEvent; type Actions = SelectAction | SelectNoneAction | SelectAllAction; export function useSelected<T extends Item>( name: string, items: T[], initialSelectedIds: string[], selectAllFilter?: (item: T) => boolean, ) { const [state, dispatch] = useReducer( (state: SelectedState, action: Actions) => { switch (action.type) { case 'select': { const { selectedRange } = state; const selectedItems = new Set(state.selectedItems); const { id, event } = action; if (event.shiftKey && selectedRange) { const idx = items.findIndex(p => p.id === id); const startIdx = items.findIndex(p => p.id === selectedRange.start); const endIdx = items.findIndex(p => p.id === selectedRange.end); let range: Range<number>; let deleteUntil: Range<number>; if (endIdx === -1) { range = { start: startIdx, end: idx }; } else if (endIdx < startIdx) { if (idx <= startIdx) { range = { start: startIdx, end: idx }; if (idx > endIdx) { deleteUntil = { start: idx - 1, end: endIdx }; } } else { // Switching directions range = { start: endIdx, end: idx }; } } else { if (idx >= startIdx) { range = { start: startIdx, end: idx }; if (idx < endIdx) { deleteUntil = { start: idx + 1, end: endIdx }; } } else { // Switching directions range = { start: endIdx, end: idx }; } } iterateRange(range, i => selectedItems.add(items[i].id)); if (deleteUntil) { iterateRange(deleteUntil, i => selectedItems.delete(items[i].id)); } return { ...state, selectedItems, selectedRange: { start: items[range.start].id, end: items[range.end].id, }, }; } else { let range = null; if (!selectedItems.delete(id)) { selectedItems.add(id); range = { start: id, end: null }; } return { ...state, selectedItems, selectedRange: range, }; } } case 'select-none': return { ...state, selectedItems: new Set<string>() }; case 'select-all': let selectedItems: string[] = []; if (action.ids && items && selectAllFilter) { const idsToInclude = new Set( items.filter(selectAllFilter).map(item => item.id), ); selectedItems = action.ids.filter(id => idsToInclude.has(id)); } else if (items && selectAllFilter) { selectedItems = items.filter(selectAllFilter).map(item => item.id); } else { selectedItems = action.ids || items.map(item => item.id); } return { ...state, selectedItems: new Set(selectedItems), selectedRange: action.ids && action.ids.length === 1 ? { start: action.ids[0], end: null } : null, }; default: throw new Error('Unexpected action: ' + JSON.stringify(action)); } }, null, () => ({ selectedItems: new Set<string>(initialSelectedIds || []), selectedRange: initialSelectedIds && initialSelectedIds.length === 1 ? { start: initialSelectedIds[0], end: null } : null, }), ); const prevItems = useRef(items); useEffect(() => { if (state.selectedItems.size > 0) { // We need to make sure there are no ids in the selection that // aren't valid anymore. This happens if the item has been // deleted or otherwise removed from the current view. We do // this by cross-referencing the current selection with the // available item ids // // This effect may run multiple times while items is updated, we // need to make sure that we don't remove selected ids until the // items array *actually* changes. A component may render with // new `items` arrays that are the same, just fresh instances, but // we need to wait until the actual array changes. This solves // the case where undo-ing adds back items, but we remove the // selected item too early (because the component rerenders // multiple times) const ids = new Set(items.map(item => item.id)); const isSame = prevItems.current.length === items.length && prevItems.current.every(item => ids.has(item.id)); if (!isSame) { const selected = [...state.selectedItems]; const filtered = selected.filter(id => ids.has(id)); // If the selected items has changed, update the selection if (selected.length !== filtered.length) { dispatch({ type: 'select-all', ids: filtered }); } } } prevItems.current = items; }, [items, state.selectedItems]); useEffect(() => { const prevState = undo.getUndoState('selectedItems'); undo.setUndoState('selectedItems', { name, items: state.selectedItems }); return () => undo.setUndoState('selectedItems', prevState); }, [state.selectedItems]); const lastUndoState = useSelector((state: State) => state.app.lastUndoState); useEffect(() => { function onUndo({ messages, undoTag }: UndoState) { const tagged = undo.getTaggedState(undoTag); const deletedIds = new Set( messages .filter(msg => msg.column === 'tombstone' && msg.value === 1) .map(msg => msg.row), ); if (tagged?.selectedItems?.name === name) { dispatch({ type: 'select-all', // Coerce the Set into an array ids: [...tagged.selectedItems.items].filter( id => !deletedIds.has(id), ), }); } } if (lastUndoState && lastUndoState.current) { onUndo(lastUndoState.current); } return listen('undo-event', onUndo); }, []); return { items: state.selectedItems, dispatch, }; } const SelectedDispatch = createContext<(action: Actions) => void>(null); const SelectedItems = createContext<Set<string>>(null); export function useSelectedDispatch() { return useContext(SelectedDispatch); } export function useSelectedItems() { return useContext(SelectedItems); } type SelectedProviderProps<T extends Item> = { instance: ReturnType<typeof useSelected<T>>; fetchAllIds?: () => Promise<string[]>; children: ReactElement; }; export function SelectedProvider<T extends Item>({ instance, fetchAllIds, children, }: SelectedProviderProps<T>) { const latestItems = useRef(null); useEffect(() => { latestItems.current = instance.items; }, [instance.items]); const dispatch = useCallback( async (action: Actions) => { if (!action.event && isNonProductionEnvironment()) { throw new Error('SelectedDispatch actions must have an event'); } if (action.type === 'select-all') { if (latestItems.current && latestItems.current.size > 0) { return instance.dispatch({ type: 'select-none', event: action.event, }); } else { if (fetchAllIds) { return instance.dispatch({ type: 'select-all', ids: await fetchAllIds(), event: action.event, }); } return instance.dispatch({ type: 'select-all', event: action.event }); } } return instance.dispatch(action); }, [instance.dispatch, fetchAllIds], ); return ( <SelectedItems.Provider value={instance.items}> <SelectedDispatch.Provider value={dispatch}> {children} </SelectedDispatch.Provider> </SelectedItems.Provider> ); } type SelectedProviderWithItemsProps<T extends Item> = { name: string; items: T[]; initialSelectedIds: string[]; fetchAllIds: () => Promise<string[]>; registerDispatch?: (dispatch: Dispatch<Actions>) => void; selectAllFilter?: (item: T) => boolean; children: ReactElement; }; // This can be helpful in class components if you cannot use the // custom hook export function SelectedProviderWithItems<T extends Item>({ name, items, initialSelectedIds, fetchAllIds, registerDispatch, selectAllFilter, children, }: SelectedProviderWithItemsProps<T>) { const selected = useSelected<T>( name, items, initialSelectedIds, selectAllFilter, ); useEffect(() => { registerDispatch?.(selected.dispatch); }, [registerDispatch]); return ( <SelectedProvider<T> instance={selected} fetchAllIds={fetchAllIds}> {children} </SelectedProvider> ); }