/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import * as React from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; import { assertUnreachable } from "../expressions/ExpressionDefinitionRoot/ExpressionDefinitionLogicTypeSelector"; import { ResizingWidth, useResizingWidthsDispatch } from "../resizing/ResizingWidthsContext"; import { useBoxedExpressionEditor } from "../BoxedExpressionEditorContext"; export const SELECTION_MIN_ACTIVE_DEPTH = -1; export const SELECTION_MIN_MAX_DEPTH = 0; export const INITIAL_CURRENT_DEPTH = { active: undefined, max: SELECTION_MIN_MAX_DEPTH, }; export interface BeeTableCellCoordinates { columnIndex: number; rowIndex: number; } export interface BeeTableSelectionActiveCell { columnIndex: number; rowIndex: number; isEditing: boolean; } export enum SelectionPart { ActiveCell, SelectionEnd, SelectionStart, } export interface BeeTableSelectionContextType { activeCell: BeeTableSelectionActiveCell | undefined; activeCellForNestedTables: BeeTableSelectionActiveCell | undefined; selectionEnd: BeeTableSelectionActiveCell | undefined; selectionStart: BeeTableSelectionActiveCell | undefined; currentDepth: { active: number | undefined; max: number }; depth: number; isSelectionHere: boolean; } export interface BeeTableCoordinatesDispatchContextType { setMaxDepth: React.Dispatch>; } export interface BeeTableSelectionDispatchContextType { setCurrentDepth: React.Dispatch>; erase(): void; copy(): void; cut(): void; paste(): void; adaptSelection(args: { atRowIndex: number; rowCountDelta: number; atColumnIndex: number; columnCountDelta: number; }): void; mutateSelection: (args: { part: SelectionPart; columnCount: (rowIndex: number) => number; rowCount: number; deltaColumns: number; deltaRows: number; isEditingActiveCell: boolean; keepInsideSelection: boolean; }) => void; resetSelectionAt: React.Dispatch< React.SetStateAction<(BeeTableSelectionActiveCell & { keepSelection?: boolean }) | undefined> >; setSelectionEnd: React.Dispatch>; registerSelectableCellRef(rowIndex: number, columnIndex: number, ref: BeeTableCellRef): BeeTableCellRef; deregisterSelectableCellRef(rowIndex: number, columnIndex: number, ref: BeeTableCellRef): void; } export interface BeeTableCoordinatesContextType { containerCellCoordinates: BeeTableCellCoordinates | undefined; } export const BeeTableSelectionContext = React.createContext({ activeCell: undefined, activeCellForNestedTables: undefined, selectionEnd: undefined, selectionStart: undefined, currentDepth: INITIAL_CURRENT_DEPTH, depth: SELECTION_MIN_ACTIVE_DEPTH, isSelectionHere: true, }); export const BeeTableCoordinatesContext = React.createContext({ containerCellCoordinates: undefined, }); export const BeeTableCoordinatesDispatchContext = React.createContext( {} as any ); export const BeeTableSelectionDispatchContext = React.createContext({} as any); export type BeeTableCellStatus = { isActive: boolean; isEditing: boolean; isSelected: boolean; selectedPositions?: BeeTableSelectionPosition[]; }; export interface BeeTableCellRef { setStatus?(args: BeeTableCellStatus): void; setValue?(value: string): void; getValue?(): string; setResizingWidth?: React.Dispatch>; } export interface BeeTableSelection { active: BeeTableSelectionActiveCell | undefined; selectionEnd: BeeTableSelectionActiveCell | undefined; selectionStart: BeeTableSelectionActiveCell | undefined; } export enum BeeTableSelectionPosition { Top = "top", Bottom = "bottom", Left = "left", Right = "right", } const TEXT_TO_CLIPBOARD_ROW_SEPARATOR = "\n"; const CLIPBOARD_TO_TEXT_ROW_SEPARATOR = /\r?\n/; const CLIPBOARD_COLUMN_SEPARATOR = "\t"; const NEUTRAL_SELECTION = { active: undefined, selectionEnd: undefined, selectionStart: undefined, }; const NEUTRAL_CELL_STATUS = { isActive: false, isEditing: false, isSelected: false, }; const CELL_EMPTY_VALUE = ""; // This value needs to be parameterized, perhaps. Not all values are strings. See https://github.com/apache/incubator-kie-issues/issues/170. export function BeeTableCoordinatesContextProvider({ children, coordinates, }: React.PropsWithChildren<{ coordinates: BeeTableCellCoordinates }>) { const { activeCell, depth } = useBeeTableSelection(); // FIXME: Tiago --> Temporary fix for the Boxed Expression Editor to work well. Ideally this wouldn't bee here, as the BeeTable should be decoupled from the DMN Editor's Boxed Expression Editor use-case. const { beeGwtService } = useBoxedExpressionEditor(); useEffect(() => { if (!activeCell && depth === SELECTION_MIN_MAX_DEPTH) { beeGwtService?.selectObject(undefined); } }, [activeCell, beeGwtService, depth]); // const { setMaxDepth: setParentMaxDepth } = useBeeTableCoordinatesDispatch(); const [_maxDepth, _setMaxDepth] = useState(depth); const { setCurrentDepth } = useBeeTableSelectionDispatch(); const setMaxDepth: React.Dispatch> = useCallback( (newMaxDepthAction) => { setParentMaxDepth?.(newMaxDepthAction); _setMaxDepth?.(newMaxDepthAction); }, [setParentMaxDepth] ); useEffect(() => { setMaxDepth((prev) => Math.max(prev, depth)); }, [coordinates.columnIndex, coordinates.rowIndex, depth, setMaxDepth]); useEffect(() => { if (coincides(activeCell, coordinates)) { setCurrentDepth((prev) => ({ active: prev.active, max: _maxDepth, })); } }, [_maxDepth, activeCell, coordinates, depth, setCurrentDepth]); const value = useMemo(() => { return { containerCellCoordinates: coordinates, }; }, [coordinates]); const dispatch = useMemo(() => { return { setMaxDepth, }; }, [setMaxDepth]); return ( {children} ); } export function BeeTableSelectionContextProvider({ children }: React.PropsWithChildren<{}>) { const refs = React.useRef>>>(new Map()); const [_selection, _setSelection] = useState(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(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(() => { 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 ( <>{children} ); } export function useBeeTableSelection() { return React.useContext(BeeTableSelectionContext); } export function useBeeTableSelectionDispatch() { return React.useContext(BeeTableSelectionDispatchContext); } export function useBeeTableCoordinates() { return React.useContext(BeeTableCoordinatesContext); } export function useBeeTableCoordinatesDispatch() { return React.useContext(BeeTableCoordinatesDispatchContext); } /** * This is done like this because if when we have every Th/Td observing { activeCell } from BeeTableSelectionContext, * performance suffers. Every component can register a BeeTableCellRef and observe changes to it, then set their own state with a "copy" from the status. */ export function useBeeTableSelectableCellRef( rowIndex: number, columnIndex: number, setValue?: BeeTableCellRef["setValue"], getValue?: BeeTableCellRef["getValue"] ) { const { registerSelectableCellRef, deregisterSelectableCellRef } = useBeeTableSelectionDispatch(); const [status, setStatus] = useState(NEUTRAL_CELL_STATUS); useLayoutEffect(() => { const ref = registerSelectableCellRef?.(rowIndex, columnIndex, { setStatus, setValue, getValue, }); return () => { deregisterSelectableCellRef?.(rowIndex, columnIndex, ref); }; }, [columnIndex, rowIndex, getValue, setValue, registerSelectableCellRef, deregisterSelectableCellRef]); return status; } function getSelectionIterationBoundaries(selection: BeeTableSelection) { // Let's always go smaller to bigger, no matter the direction of the selection. return { startColumn: Math.min(selection.selectionStart?.columnIndex ?? 0, selection.selectionEnd?.columnIndex ?? 0), endColumn: Math.max(selection.selectionStart?.columnIndex ?? 0, selection.selectionEnd?.columnIndex ?? 0), startRow: Math.min(selection.selectionStart?.rowIndex ?? 0, selection.selectionEnd?.rowIndex ?? 0), endRow: Math.max(selection.selectionStart?.rowIndex ?? 0, selection.selectionEnd?.rowIndex ?? 0), }; } function getSelectedPositions(selection: BeeTableSelection, cell: BeeTableCellCoordinates) { const { startRow, endRow, startColumn, endColumn } = getSelectionIterationBoundaries(selection); return [ ...(cell.rowIndex === startRow ? [BeeTableSelectionPosition.Top] : []), ...(cell.rowIndex === endRow ? [BeeTableSelectionPosition.Bottom] : []), ...(cell.columnIndex === startColumn ? [BeeTableSelectionPosition.Left] : []), ...(cell.columnIndex === endColumn ? [BeeTableSelectionPosition.Right] : []), ]; } function coincides(a: BeeTableCellCoordinates | undefined, b: BeeTableCellCoordinates | undefined) { return a?.columnIndex === b?.columnIndex && a?.rowIndex === b?.rowIndex; } function isCellSelected(row: number, column: number, current: BeeTableSelection) { if (!current.selectionEnd && !current.selectionStart) { return false; } const bounds = getSelectionIterationBoundaries(current); return row >= bounds.startRow && row <= bounds.endRow && column >= bounds.startColumn && column <= bounds.endColumn; } function isSelectionExpanded(selection: BeeTableSelection) { return !coincides(selection.active, selection.selectionEnd) || !coincides(selection.active, selection.selectionStart); } export function useBeeTableSelectableCell( cellRef: React.RefObject, rowIndex: number, columnIndex: number, setValue?: BeeTableCellRef["setValue"], getValue?: BeeTableCellRef["getValue"] ) { const { isResizing } = useResizingWidthsDispatch(); const { isActive, isEditing, isSelected, selectedPositions } = useBeeTableSelectableCellRef( rowIndex, columnIndex, setValue, getValue ); const cssClasses = useMemo(() => { return ` ${isActive ? "active" : ""} ${isEditing ? "editing" : ""} ${isSelected ? "selected" : ""} ${(selectedPositions?.length ?? 0) <= 0 ? "middle" : selectedPositions?.join(" ")} `; }, [isActive, isEditing, isSelected, selectedPositions]); const { selectionStart, selectionEnd } = useBeeTableSelection(); const { resetSelectionAt, setSelectionEnd } = useBeeTableSelectionDispatch(); const onMouseDown = useCallback( (e: React.MouseEvent) => { if (document.activeElement instanceof HTMLInputElement) { // See https://github.com/apache/incubator-kie-issues/issues/1158 (document.activeElement as any)?.blur?.(); } e.stopPropagation(); if ( isCellSelected(rowIndex, columnIndex, { active: undefined, selectionEnd: selectionEnd, selectionStart: selectionStart, }) ) { // That's the right-click case to open the Context Menu at the right place. if (e.button !== 0 && isSelected) { resetSelectionAt({ columnIndex, rowIndex, isEditing: false, keepSelection: true, }); return; } } if (!isActive && !isEditing) { const set = e.shiftKey ? setSelectionEnd : resetSelectionAt; set({ columnIndex, rowIndex, isEditing: false, }); } }, [ rowIndex, columnIndex, selectionEnd, selectionStart, isActive, isEditing, isSelected, resetSelectionAt, setSelectionEnd, ] ); const onDoubleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); resetSelectionAt({ columnIndex, rowIndex, isEditing: columnIndex > 0, // Not rowIndex column }); }, [columnIndex, rowIndex, resetSelectionAt] ); useEffect(() => { function onEnter(e: MouseEvent) { e.stopPropagation(); // User is pressing the left mouse button. Meaning user is dragging. // Not a final solution, as user can start dragging from anywhere. // Ideally, we want users to change selection only when the dragging originates // some other cell within the table. if (e.buttons === 1 && e.button === 0 && !isResizing()) { setSelectionEnd({ columnIndex, rowIndex, isEditing: false, }); } } const cell = cellRef.current; cell?.addEventListener("mouseenter", onEnter); return () => { cell?.removeEventListener("mouseenter", onEnter); }; }, [columnIndex, rowIndex, resetSelectionAt, setSelectionEnd, cellRef, isResizing]); useLayoutEffect(() => { if (isActive && !isEditing) { const cellElement = cellRef.current; if (!cellElement) { return; } // Find the boxed-expression-provider container (top-level container for this component) const activeElement = document.activeElement; const boxedExpressionProvider = cellElement.closest(".boxed-expression-provider"); // Don't steal focus if the active element is outside this boxed-expression-provider. // This prevents stealing focus from input fields in other tables/components when the table re-renders. if (activeElement && boxedExpressionProvider && !boxedExpressionProvider.contains(activeElement)) { return; } cellElement.focus(); } }, [columnIndex, isActive, isEditing, rowIndex, cellRef]); return { cssClasses, onMouseDown, onDoubleClick, isActive, }; }