1/*
2 * insert_symbol-grid.tsx
3 *
4 * Copyright (C) 2019-20 by RStudio, PBC
5 *
6 * Unless you have received this program directly from RStudio pursuant
7 * to the terms of a commercial license agreement with RStudio, then
8 * this program is licensed to you under the terms of version 3 of the
9 * GNU Affero General Public License. This program is distributed WITHOUT
10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13 *
14 */
15
16import React from 'react';
17import { FixedSizeGrid } from 'react-window';
18
19import debounce from 'lodash.debounce';
20
21import { EditorUI } from '../../api/ui';
22import { WidgetProps } from '../../api/widgets/react';
23
24import { CharacterGridCellItemData, SymbolCharacterCell } from './insert_symbol-grid-cell';
25import { SymbolCharacter } from './insert_symbol-dataprovider';
26
27import './insert_symbol-grid-styles.css';
28
29interface CharacterGridProps extends WidgetProps {
30  height: number;
31  width: number;
32  numberOfColumns: number;
33  symbolCharacters: SymbolCharacter[];
34  selectedIndex: number;
35  onSelectionChanged: (selectedIndex: number) => void;
36  onSelectionCommitted: VoidFunction;
37  ui: EditorUI;
38}
39
40const selectedItemClassName = 'pm-grid-item-selected';
41
42const SymbolCharacterGrid = React.forwardRef<any, CharacterGridProps>((props, ref) => {
43  const columnWidth = Math.floor(props.width / props.numberOfColumns);
44  const characterCellData: CharacterGridCellItemData = {
45    symbolCharacters: props.symbolCharacters,
46    numberOfColumns: props.numberOfColumns,
47    selectedIndex: props.selectedIndex,
48    onSelectionChanged: props.onSelectionChanged,
49    onSelectionCommitted: props.onSelectionCommitted,
50    selectedItemClassName,
51  };
52
53  const gridRef = React.useRef<FixedSizeGrid>(null);
54  const handleScroll = debounce(() => {
55    gridRef.current?.scrollToItem({ rowIndex: Math.floor(props.selectedIndex / props.numberOfColumns) });
56  }, 5);
57
58  React.useEffect(handleScroll, [props.selectedIndex]);
59
60  const handleKeyDown = (event: React.KeyboardEvent) => {
61    const newIndex = newIndexForKeyboardEvent(
62      event,
63      props.selectedIndex,
64      props.numberOfColumns,
65      props.symbolCharacters.length,
66    );
67    if (newIndex !== undefined) {
68      props.onSelectionChanged(newIndex);
69      event.preventDefault();
70    }
71  };
72
73  return (
74    <div onKeyDown={handleKeyDown} tabIndex={0} ref={ref}>
75      <FixedSizeGrid
76        columnCount={props.numberOfColumns}
77        rowCount={Math.ceil(props.symbolCharacters.length / props.numberOfColumns)}
78        height={props.height}
79        width={props.width + 1}
80        rowHeight={columnWidth}
81        columnWidth={columnWidth}
82        itemData={characterCellData}
83        className="pm-symbol-grid"
84        ref={gridRef}
85      >
86        {SymbolCharacterCell}
87      </FixedSizeGrid>
88    </div>
89  );
90});
91
92function previous(currentIndex: number, numberOfColumns: number, numberOfCells: number): number {
93  const newIndex = currentIndex - 1;
94  return Math.max(0, newIndex);
95}
96function next(currentIndex: number, numberOfColumns: number, numberOfCells: number): number {
97  const newIndex = currentIndex + 1;
98  return Math.min(numberOfCells - 1, newIndex);
99}
100function prevRow(currentIndex: number, numberOfColumns: number, numberOfCells: number): number {
101  const newIndex = currentIndex - numberOfColumns;
102  return newIndex >= 0 ? newIndex : currentIndex;
103}
104function nextRow(currentIndex: number, numberOfColumns: number, numberOfCells: number): number {
105  const newIndex = currentIndex + numberOfColumns;
106  return newIndex < numberOfCells ? newIndex : currentIndex;
107}
108function nextPage(currentIndex: number, numberOfColumns: number, numberOfCells: number): number {
109  const newIndex = currentIndex + 6 * numberOfColumns;
110  return Math.min(numberOfCells - 1, newIndex);
111}
112function prevPage(currentIndex: number, numberOfColumns: number, numberOfCells: number): number {
113  const newIndex = currentIndex - 6 * numberOfColumns;
114  return Math.max(0, newIndex);
115}
116
117export const newIndexForKeyboardEvent = (
118  event: React.KeyboardEvent,
119  selectedIndex: number,
120  numberOfColumns: number,
121  numberOfCells: number,
122): number | undefined => {
123  switch (event.key) {
124    case 'ArrowLeft': // left
125      return previous(selectedIndex, numberOfColumns, numberOfCells);
126
127    case 'ArrowUp': // up
128      return prevRow(selectedIndex, numberOfColumns, numberOfCells);
129
130    case 'ArrowRight': // right
131      return next(selectedIndex, numberOfColumns, numberOfCells);
132
133    case 'ArrowDown': // down
134      return nextRow(selectedIndex, numberOfColumns, numberOfCells);
135
136    case 'PageDown':
137      return nextPage(selectedIndex, numberOfColumns, numberOfCells);
138
139    case 'PageUp':
140      return prevPage(selectedIndex, numberOfColumns, numberOfCells);
141
142    case 'Home':
143      return 0;
144
145    case 'End':
146      return numberOfCells - 1;
147
148    default:
149      return undefined;
150  }
151};
152
153export default SymbolCharacterGrid;
154