in packages/boxed-expression-component/src/selection/BeeTableSelectionContext.tsx [226:909]
export function BeeTableSelectionContextProvider({ children }: React.PropsWithChildren<{}>) {
const refs = React.useRef<Map<number, Map<number, Set<BeeTableCellRef>>>>(new Map());
const [_selection, _setSelection] = useState<BeeTableSelection>(NEUTRAL_SELECTION);
const [_currentDepth, _setCurrentDepth] = useState<{ active: number | undefined; max: number }>(
INITIAL_CURRENT_DEPTH
);
const {
isSelectionHere: isParentSelectionThere,
activeCellForNestedTables: parentActiveCell,
currentDepth: parentCurrentDepth,
depth: parentDepth,
} = useBeeTableSelection();
const { setCurrentDepth: setParentCurrentDepth, resetSelectionAt: resetParentSelectionAt } =
useBeeTableSelectionDispatch();
const { containerCellCoordinates } = useBeeTableCoordinates();
//
const depth = parentDepth + 1;
const activeDepth = parentCurrentDepth.active ?? _currentDepth.active;
const activeMaxDepth = Math.max(parentCurrentDepth.max, _currentDepth.max);
const setCurrentDepth = setParentCurrentDepth ?? _setCurrentDepth;
const isSelectionHere = useMemo(() => {
return coincides(parentActiveCell, containerCellCoordinates) && isParentSelectionThere;
}, [containerCellCoordinates, isParentSelectionThere, parentActiveCell]);
const selection = useMemo(() => {
if (depth === activeDepth && isSelectionHere) {
return _selection;
}
return NEUTRAL_SELECTION;
}, [_selection, activeDepth, depth, isSelectionHere]);
const selectionRef = React.useRef<BeeTableSelection>(selection);
useEffect(() => {
selectionRef.current = selection;
}, [selection]);
//
// paste batching strategy (begin)
//
// This is a hack to make React batch the multiple state updates we're doing here with the calls to `setValue`.
// Every call to `setValue` mutates the expression, so batching is essential for performance reasons.
// This effect runs once when pasteData is truthy. Then, after running, it sets pasteData to a falsy value, which short-circuits it.
//
// This can be refactored to be simpler when upgrading to React 18, as batching is automatic, even outside event handlers and hooks.
const [pasteData, setPasteData] = useState("");
useEffect(() => {
if (!pasteData) {
return;
}
const clipboardValue = pasteData;
if (!selectionRef.current?.selectionStart || !selectionRef.current?.selectionEnd) {
return;
}
const clipboardMatrix = clipboardValue
.split(CLIPBOARD_TO_TEXT_ROW_SEPARATOR)
.map((r) => r.split(CLIPBOARD_COLUMN_SEPARATOR));
const { startRow, endRow, startColumn, endColumn } = getSelectionIterationBoundaries(selectionRef.current);
const pasteEndRow = Math.max(endRow, startRow + clipboardMatrix.length - 1);
const pasteEndColumn = Math.max(endColumn, startColumn + clipboardMatrix[0].length - 1);
for (let r = startRow; r <= pasteEndRow; r++) {
for (let c = startColumn; c <= pasteEndColumn; c++) {
refs.current
?.get(r)
?.get(c)
?.forEach((e) => e.setValue?.(clipboardMatrix[r - startRow]?.[c - startColumn]));
}
}
_setSelection({
active: {
rowIndex: startRow,
columnIndex: startColumn,
isEditing: false,
},
selectionStart: {
rowIndex: startRow,
columnIndex: startColumn,
isEditing: false,
},
selectionEnd: {
rowIndex: pasteEndRow,
columnIndex: pasteEndColumn,
isEditing: false,
},
});
setPasteData("");
}, [pasteData]);
// paste batching strategy (end)
const value = useMemo(() => {
return {
activeCell: selection.active,
selectionStart: selection.selectionStart,
selectionEnd: selection.selectionEnd,
activeCellForNestedTables: _selection.active,
currentDepth: {
active: activeDepth,
max: activeMaxDepth,
},
depth,
isSelectionHere,
};
}, [
_selection.active,
activeDepth,
activeMaxDepth,
depth,
isSelectionHere,
selection.active,
selection.selectionEnd,
selection.selectionStart,
]);
const dispatch = useMemo<BeeTableSelectionDispatchContextType>(() => {
return {
setCurrentDepth: (newCurrentDepthAction) => {
setCurrentDepth((prev) => {
const newCurrentDepth =
typeof newCurrentDepthAction === "function"
? newCurrentDepthAction(prev ?? SELECTION_MIN_ACTIVE_DEPTH)
: newCurrentDepthAction;
return {
max: Math.max(SELECTION_MIN_MAX_DEPTH, newCurrentDepth.max),
active: newCurrentDepth.active ?? SELECTION_MIN_ACTIVE_DEPTH,
};
});
},
mutateSelection: ({
part,
columnCount,
rowCount,
deltaColumns,
deltaRows,
isEditingActiveCell,
keepInsideSelection,
}) => {
_setSelection((prev) => {
if (!prev.active) {
return prev;
}
const isExpanded = isSelectionExpanded(prev);
const { startRow, startColumn, endRow, endColumn } = getSelectionIterationBoundaries(prev);
const boundaries =
isExpanded && keepInsideSelection
? {
rows: { min: startRow, max: endRow },
columns: { min: startColumn, max: endColumn },
}
: {
rows: { min: 0, max: rowCount - 1 },
columns: { min: 1, max: columnCount(prev.active.rowIndex) - 1 },
};
const prevCoords =
part === SelectionPart.ActiveCell
? {
rowIndex: prev.active.rowIndex,
columnIndex: prev.active.columnIndex,
}
: part === SelectionPart.SelectionEnd
? {
rowIndex: prev.selectionEnd?.rowIndex,
columnIndex: prev.selectionEnd?.columnIndex,
}
: part === SelectionPart.SelectionStart
? {
rowIndex: prev.selectionStart?.rowIndex,
columnIndex: prev.selectionStart?.columnIndex,
}
: (() => {
throw new Error("Impossible case for SelectionPart");
})();
const newRowIndex =
(prevCoords.rowIndex ?? 0) < 0
? prevCoords.rowIndex ?? 0 // Don't move away from header cells
: Math.min(boundaries.rows.max, Math.max(boundaries.rows.min, (prevCoords.rowIndex ?? 0) + deltaRows));
const newColumnIndex =
prevCoords.columnIndex === 0
? prevCoords.columnIndex // Don't move away from rowIndex cells
: Math.min(
boundaries.columns.max,
Math.max(boundaries.columns.min, (prevCoords.columnIndex ?? 0) + deltaColumns)
);
switch (part) {
case SelectionPart.SelectionEnd:
return {
...prev,
selectionEnd: {
rowIndex: newRowIndex,
columnIndex: newColumnIndex,
isEditing: prev.selectionEnd?.isEditing ?? false,
},
};
case SelectionPart.SelectionStart:
return {
...prev,
selectionStart: {
rowIndex: newRowIndex,
columnIndex: newColumnIndex,
isEditing: prev.selectionStart?.isEditing ?? false,
},
};
case SelectionPart.ActiveCell:
if (!isExpanded || !keepInsideSelection) {
return {
active: {
rowIndex: newRowIndex,
columnIndex: newColumnIndex,
isEditing: isEditingActiveCell,
},
selectionEnd: {
rowIndex: newRowIndex,
columnIndex: newColumnIndex,
isEditing: false,
},
selectionStart: {
rowIndex: newRowIndex,
columnIndex: newColumnIndex,
isEditing: false,
},
};
}
// Wrap-around inside selection
//
// Direction: left-to-right, top-to-bottom
//
// ===============================================
// Enter --> Top-Down, LTR
// Shift + Enter --> Bottom-Up, RTL
// Tab --> LTR, Top-Down
// Shift + Tab --> RTL, Bottom-Up
// ===============================================
const targetRow = prev.active.rowIndex + deltaRows;
const targetColumn = prev.active.columnIndex + deltaColumns;
if (targetRow > boundaries.rows.max) {
const nextColumn = prev.active.columnIndex + 1;
return {
...prev,
active: {
rowIndex: boundaries.rows.min,
columnIndex: nextColumn > boundaries.columns.max ? boundaries.columns.min : nextColumn,
isEditing: isEditingActiveCell,
},
};
} else if (targetColumn < boundaries.columns.min) {
const previousRow = prev.active.rowIndex - 1;
return {
...prev,
active: {
rowIndex: previousRow < boundaries.rows.min ? boundaries.rows.max : previousRow,
columnIndex: boundaries.columns.max,
isEditing: isEditingActiveCell,
},
};
} else if (targetColumn > boundaries.columns.max) {
const nextRow = prev.active.rowIndex + 1;
return {
...prev,
active: {
rowIndex: nextRow > boundaries.rows.max ? boundaries.rows.min : nextRow,
columnIndex: boundaries.columns.min,
isEditing: isEditingActiveCell,
},
};
} else if (targetRow < boundaries.rows.min) {
const previousColumn = prev.active.columnIndex - 1;
return {
...prev,
active: {
rowIndex: boundaries.rows.max,
columnIndex: previousColumn < boundaries.columns.min ? boundaries.columns.max : previousColumn,
isEditing: isEditingActiveCell,
},
};
} else {
return {
...prev,
active: {
rowIndex: newRowIndex,
columnIndex: newColumnIndex,
isEditing: isEditingActiveCell,
},
};
}
default:
assertUnreachable(part);
}
});
},
adaptSelection: ({
atRowIndex,
rowCountDelta,
atColumnIndex,
columnCountDelta,
}: {
atRowIndex: number;
rowCountDelta: number;
atColumnIndex: number;
columnCountDelta: number;
}) => {
_setSelection((prev) => {
if (!prev || !prev.active || !prev.selectionStart || !prev.selectionEnd) {
return prev;
}
let moveRows = 0;
let growRows = 0;
let activeMoveRows = 0;
if (atRowIndex >= 0) {
if (atRowIndex <= prev.selectionStart.rowIndex) {
moveRows = rowCountDelta;
} else if (atRowIndex <= prev.selectionEnd.rowIndex) {
growRows = rowCountDelta;
}
if (atRowIndex <= prev.active.rowIndex) {
activeMoveRows = rowCountDelta;
}
}
let moveColumns = 0;
let growColumns = 0;
let activeMoveColumns = 0;
if (atColumnIndex >= 0) {
if (atColumnIndex <= prev.selectionStart.columnIndex) {
moveColumns = columnCountDelta;
} else if (atColumnIndex <= prev.selectionEnd.columnIndex) {
growColumns = columnCountDelta;
}
if (atColumnIndex <= prev.active.columnIndex) {
activeMoveColumns = columnCountDelta;
}
}
return {
active: {
rowIndex: prev.active.rowIndex + activeMoveRows,
columnIndex: prev.active.columnIndex + activeMoveColumns,
isEditing: prev.active.isEditing,
},
selectionStart: {
rowIndex: prev.selectionStart.rowIndex + moveRows,
columnIndex: prev.selectionStart.columnIndex + moveColumns,
isEditing: prev.selectionStart.isEditing,
},
selectionEnd: {
rowIndex: prev.selectionEnd.rowIndex + moveRows + growRows,
columnIndex: prev.selectionEnd.columnIndex + moveColumns + growColumns,
isEditing: prev.selectionEnd.isEditing,
},
};
});
},
copy: () => {
if (!selectionRef.current?.selectionStart || !selectionRef.current?.selectionEnd) {
return;
}
const clipboardMatrix: string[][] = [];
const { startRow, endRow, startColumn, endColumn } = getSelectionIterationBoundaries(selectionRef.current);
for (let r = startRow; r <= endRow; r++) {
clipboardMatrix[r - startRow] ??= [];
for (let c = startColumn; c <= endColumn; c++) {
clipboardMatrix[r - startRow][c - startColumn] = [...(refs.current?.get(r)?.get(c) ?? [])]
?.flatMap((ref) => (ref.getValue ? [ref.getValue()] : []))
.join(""); // FIXME: What to do? Only one ref should be yielding the content. See https://github.com/apache/incubator-kie-issues/issues/170
}
}
const clipboardValue = clipboardMatrix
.map((r) => r.join(CLIPBOARD_COLUMN_SEPARATOR))
.join(TEXT_TO_CLIPBOARD_ROW_SEPARATOR);
navigator.clipboard.writeText(clipboardValue);
},
cut: () => {
if (!selectionRef.current?.selectionStart || !selectionRef.current?.selectionEnd) {
return;
}
const clipboardMatrix: string[][] = [];
const { startRow, endRow, startColumn, endColumn } = getSelectionIterationBoundaries(selectionRef.current);
for (let r = startRow; r <= endRow; r++) {
clipboardMatrix[r - startRow] ??= [];
for (let c = startColumn; c <= endColumn; c++) {
clipboardMatrix[r - startRow][c - startColumn] = [...(refs.current?.get(r)?.get(c) ?? [])]
?.flatMap((ref) => {
const cellValue = ref.getValue ? [ref.getValue()] : [];
ref.setValue?.(CELL_EMPTY_VALUE);
return cellValue;
})
.join(""); // What to do? Only one ref should be yielding the content. See https://github.com/apache/incubator-kie-issues/issues/170
}
}
const clipboardValue = clipboardMatrix
.map((row) => row.join(CLIPBOARD_COLUMN_SEPARATOR))
.join(TEXT_TO_CLIPBOARD_ROW_SEPARATOR);
navigator.clipboard.writeText(clipboardValue);
},
paste: () => {
navigator.clipboard.readText().then((clipboardValue) => {
setPasteData(clipboardValue);
});
},
erase: () => {
if (!selectionRef.current?.selectionStart || !selectionRef.current?.selectionEnd) {
return;
}
const { startRow, endRow, startColumn, endColumn } = getSelectionIterationBoundaries(selectionRef.current);
for (let r = startRow; r <= endRow; r++) {
for (let c = startColumn; c <= endColumn; c++) {
refs.current
?.get(r)
?.get(c)
?.forEach((ref) => {
ref.setValue?.(CELL_EMPTY_VALUE);
});
}
}
},
resetSelectionAt: (newSelectionAction) => {
resetParentSelectionAt?.({
columnIndex: containerCellCoordinates?.columnIndex ?? 1,
rowIndex: containerCellCoordinates?.rowIndex ?? 0,
isEditing: false,
});
if (!newSelectionAction) {
setCurrentDepth((prev) => ({
max: prev.max,
active: Math.max(SELECTION_MIN_ACTIVE_DEPTH, depth - 1),
}));
return;
}
setCurrentDepth((prev) => ({
max: prev.max,
active: depth,
}));
_setSelection((prev) => {
const newActiveCell =
typeof newSelectionAction === "function" //
? newSelectionAction(prev.active)
: newSelectionAction;
return {
active: newActiveCell,
selectionStart: newActiveCell?.keepSelection ? prev.selectionStart : newActiveCell,
selectionEnd: newActiveCell?.keepSelection ? prev.selectionEnd : newActiveCell,
};
});
},
setSelectionEnd: (newSelectionEndAction) => {
_setSelection((prev) => {
const newSelectionEnd =
typeof newSelectionEndAction === "function"
? newSelectionEndAction(prev.selectionEnd)
: newSelectionEndAction;
// do not change selection if currently a cell is being edited
if (prev.active?.isEditing) {
return prev;
}
// Selecting a header cell from another header cell
// Do not allow selecting multi-line header cells
else if (
(prev.selectionEnd?.rowIndex ?? 0) < 0 &&
(newSelectionEnd?.rowIndex ?? 0) < 0 &&
prev.selectionEnd?.rowIndex !== newSelectionEnd?.rowIndex
) {
return prev;
}
// Selecting a rowIndex cell from a header cell.
// Do not allow selecting rowIndex cells from header cells
else if ((prev.selectionEnd?.rowIndex ?? 0) < 0 && newSelectionEnd?.columnIndex === 0) {
return prev;
}
// Selecting a normal cell from a rowIndex cell
// Do not allow leaving the rowIndex cells
else if (prev.selectionEnd?.columnIndex === 0) {
return {
...prev,
selectionEnd: {
columnIndex: 0,
rowIndex: newSelectionEnd?.rowIndex ?? prev.selectionEnd.rowIndex,
isEditing: false,
},
};
}
// Selecting a normal cell from a header cell
// Do not allow selecting header and normal cells simultaneously
else if ((prev.selectionEnd?.rowIndex ?? 0) < 0) {
return {
...prev,
selectionEnd: {
columnIndex: newSelectionEnd?.columnIndex ?? 0,
rowIndex: prev.selectionEnd?.rowIndex ?? 0,
isEditing: false,
},
};
}
// Selecting a rowIndex cell from a normal cell
// Do not allow selecting rowIndex and normal cells simultaneously
else if (newSelectionEnd?.columnIndex === 0) {
return {
...prev,
selectionEnd: {
columnIndex: 1,
rowIndex: Math.max(0, newSelectionEnd?.rowIndex ?? 0),
isEditing: false,
},
};
}
// Selecting a header cell from a normal cell
// Do not allow selecting rowIndex and normal cells simultaneously
else if ((newSelectionEnd?.rowIndex ?? 0) < 0) {
return {
...prev,
selectionEnd: {
columnIndex: newSelectionEnd?.columnIndex ?? 0,
rowIndex: 0,
isEditing: false,
},
};
}
// Selecting a normal cell from another normal cell
else {
return { ...prev, selectionEnd: newSelectionEnd };
}
});
},
registerSelectableCellRef: (rowIndex, columnIndex, ref) => {
refs.current?.set(rowIndex, refs.current?.get(rowIndex) ?? new Map());
const prev = refs.current?.get(rowIndex)?.get(columnIndex) ?? new Set();
refs.current?.get(rowIndex)?.set(columnIndex, new Set([...prev, ref]));
const isActive = coincides(selectionRef.current?.active, { rowIndex, columnIndex });
ref.setStatus?.({
isActive,
isEditing: isActive && (selectionRef.current?.active?.isEditing ?? false),
isSelected: !isActive && isCellSelected(rowIndex, columnIndex, selectionRef.current),
});
return ref;
},
deregisterSelectableCellRef: (rowIndex, columnIndex, ref) => {
ref.setStatus?.(NEUTRAL_CELL_STATUS);
refs.current?.get(rowIndex)?.get(columnIndex)?.delete(ref);
},
};
}, [
containerCellCoordinates?.columnIndex,
containerCellCoordinates?.rowIndex,
depth,
resetParentSelectionAt,
setCurrentDepth,
]);
// If there's no selection on the table that is coming into focus, we focus at the top-left cell.
useEffect(() => {
if (!selection.active && depth === activeDepth && isSelectionHere) {
dispatch.resetSelectionAt({
rowIndex: 0,
columnIndex: 1,
isEditing: false,
});
}
}, [activeDepth, containerCellCoordinates, depth, dispatch, isSelectionHere, parentActiveCell, selection]);
// Paint the selection
useEffect(() => {
if (!selection.active || !selection.selectionStart || !selection.selectionEnd) {
return;
}
const currentRefs = refs.current;
const active = selection.active;
const { startRow, endRow, startColumn, endColumn } = getSelectionIterationBoundaries(selection);
for (let r = startRow; r <= endRow; r++) {
// Select rowIndex cells
currentRefs
.get(r)
?.get(0)
?.forEach((e) => e.setStatus?.({ isActive: false, isEditing: false, isSelected: true }));
for (let c = startColumn; c <= endColumn; c++) {
// Select header cells
if (startRow >= 0) {
currentRefs
.get(-1)
?.get(c)
?.forEach((e) => e.setStatus?.({ isActive: false, isEditing: false, isSelected: true }));
}
// Select normal cells
const refs = currentRefs.get(r)?.get(c);
refs?.forEach((ref) =>
ref.setStatus?.({
isActive: false,
isEditing: false,
isSelected: true,
selectedPositions: getSelectedPositions(selection, { rowIndex: r, columnIndex: c }),
})
);
}
}
// Active cell. Sometimes it is not inside the selection.
currentRefs
.get(active.rowIndex)
?.get(active.columnIndex)
?.forEach((r) =>
r.setStatus?.({
isActive: true,
isEditing: active?.isEditing ?? false,
isSelected: !coincides(selectionRef.current?.selectionStart, selectionRef.current?.selectionEnd),
})
);
// Cleanup
return () => {
for (let r = startRow; r <= endRow; r++) {
currentRefs
.get(r)
?.get(0)
?.forEach((e) => e.setStatus?.(NEUTRAL_CELL_STATUS));
for (let c = startColumn; c <= endColumn; c++) {
currentRefs
.get(-1)
?.get(c)
?.forEach((e) => e.setStatus?.(NEUTRAL_CELL_STATUS));
const refs = currentRefs.get(r)?.get(c);
refs?.forEach((ref) => ref.setStatus?.(NEUTRAL_CELL_STATUS));
}
}
currentRefs
.get(active.rowIndex)
?.get(active.columnIndex)
?.forEach((r) => r.setStatus?.(NEUTRAL_CELL_STATUS));
};
}, [selection, selectionRef.current?.selectionStart, selectionRef.current?.selectionEnd]);
return (
<BeeTableSelectionContext.Provider value={value}>
<BeeTableSelectionDispatchContext.Provider value={dispatch}>
<>{children}</>
</BeeTableSelectionDispatchContext.Provider>
</BeeTableSelectionContext.Provider>
);
}