ExcelNavigation.ts
import type {
CellKeyDownEvent,
CellPosition,
ColDef,
Column,
GridApi,
RowNode,
} from 'ag-grid-community';
type SelectionState = {
/*
* 選択開始セル。
*/
anchor: CellPosition;
/*
* 現在の縦方向の選択端。
*/
rowEdge: number;
/*
* 現在の横方向の選択端。
*/
columnEdge: Column;
};
/*
* GridごとにCtrl+Shift選択状態を保持する。
*
* 同一画面に複数AG Gridが存在しても
* 状態が干渉しない。
*/
const selectionStates =
new WeakMap<object, SelectionState>();
/*
* ============================================================
* AG Grid標準のCtrl+Arrowを抑止
* ============================================================
*/
export const excelSuppressKeyboardEvent: NonNullable<
ColDef['suppressKeyboardEvent']
> = (params) => {
const event = params.event;
const ctrlOrCommand =
event.ctrlKey ||
event.metaKey;
return (
ctrlOrCommand &&
isArrowKey(event.key)
);
};
/*
* ============================================================
* Ctrl / Ctrl+Shift + Arrow
* ============================================================
*/
export const excelOnCellKeyDown = (
params: CellKeyDownEvent,
) => {
const event =
params.event as KeyboardEvent;
const api = params.api;
const ctrlOrCommand =
event.ctrlKey ||
event.metaKey;
/*
* Ctrl+Arrow以外。
*/
if (
!ctrlOrCommand ||
!isArrowKey(event.key)
) {
/*
* 通常操作に戻ったら
* Ctrl+Shift選択状態を解除する。
*/
if (!event.shiftKey) {
selectionStates.delete(api);
}
return;
}
event.preventDefault();
event.stopPropagation();
const focused =
api.getFocusedCell();
if (!focused) {
return;
}
/*
* ========================================================
* Ctrl + Shift + Arrow
* ========================================================
*/
if (event.shiftKey) {
let state =
selectionStates.get(api);
/*
* 最初のCtrl+Shift操作。
*/
if (!state) {
state = {
anchor: focused,
rowEdge:
focused.rowIndex,
columnEdge:
focused.column,
};
selectionStates.set(
api,
state,
);
}
/*
* ------------------------------------------------------
* Ctrl + Shift + ↑ / ↓
* ------------------------------------------------------
*/
if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
const direction: -1 | 1 =
event.key === 'ArrowUp'
? -1
: 1;
/*
* 縦方向探索はanchor列を使う。
*
* A1:D100 の状態でも
*
* Ctrl+Shift+↓
*
* はA列を探索する。
*/
const lookupCell: CellPosition = {
rowIndex:
state.rowEdge,
rowPinned:
state.anchor.rowPinned,
column:
state.anchor.column,
};
const destination =
findVerticalDestination(
api,
lookupCell,
direction,
);
state.rowEdge =
destination.rowIndex;
}
/*
* ------------------------------------------------------
* Ctrl + Shift + ← / →
* ------------------------------------------------------
*/
else if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight'
) {
const direction: -1 | 1 =
event.key === 'ArrowLeft'
? -1
: 1;
/*
* 横方向探索はanchor行を使う。
*
* A1:A100
*
* からCtrl+Shift+→の場合、
*
* A100ではなくA1から探索する。
*/
const lookupCell: CellPosition = {
rowIndex:
state.anchor.rowIndex,
rowPinned:
state.anchor.rowPinned,
column:
state.columnEdge,
};
const destination =
findHorizontalDestination(
api,
lookupCell,
direction,
);
state.columnEdge =
destination.column;
}
/*
* =====================================================
* AG Grid 32.1.0
*
* clearCellSelection()ではなく
* clearRangeSelection()
* =====================================================
*/
api.clearRangeSelection();
api.addCellRange({
rowStartIndex:
state.anchor.rowIndex,
rowStartPinned:
state.anchor.rowPinned ??
undefined,
rowEndIndex:
state.rowEdge,
rowEndPinned:
state.anchor.rowPinned ??
undefined,
columnStart:
state.anchor.column,
columnEnd:
state.columnEdge,
});
api.ensureIndexVisible(
state.rowEdge,
);
api.ensureColumnVisible(
state.columnEdge,
);
/*
* active cellはanchorに維持。
*/
api.setFocusedCell(
state.anchor.rowIndex,
state.anchor.column,
state.anchor.rowPinned ??
undefined,
);
return;
}
/*
* ========================================================
* Ctrl + Arrow
* ========================================================
*/
selectionStates.delete(api);
const destination =
findCtrlArrowDestination(
api,
focused,
event.key,
);
if (!destination) {
return;
}
/*
* AG Grid 32.1.0
*/
api.clearRangeSelection();
api.ensureIndexVisible(
destination.rowIndex,
);
api.ensureColumnVisible(
destination.column,
);
api.setFocusedCell(
destination.rowIndex,
destination.column,
destination.rowPinned ??
undefined,
);
};
/*
* ============================================================
* Arrow判定
* ============================================================
*/
const isArrowKey = (
key: string,
): boolean =>
key === 'ArrowUp' ||
key === 'ArrowDown' ||
key === 'ArrowLeft' ||
key === 'ArrowRight';
/*
* ============================================================
* Ctrl+Arrow移動先
* ============================================================
*/
const findCtrlArrowDestination = (
api: GridApi,
current: CellPosition,
key: string,
): CellPosition | null => {
switch (key) {
case 'ArrowUp':
return findVerticalDestination(
api,
current,
-1,
);
case 'ArrowDown':
return findVerticalDestination(
api,
current,
1,
);
case 'ArrowLeft':
return findHorizontalDestination(
api,
current,
-1,
);
case 'ArrowRight':
return findHorizontalDestination(
api,
current,
1,
);
default:
return null;
}
};
/*
* ============================================================
* 縦方向
* ============================================================
*/
const findVerticalDestination = (
api: GridApi,
current: CellPosition,
direction: -1 | 1,
): CellPosition => {
const rowCount =
api.getDisplayedRowCount();
if (rowCount === 0) {
return current;
}
const currentIndex =
current.rowIndex;
const adjacentIndex =
currentIndex + direction;
/*
* すでに端。
*/
if (
adjacentIndex < 0 ||
adjacentIndex >= rowCount
) {
return current;
}
const currentRow =
api.getDisplayedRowAtIndex(
currentIndex,
);
const adjacentRow =
api.getDisplayedRowAtIndex(
adjacentIndex,
);
if (
!currentRow ||
!adjacentRow
) {
return current;
}
const currentFilled =
hasCellValue(
api,
currentRow,
current.column,
);
const adjacentFilled =
hasCellValue(
api,
adjacentRow,
current.column,
);
let destinationIndex =
currentIndex;
/*
* 現在値あり
* +
* 隣値あり
*
* → 連続領域の最後。
*/
if (
currentFilled &&
adjacentFilled
) {
destinationIndex =
adjacentIndex;
while (true) {
const nextIndex =
destinationIndex +
direction;
if (
nextIndex < 0 ||
nextIndex >= rowCount
) {
break;
}
const nextRow =
api.getDisplayedRowAtIndex(
nextIndex,
);
if (!nextRow) {
break;
}
if (
!hasCellValue(
api,
nextRow,
current.column,
)
) {
break;
}
destinationIndex =
nextIndex;
}
}
/*
* 現在空白
*
* または
*
* 隣が空白
*
* → 次の値ありまで。
*/
else {
let index =
adjacentIndex;
while (
index >= 0 &&
index < rowCount
) {
const row =
api.getDisplayedRowAtIndex(
index,
);
if (!row) {
break;
}
destinationIndex =
index;
if (
hasCellValue(
api,
row,
current.column,
)
) {
break;
}
index += direction;
}
}
return {
rowIndex:
destinationIndex,
rowPinned:
current.rowPinned,
column:
current.column,
};
};
/*
* ============================================================
* 横方向
* ============================================================
*/
const findHorizontalDestination = (
api: GridApi,
current: CellPosition,
direction: -1 | 1,
): CellPosition => {
const columns =
api.getAllDisplayedColumns();
const currentIndex =
columns.indexOf(
current.column,
);
if (currentIndex < 0) {
return current;
}
const adjacentIndex =
currentIndex + direction;
if (
adjacentIndex < 0 ||
adjacentIndex >=
columns.length
) {
return current;
}
const row =
api.getDisplayedRowAtIndex(
current.rowIndex,
);
if (!row) {
return current;
}
const currentFilled =
hasCellValue(
api,
row,
current.column,
);
const adjacentColumn =
columns[adjacentIndex];
const adjacentFilled =
hasCellValue(
api,
row,
adjacentColumn,
);
let destinationIndex =
currentIndex;
/*
* 現在値あり
* +
* 隣値あり
*
* → 連続領域の最後。
*/
if (
currentFilled &&
adjacentFilled
) {
destinationIndex =
adjacentIndex;
while (true) {
const nextIndex =
destinationIndex +
direction;
if (
nextIndex < 0 ||
nextIndex >=
columns.length
) {
break;
}
const nextColumn =
columns[nextIndex];
if (
!hasCellValue(
api,
row,
nextColumn,
)
) {
break;
}
destinationIndex =
nextIndex;
}
}
/*
* 空白方向
*
* → 次の値ありへ。
*/
else {
let index =
adjacentIndex;
while (
index >= 0 &&
index <
columns.length
) {
const column =
columns[index];
destinationIndex =
index;
if (
hasCellValue(
api,
row,
column,
)
) {
break;
}
index += direction;
}
}
return {
rowIndex:
current.rowIndex,
rowPinned:
current.rowPinned,
column:
columns[
destinationIndex
],
};
};
/*
* ============================================================
* セル値判定
* ============================================================
*/
const hasCellValue = (
api: GridApi,
rowNode: RowNode,
column: Column,
): boolean => {
const colDef =
column.getColDef();
const colId =
column.getColId();
const isGroupColumn =
colDef.showRowGroup === true ||
typeof colDef.showRowGroup ===
'string' ||
colId.startsWith(
'ag-Grid-AutoColumn',
);
/*
* Auto Group Column。
*/
if (isGroupColumn) {
if (rowNode.group) {
return (
rowNode.key !== null &&
rowNode.key !== undefined &&
rowNode.key !== ''
);
}
return false;
}
/*
* AG Grid 32.1.0でも
* getCellValue()は利用可能。
*/
const value =
api.getCellValue({
rowNode,
colKey: column,
});
return !isEmptyValue(value);
};
/*
* ============================================================
* 空白判定
* ============================================================
*/
const isEmptyValue = (
value: unknown,
): boolean =>
value === null ||
value === undefined ||
value === '';
ExcelAggrid.tsx
import {
AgGridReact,
} from 'ag-grid-react';
import type {
AgGridReactProps,
} from 'ag-grid-react';
import {
excelOnCellKeyDown,
excelSuppressKeyboardEvent,
} from './excelNavigation';
/*
* ============================================================
* システム共通AG Grid
* ============================================================
*
* システム内では原則として
*
* AgGridReact
*
* を直接使わず、
*
* SystemAgGrid
*
* を使用する。
*
*
* AG Grid 32.1.0前提。
*/
export const SystemAgGrid = <
TData = any,
>(
props: AgGridReactProps<TData>,
) => {
const {
defaultColDef,
autoGroupColumnDef,
onCellKeyDown,
/*
* AG Grid 32.1系Range Selection設定。
*/
enableRangeSelection,
suppressMultiRangeSelection,
...restProps
} = props;
return (
<AgGridReact<TData>
{...restProps}
/*
* ====================================================
* defaultColDef
* ====================================================
*/
defaultColDef={{
/*
* 標準ではExcel操作を有効にする。
*/
suppressKeyboardEvent:
excelSuppressKeyboardEvent,
/*
* 個別画面側で設定されていれば
* そちらを優先する。
*/
...defaultColDef,
}}
/*
* ====================================================
* Auto Group Column
* ====================================================
*/
autoGroupColumnDef={{
suppressKeyboardEvent:
excelSuppressKeyboardEvent,
...autoGroupColumnDef,
}}
/*
* ====================================================
* AG Grid 32.1 Range Selection
* ====================================================
*
* v32.1ではcellSelectionではない。
*/
enableRangeSelection={
enableRangeSelection ??
true
}
/*
* Excel通常選択に合わせて
* 複数Rangeは作らない。
*/
suppressMultiRangeSelection={
suppressMultiRangeSelection ??
true
}
/*
* ====================================================
* Keyboard
* ====================================================
*
* 個別側に指定があれば
* 個別処理を優先。
*/
onCellKeyDown={
onCellKeyDown ??
excelOnCellKeyDown
}
/>
);
};