Merge pull request #291 from dbeaver/feat/multiselect

Feat/multiselect
This commit is contained in:
Alexey
2021-02-18 11:28:55 +03:00
committed by GitHub
10 changed files with 545 additions and 201 deletions
@@ -36,8 +36,8 @@ export const CellRenderer: React.FC<CellRendererProps<any>> = observer(function
classes.push('rdg-cell-custom-edited');
}
const handleClick = useCallback((event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
selectionContext?.select(column.key, rowIdx, event.ctrlKey, event.shiftKey);
const handleMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
selectionContext?.select(column.idx, rowIdx, event.ctrlKey, event.shiftKey);
}, [column, rowIdx, selectionContext]);
const handleDoubleClick = useCallback(() => {
@@ -49,7 +49,9 @@ export const CellRenderer: React.FC<CellRendererProps<any>> = observer(function
return (
<Cell
className={classes.join(' ')}
onClick={handleClick}
data-rowindex={rowIdx}
data-columnindex={column.idx}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
{...props}
row={row}
@@ -8,10 +8,17 @@
import { createContext } from 'react';
import type { IPosition } from './useGridSelectionContext';
export interface IDataGridSelectionContext {
selectedCells: Map<number, number[]>;
select: (key: string, rowIdx: number, multiple: boolean, range: boolean) => void;
isSelected: (key: string, rowIdx: number) => boolean;
select: (columnIndex: number, rowIdx: number, multiple: boolean, range: boolean) => void;
selectColumn: (columnKey: string, multiple: boolean) => void;
selectTable: () => void;
isSelected: (columnKey: string, rowIdx: number) => boolean;
updateMultiSelection: (
startPosition: IPosition, lastPosition: IPosition, multiple: boolean, temporary: boolean
) => void;
}
export const DataGridSelectionContext = createContext<IDataGridSelectionContext | null>(null);
@@ -9,156 +9,213 @@
import { action, observable } from 'mobx';
import { useCallback, useMemo, useState } from 'react';
import type { SqlResultSet } from '@cloudbeaver/core-sdk';
import type { IDatabaseDataResult } from '@cloudbeaver/plugin-data-viewer';
import type { ITableData } from '../useTableData';
import type { IDataGridSelectionContext } from './DataGridSelectionContext';
interface IPosition {
export interface IPosition {
idx: number;
rowIdx: number;
isIndexColumn: boolean;
}
interface IGridSelectionOptions {
indexColumnKey: string;
}
export function useGridSelectionContext(modelResultData: IDatabaseDataResult | null, options: IGridSelectionOptions) {
export function useGridSelectionContext(tableData: ITableData) {
const [selectedCells] = useState(() => observable.map<number, number[]>());
const [temporarySelectedCells] = useState(() => observable.map<number, number[]>());
const [lastSelectedCell, setLastSelectedCell] = useState<IPosition | null>(null);
const selectRow = useCallback(action((rowIdx: number, multiple: boolean) => {
if (!modelResultData) {
throw new Error('Model result data must be provided');
}
const selectRows = useCallback(action(
(startPosition: number, lastPosition: number, multiple: boolean, temporary = false) => {
const columnsLength = tableData.columns?.length || 0;
const columnsLength = (modelResultData.data as SqlResultSet).columns?.length || 0;
temporarySelectedCells.clear();
if (!multiple) {
selectedCells.clear();
}
const rowSelection = [];
for (let i = 0; i < columnsLength; i++) {
rowSelection.push(i);
}
const firstRowIdx = Math.min(startPosition, lastPosition);
const lastRowIdx = Math.max(startPosition, lastPosition);
for (let rowIdx = firstRowIdx; rowIdx <= lastRowIdx; rowIdx++) {
if (selectedCells.get(rowIdx)?.length === columnsLength) {
selectedCells.delete(rowIdx);
continue;
}
if (temporary) {
temporarySelectedCells.set(rowIdx, rowSelection);
} else {
selectedCells.set(rowIdx, rowSelection);
}
}
}), [selectedCells, temporarySelectedCells]);
const selectRange = useCallback(action(
(startPosition: number, lastPosition: number, columns: number[], multiple: boolean, temporary = false) => {
temporarySelectedCells.clear();
if (!multiple) {
selectedCells.clear();
}
const top = Math.min(startPosition, lastPosition);
const bottom = Math.max(startPosition, lastPosition);
for (let rowIdx = top; rowIdx <= bottom; rowIdx++) {
if (temporary) {
temporarySelectedCells.set(rowIdx, [...columns]);
} else {
const currentRowSelection = selectedCells.get(rowIdx) || [];
const newRowSelection = [...currentRowSelection, ...columns]
.filter((columnIdx, idx, arr) => arr.indexOf(columnIdx) === idx);
selectedCells.set(rowIdx, newRowSelection);
}
}
}), [selectedCells, temporarySelectedCells]);
const updateMultiSelection = useCallback(
(startPosition: IPosition, lastPosition: IPosition, multiple: boolean, temporary: boolean) => {
const columnsInRange = tableData.getColumnsInRange(startPosition.idx, lastPosition.idx);
const isIndexColumnInRange = tableData.isIndexColumnInRange(columnsInRange);
if (isIndexColumnInRange) {
selectRows(startPosition.rowIdx, lastPosition.rowIdx, multiple, temporary);
} else {
selectRange(
startPosition.rowIdx,
lastPosition.rowIdx,
columnsInRange.map(column => Number(column.key)),
multiple,
temporary
);
}
}, [selectRange, selectRows, tableData]);
const isColumnSelected = useCallback((columnIndex: number) => {
const rowsLength = tableData.rows?.length || 0;
for (let rowIdx = 0; rowIdx < rowsLength; rowIdx++) {
const rowSelection = selectedCells.get(rowIdx) || [];
if (!rowSelection.includes(columnIndex)) {
return false;
}
}
return true;
}, [selectedCells, tableData]);
const selectColumn = useCallback(action((columnKey: string, multiple: boolean) => {
const columnIndex = Number(columnKey);
const isSelected = isColumnSelected(columnIndex);
if (!multiple) {
selectedCells.clear();
}
const rowSelection = [];
for (let i = 0; i < columnsLength; i++) {
rowSelection.push(i);
const rowsLength = tableData.rows?.length || 0;
for (let rowIdx = 0; rowIdx < rowsLength; rowIdx++) {
const rowSelection = selectedCells.get(rowIdx) || [];
if (isSelected) {
selectedCells.set(rowIdx, [...rowSelection.filter(colIdx => colIdx !== columnIndex)]);
} else {
selectedCells.set(rowIdx, [...rowSelection, columnIndex]);
}
}
}), [tableData, selectedCells, isColumnSelected]);
selectedCells.set(rowIdx, rowSelection);
}), [selectedCells, modelResultData]);
const isRowSelected = useCallback((rowIdx: number) => {
if (!modelResultData) {
throw new Error('Model result data must be provided');
}
const columnsLength = (modelResultData.data as SqlResultSet).columns?.length || 0;
return selectedCells.get(rowIdx)?.length === columnsLength;
}, [modelResultData, selectedCells]);
const unSelectRow = useCallback((rowIdx: number) => {
selectedCells.delete(rowIdx);
}, [selectedCells]);
const selectRange = useCallback(action((idx: number, rowIdx: number, isIndexCol: boolean) => {
if (!lastSelectedCell) {
throw new Error('Last selected cell must be provided');
}
selectedCells.clear();
const left = Math.min(idx, lastSelectedCell.idx);
const right = Math.max(idx, lastSelectedCell.idx);
const top = Math.min(rowIdx, lastSelectedCell.rowIdx);
const bottom = Math.max(rowIdx, lastSelectedCell.rowIdx);
const selectTable = useCallback(() => {
const rowsLength = tableData.rows?.length || 0;
const columnsLength = tableData.columns?.length || 0;
const rowSelection = [];
for (let colIdx = left; colIdx <= right; colIdx++) {
for (let colIdx = 0; colIdx < columnsLength; colIdx++) {
rowSelection.push(colIdx);
}
for (let rowIdx = top; rowIdx <= bottom; rowIdx++) {
if (isIndexCol || lastSelectedCell.isIndexColumn) {
selectRow(rowIdx, true);
continue;
}
for (let rowIdx = 0; rowIdx < rowsLength; rowIdx++) {
selectedCells.set(rowIdx, [...rowSelection]);
}
}), [selectedCells, lastSelectedCell]);
}, [tableData, selectedCells]);
const isSelected = useCallback((key: string, rowIdx: number) => {
if (!selectedCells.has(rowIdx)) {
const isSelected = useCallback((columnKey: string, rowIdx: number) => {
if (!selectedCells.has(rowIdx) && !temporarySelectedCells.has(rowIdx)) {
return false;
}
const rowSelection = selectedCells.get(rowIdx)!;
const idx = Number.parseInt(key);
return rowSelection.includes(idx);
const rowSelection = selectedCells.get(rowIdx);
const temporaryRowSelection = temporarySelectedCells.get(rowIdx);
const idx = Number(columnKey);
return !!(rowSelection?.includes(idx) || temporaryRowSelection?.includes(idx));
},
[selectedCells]);
[selectedCells, temporarySelectedCells]);
const unSelect = useCallback((idx: number, rowIdx: number) => {
if (!selectedCells.has(rowIdx)) {
return false;
const selectCell = useCallback((columnIdx: number, rowIdx: number) => {
const rowSelection = selectedCells.get(rowIdx);
if (rowSelection === undefined) {
selectedCells.set(rowIdx, [columnIdx]);
return;
}
const rowSelection = selectedCells.get(rowIdx)!;
const targetIndex = rowSelection.indexOf(idx);
const targetIndex = rowSelection.indexOf(columnIdx);
if (targetIndex !== -1) {
rowSelection.splice(targetIndex, 1);
return true;
}
return false;
}, [selectedCells]);
const selectCell = useCallback((idx: number, rowIdx: number) => {
const rowSelection = selectedCells.get(rowIdx);
if (rowSelection === undefined) {
selectedCells.set(rowIdx, [idx]);
return;
}
rowSelection.push(idx);
rowSelection.push(columnIdx);
}, [selectedCells]);
const select = useCallback((key: string, rowIdx: number, multiple: boolean, range: boolean) => {
const isIndexColumn = key === options.indexColumnKey;
const columnIndex = isIndexColumn ? 0 : Number.parseInt(key);
const select = useCallback(
(columnIndex: number, rowIdx: number, multiple: boolean, range: boolean) => {
const columnKey = tableData.getColumnKeyFromColumnIndex(columnIndex);
const isIndexColumn = tableData.isIndexColumn(columnKey);
setLastSelectedCell({ idx: columnIndex, rowIdx, isIndexColumn });
setLastSelectedCell({ idx: columnIndex, rowIdx });
if (selectedCells.size > 0 && range) {
selectRange(columnIndex, rowIdx, isIndexColumn);
return;
}
if (isIndexColumn) {
if (multiple && isRowSelected(rowIdx)) {
unSelectRow(rowIdx);
} else {
selectRow(rowIdx, multiple);
if (selectedCells.size > 0 && range && lastSelectedCell) {
updateMultiSelection(
{ idx: lastSelectedCell.idx, rowIdx: lastSelectedCell.rowIdx },
{ idx: columnIndex, rowIdx },
multiple,
false);
return;
}
return;
}
if (!multiple) {
selectedCells.clear();
}
if (isIndexColumn) {
selectRows(rowIdx, rowIdx, multiple);
return;
}
if (isSelected(key, rowIdx)) {
unSelect(columnIndex, rowIdx);
return;
}
if (!multiple) {
selectedCells.clear();
}
selectCell(columnIndex, rowIdx);
}, [selectedCells, selectRow, selectRange, unSelect, isSelected, selectCell, isRowSelected, unSelectRow]);
selectCell(Number(columnKey), rowIdx);
}, [
tableData,
selectedCells,
selectRows,
selectCell,
lastSelectedCell,
updateMultiSelection,
]);
const context: IDataGridSelectionContext = useMemo(() => ({
selectedCells,
select,
selectColumn,
selectTable,
isSelected,
}), [selectedCells, select, isSelected]);
updateMultiSelection,
}), [selectedCells, select, isSelected, selectColumn, selectTable, updateMultiSelection]);
return context;
}
@@ -6,23 +6,19 @@
* you may not use this file except in compliance with the License.
*/
import { computed } from 'mobx';
import { observer } from 'mobx-react-lite';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import DataGrid from 'react-data-grid';
import type { Column } from 'react-data-grid';
import type { DataGridHandle } from 'react-data-grid';
import type { Position } from 'react-data-grid/lib/types';
import styled from 'reshadow';
import { Executor } from '@cloudbeaver/core-executor';
import type { SqlResultSet } from '@cloudbeaver/core-sdk';
import { useStyles } from '@cloudbeaver/core-theming';
import { TextTools } from '@cloudbeaver/core-utils';
import type { IDatabaseDataModel } from '@cloudbeaver/plugin-data-viewer';
import { EditingContext } from '../Editing/EditingContext';
import { useEditing } from '../Editing/useEditing';
import { ResultSetTools } from '../ResultSetTools';
import baseStyles from '../styles/base.scss';
import { reactGridStyles } from '../styles/styles';
import { DataGridContext, IColumnResizeInfo, IDataGridContext } from './DataGridContext';
@@ -30,11 +26,11 @@ import { DataGridSelectionContext } from './DataGridSelection/DataGridSelectionC
import { useGridSelectionContext } from './DataGridSelection/useGridSelectionContext';
import { DataGridSortingContext } from './DataGridSorting/DataGridSortingContext';
import { useGridSortingContext } from './DataGridSorting/useGridSortingContext';
import { DataGridTableContainer } from './DataGridTableContainer';
import { CellFormatter } from './Formatters/CellFormatter';
import { IndexFormatter } from './Formatters/IndexFormatter';
import { RowRenderer } from './RowRenderer/RowRenderer';
import { TableColumnHeader } from './TableColumnHeader/TableColumnHeader';
import { useGridDragging } from './useGridDragging';
import { useGridSelectedCellsCopy } from './useGridSelectedCellsCopy';
import { useTableData } from './useTableData';
interface Props {
model: IDatabaseDataModel<any>;
@@ -47,25 +43,18 @@ function isAtBottom(event: React.UIEvent<HTMLDivElement>): boolean {
return target.clientHeight + target.scrollTop === target.scrollHeight;
}
const indexColumn: Column<any[], any> = {
key: Number.MAX_SAFE_INTEGER + '',
name: '#',
minWidth: 60,
width: 60,
resizable: false,
frozen: true,
formatter: IndexFormatter,
};
export const DataGridTable: React.FC<Props> = observer(function DataGridTable({ model, resultIndex, className }) {
const dataGridRef = useRef<DataGridHandle>(null);
const editorRef = useRef<HTMLDivElement>(null);
const styles = useStyles(reactGridStyles, baseStyles);
const [columnResize] = useState(() => new Executor<IColumnResizeInfo>());
const modelResultData = model?.getResult(resultIndex);
const tableData = useTableData(modelResultData);
const gridSortingContext = useGridSortingContext(model);
const gridSelectionContext = useGridSelectionContext(modelResultData, { indexColumnKey: indexColumn.key });
const gridSelectionContext = useGridSelectionContext(tableData);
const editingContext = useEditing({
onEdit: (position, key) => {
const editor = model.source.getEditor(resultIndex);
@@ -86,6 +75,19 @@ export const DataGridTable: React.FC<Props> = observer(function DataGridTable({
},
});
const { onKeydownHandler } = useGridSelectedCellsCopy(modelResultData, gridSelectionContext);
const { onMouseDownHandler, onMouseMoveHandler } = useGridDragging({
onDragStart: startPosition => {
dataGridRef.current?.selectCell({ idx: startPosition.idx, rowIdx: startPosition.rowIdx });
},
onDragOver: (startPosition, currentPosition, event) => {
gridSelectionContext.updateMultiSelection(startPosition, currentPosition, event.ctrlKey, true);
},
onDragEnd: (startPosition, currentPosition, event) => {
gridSelectionContext.updateMultiSelection(startPosition, currentPosition, event.ctrlKey, false);
},
});
useEffect(() => {
if (model.isLoading()) {
editingContext.close();
@@ -122,62 +124,37 @@ export const DataGridTable: React.FC<Props> = observer(function DataGridTable({
}
}, [model, modelResultData]);
const { columns, rows } = useMemo(() => computed(() => {
if (!modelResultData) {
return { columns: [], rows: [] };
}
const columnNames = ResultSetTools.getHeaders(modelResultData.data as SqlResultSet);
const rowStrings = ResultSetTools.getLongestCells(modelResultData.data as SqlResultSet);
// TODO: seems better to do not measure container size
// for detecting max columns size, better to use configurable variable
const measuredCells = TextTools.getWidth({
font: '400 14px Roboto',
text: columnNames.map((cell, i) => {
if (cell.length > (rowStrings[i] || '').length) {
return cell;
}
return rowStrings[i];
}),
}).map(v => v + 16 + 32 + 20);
// TODO: we need some result type specified formatter to common actions with data
const rows = (modelResultData.data as SqlResultSet).rows || [];
const columns = (modelResultData.data as SqlResultSet).columns!.map<Column<any[], any>>((col, columnIndex) => ({
key: columnIndex + '',
name: col.label!,
width: Math.min(300, measuredCells[columnIndex]),
headerRenderer: TableColumnHeader,
}));
columns.unshift(indexColumn);
return { rows, columns };
}), [modelResultData]).get();
const gridContext = useMemo<IDataGridContext>(() => ({
model,
columnResize,
resultIndex,
getEditorPortal: () => editorRef.current,
}), [model, resultIndex, editorRef]);
getDataGridApi: () => dataGridRef.current,
}), [model, resultIndex, editorRef, dataGridRef]);
return styled(styles)(
<DataGridContext.Provider value={gridContext}>
<DataGridSortingContext.Provider value={gridSortingContext}>
<DataGridSelectionContext.Provider value={gridSelectionContext}>
<EditingContext.Provider value={editingContext}>
<DataGridTableContainer modelResultData={modelResultData}>
<grid-container
as='div'
className="cb-react-grid-container"
tabIndex={-1}
onKeyDown={onKeydownHandler}
onMouseDown={onMouseDownHandler}
onMouseMove={onMouseMoveHandler}
>
<DataGrid
ref={dataGridRef}
className={`cb-react-grid-theme ${className}`}
columns={columns}
columns={tableData.columns}
defaultColumnOptions={{
minWidth: 40,
resizable: true,
formatter: CellFormatter,
}}
rows={rows}
rows={tableData.rows}
headerRowHeight={28}
rowHeight={24}
rowRenderer={RowRenderer}
@@ -186,7 +163,7 @@ export const DataGridTable: React.FC<Props> = observer(function DataGridTable({
onScroll={handleScroll}
/>
<div ref={editorRef} />
</DataGridTableContainer>
</grid-container>
</EditingContext.Provider>
</DataGridSelectionContext.Provider>
</DataGridSortingContext.Provider>
@@ -16,6 +16,7 @@ import type { SqlResultSet } from '@cloudbeaver/core-sdk';
import type { SortMode } from '@cloudbeaver/plugin-data-viewer';
import { DataGridContext } from '../DataGridContext';
import { DataGridSelectionContext } from '../DataGridSelection/DataGridSelectionContext';
import { DataGridSortingContext } from '../DataGridSorting/DataGridSortingContext';
const headerStyles = css`
@@ -24,6 +25,7 @@ const headerStyles = css`
align-items: center;
align-content: center;
width: 100%;
cursor: pointer;
}
shrink-container {
display: flex;
@@ -83,9 +85,10 @@ export const TableColumnHeader: React.FC<HeaderRendererProps<any>> = observer(fu
}) {
const dataGridContext = useContext(DataGridContext);
const gridSortingContext = useContext(DataGridSortingContext);
const gridSelectionContext = useContext(DataGridSelectionContext);
if (!dataGridContext || !gridSortingContext) {
throw new Error('Data grid context or sorting context are missed');
if (!dataGridContext || !gridSortingContext || !gridSelectionContext) {
throw new Error('One of the following contexts are missed(data grid context, grid sorting context, grid selection context)');
}
const model = dataGridContext.model;
@@ -99,6 +102,7 @@ export const TableColumnHeader: React.FC<HeaderRendererProps<any>> = observer(fu
const currentSortMode = gridSortingContext.getSortMode(columnName);
const handleSort = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (loading) {
return;
}
@@ -117,8 +121,12 @@ export const TableColumnHeader: React.FC<HeaderRendererProps<any>> = observer(fu
gridSortingContext.setSortMode(columnName, nextSort, e.ctrlKey || e.metaKey);
};
const handleColumnSelection = (e: React.MouseEvent<HTMLDivElement>) => {
gridSelectionContext.selectColumn(calculatedColumn.key, e.ctrlKey);
};
return styled(headerStyles)(
<table-header as="div">
<table-header as="div" onClick={handleColumnSelection}>
<shrink-container as='div'>
<icon as="div">
<StaticImage icon={column?.icon} />
@@ -0,0 +1,20 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { useContext } from 'react';
import type { HeaderRendererProps } from 'react-data-grid';
import { DataGridSelectionContext } from '../DataGridSelection/DataGridSelectionContext';
export const TableIndexColumnHeader: React.FC<HeaderRendererProps<any>> = function TableIndexColumnHeader(props) {
const selectionContext = useContext(DataGridSelectionContext);
return (
<div onClick={() => selectionContext?.selectTable()}>{props.column.name}</div>
);
};
@@ -0,0 +1,193 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { useCallback, useEffect } from 'react';
import { useObjectRef } from '@cloudbeaver/core-blocks';
interface IDraggingPosition {
idx: number;
rowIdx: number;
}
interface IMousePosition {
x: number;
y: number;
}
type DraggingCallback = (
startPosition: IDraggingPosition,
currentPosition: IDraggingPosition,
event: React.MouseEvent<HTMLDivElement, MouseEvent> | MouseEvent
) => void;
interface IDraggingState {
startDraggingCell: IDraggingPosition | null;
currentDraggingCell: IDraggingPosition | null;
startMousePosition: IMousePosition | null;
dragging: boolean;
mouseDown: boolean;
}
interface IDraggingCallbacks {
onDragStart?: (
startPosition: IDraggingPosition,
event: React.MouseEvent<HTMLDivElement, MouseEvent> | MouseEvent
) => void;
onDragOver?: DraggingCallback;
onDragEnd?: DraggingCallback;
}
const THRESHOLD = 10;
function getDelta(startPosition: IMousePosition | null, currentPosition: IMousePosition | null) {
if (!startPosition || !currentPosition) {
return null;
}
const xDelta = Math.abs(startPosition.x - currentPosition.x);
const yDelta = Math.abs(startPosition.y - currentPosition.y);
return Math.max(xDelta, yDelta);
}
function getCellPositionFromEvent(event: React.MouseEvent<HTMLDivElement, MouseEvent>) {
const target = event.target as HTMLElement;
const cell = target.closest('[role="gridcell"]') as HTMLElement | null;
if (!cell) {
return;
}
const rowIdx = cell.getAttribute('data-rowindex');
const columnIdx = cell.getAttribute('data-columnindex');
if (!rowIdx || !columnIdx) {
return;
}
return {
rowIdx: Number(rowIdx),
colIdx: Number(columnIdx),
};
}
function isDraggingStarted(delta: number | null, threshold: number) {
if (delta === null) {
return false;
}
return delta > threshold;
}
export function useGridDragging(props: IDraggingCallbacks) {
const callbacks = useObjectRef(props);
const state = useObjectRef<IDraggingState>({
startDraggingCell: null,
currentDraggingCell: null,
startMousePosition: null,
dragging: false,
mouseDown: false,
}, {});
const onMouseDownHandler = useCallback((event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const position = getCellPositionFromEvent(event);
if (!position) {
return;
}
state.mouseDown = true;
state.startMousePosition = { x: event.pageX, y: event.pageY };
state.startDraggingCell = { idx: position.colIdx, rowIdx: position.rowIdx };
}, []);
const onMouseMoveHandler = useCallback((event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!state.mouseDown) {
return;
}
const position = getCellPositionFromEvent(event);
if (!position) {
return;
}
if (!state.dragging) {
const delta = getDelta(state.startMousePosition, { x: event.pageX, y: event.pageY });
if (!isDraggingStarted(delta, THRESHOLD)) {
return;
}
if (callbacks.onDragStart && state.startDraggingCell) {
callbacks.onDragStart(state.startDraggingCell, event);
}
state.dragging = true;
return;
}
// check if the new cell is equal to the previous cell
if (position.rowIdx === state.currentDraggingCell?.rowIdx
&& position.colIdx === state.currentDraggingCell.idx) {
return;
}
state.currentDraggingCell = { idx: position.colIdx, rowIdx: position.rowIdx };
if (callbacks.onDragOver) {
callbacks.onDragOver(
{
idx: state.startDraggingCell!.idx,
rowIdx: state.startDraggingCell!.rowIdx,
},
{
idx: position.colIdx,
rowIdx: position.rowIdx,
},
event);
}
}, []);
const onMouseUpHandler = useCallback((event: React.MouseEvent<HTMLDivElement, MouseEvent> | MouseEvent) => {
state.mouseDown = false;
state.startMousePosition = null;
if (!state.dragging || !state.startDraggingCell || !state.currentDraggingCell) {
return;
}
if (callbacks.onDragEnd) {
callbacks.onDragEnd(
{
idx: state.startDraggingCell.idx,
rowIdx: state.startDraggingCell.rowIdx,
},
{
idx: state.currentDraggingCell.idx,
rowIdx: state.currentDraggingCell.rowIdx,
},
event);
}
state.dragging = false;
state.startMousePosition = null;
state.currentDraggingCell = null;
}, []);
useEffect(() => {
document.addEventListener('mouseup', onMouseUpHandler);
return () => document.removeEventListener('mouseup', onMouseUpHandler);
}, [onMouseUpHandler]);
return {
onMouseDownHandler,
onMouseMoveHandler,
};
}
@@ -6,29 +6,13 @@
* you may not use this file except in compliance with the License.
*/
import { useCallback, useContext } from 'react';
import styled, { css } from 'reshadow';
import { useCallback } from 'react';
import type { SqlResultSet } from '@cloudbeaver/core-sdk';
import { copyToClipboard } from '@cloudbeaver/core-utils';
import type { IDatabaseDataResult } from '@cloudbeaver/plugin-data-viewer';
import { DataGridSelectionContext } from './DataGridSelection/DataGridSelectionContext';
const styles = css`
grid-container {
outline: 0;
display: flex;
flex-direction: column;
flex: 1;
}
`;
interface Props {
children: React.ReactNode;
modelResultData: IDatabaseDataResult | null;
className?: string;
}
import type { IDataGridSelectionContext } from './DataGridSelection/DataGridSelectionContext';
const EVENT_KEY_CODE = {
C: 'KeyC',
@@ -72,28 +56,19 @@ function copyGridSelectedDataToClipboard(modelData: SqlResultSet, selectedCells:
// needed for event.code
type IKeyboardEvent = React.KeyboardEvent<HTMLDivElement> & KeyboardEvent;
export const DataGridTableContainer: React.FC<Props> = function DataGridTableContainer({
modelResultData, children, className,
}) {
const selectionContext = useContext(DataGridSelectionContext);
if (!selectionContext) {
throw new Error('Selection context must be provided');
}
export function useGridSelectedCellsCopy(
modelResultData: IDatabaseDataResult | null,
selectionContext: IDataGridSelectionContext | null
) {
const onKeydownHandler = useCallback((event: IKeyboardEvent) => {
if (!modelResultData) {
if (!modelResultData || !selectionContext) {
return;
}
if ((event.ctrlKey || event.metaKey) && event.code === EVENT_KEY_CODE.C) {
copyGridSelectedDataToClipboard(modelResultData.data, selectionContext.selectedCells);
}
}, [modelResultData, selectionContext.selectedCells]);
}, [modelResultData, selectionContext]);
return styled(styles)(
<grid-container as='div' tabIndex={-1} className={className} onKeyDown={onKeydownHandler}>
{children}
</grid-container>
);
};
return { onKeydownHandler };
}
@@ -0,0 +1,98 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { computed } from 'mobx';
import { useCallback, useMemo } from 'react';
import type { Column } from 'react-data-grid';
import type { SqlResultSet } from '@cloudbeaver/core-sdk';
import { TextTools } from '@cloudbeaver/core-utils';
import type { IDatabaseDataResult } from '@cloudbeaver/plugin-data-viewer';
import { ResultSetTools } from '../ResultSetTools';
import { IndexFormatter } from './Formatters/IndexFormatter';
import { TableColumnHeader } from './TableColumnHeader/TableColumnHeader';
import { TableIndexColumnHeader } from './TableColumnHeader/TableIndexColumnHeader';
export const indexColumn: Column<any[], any> = {
key: Number.MAX_SAFE_INTEGER + '',
name: '#',
minWidth: 60,
width: 60,
resizable: false,
frozen: true,
headerRenderer: TableIndexColumnHeader,
formatter: IndexFormatter,
};
export interface ITableData {
columns: Array<Column<any[], any>>;
rows: any[][];
getColumnsInRange: (startIndex: number, endIndex: number) => Array<Column<any[], any>>;
isIndexColumn: (columnKey: string) => boolean;
isIndexColumnInRange: (columnsRange: Array<Column<any[], any>>) => boolean;
getColumnKeyFromColumnIndex: (columnIndex: number) => string;
}
export function useTableData(modelResultData: IDatabaseDataResult | null): ITableData {
const { columns, rows } = useMemo(() => computed(() => {
if (!modelResultData) {
return { columns: [], rows: [] };
}
const columnNames = ResultSetTools.getHeaders(modelResultData.data as SqlResultSet);
const rowStrings = ResultSetTools.getLongestCells(modelResultData.data as SqlResultSet);
// TODO: seems better to do not measure container size
// for detecting max columns size, better to use configurable variable
const measuredCells = TextTools.getWidth({
font: '400 14px Roboto',
text: columnNames.map((cell, i) => {
if (cell.length > (rowStrings[i] || '').length) {
return cell;
}
return rowStrings[i];
}),
}).map(v => v + 16 + 32 + 20);
// TODO: we need some result type specified formatter to common actions with data
const rows = (modelResultData.data as SqlResultSet).rows || [];
const columns = (modelResultData.data as SqlResultSet).columns!.map<Column<any[], any>>((col, columnIndex) => ({
key: columnIndex + '',
name: col.label!,
width: Math.min(300, measuredCells[columnIndex]),
headerRenderer: TableColumnHeader,
}));
columns.unshift(indexColumn);
return { rows, columns };
}), [modelResultData]).get();
const isIndexColumn = useCallback((columnKey: string) => columnKey === indexColumn.key, []);
const isIndexColumnInRange = useCallback(
(columnsRange: Array<Column<any[], any>>) => columnsRange.some(column => isIndexColumn(column.key)), [isIndexColumn]
);
const getColumnKeyFromColumnIndex = useCallback((columnIndex: number) => columns[columnIndex].key, [columns]);
const getColumnsInRange = useCallback((startIndex: number, endIndex: number) => {
if (startIndex === endIndex) {
return [columns[startIndex]];
}
const firstIndex = Math.min(startIndex, endIndex);
const lastIndex = Math.max(startIndex, endIndex);
return columns.slice(firstIndex, lastIndex + 1);
}, [columns]);
return {
columns, rows, getColumnsInRange, isIndexColumn, isIndexColumnInRange, getColumnKeyFromColumnIndex,
};
}
@@ -8,6 +8,13 @@
@import "react-data-grid/dist/react-data-grid.css";
.cb-react-grid-container {
outline: 0;
display: flex;
flex-direction: column;
flex: 1;
}
.cb-react-grid-theme {
font-family: inherit;
outline: 0;