diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx index a0fb9e90a6..bb61a26b0f 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx @@ -36,8 +36,8 @@ export const CellRenderer: React.FC> = observer(function classes.push('rdg-cell-custom-edited'); } - const handleClick = useCallback((event: React.MouseEvent) => { - selectionContext?.select(column.key, rowIdx, event.ctrlKey, event.shiftKey); + const handleMouseDown = useCallback((event: React.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> = observer(function return ( ; - 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(null); diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridSelection/useGridSelectionContext.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridSelection/useGridSelectionContext.tsx index 59d5e31290..744f35e20b 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridSelection/useGridSelectionContext.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridSelection/useGridSelectionContext.tsx @@ -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()); + const [temporarySelectedCells] = useState(() => observable.map()); const [lastSelectedCell, setLastSelectedCell] = useState(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; } diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTable.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTable.tsx index f93dd18b33..1623e2f813 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTable.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTable.tsx @@ -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; @@ -47,25 +43,18 @@ function isAtBottom(event: React.UIEvent): boolean { return target.clientHeight + target.scrollTop === target.scrollHeight; } -const indexColumn: Column = { - key: Number.MAX_SAFE_INTEGER + '', - name: '#', - minWidth: 60, - width: 60, - resizable: false, - frozen: true, - formatter: IndexFormatter, -}; - export const DataGridTable: React.FC = observer(function DataGridTable({ model, resultIndex, className }) { + const dataGridRef = useRef(null); const editorRef = useRef(null); const styles = useStyles(reactGridStyles, baseStyles); const [columnResize] = useState(() => new Executor()); 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 = 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 = 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>((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(() => ({ model, columnResize, resultIndex, getEditorPortal: () => editorRef.current, - }), [model, resultIndex, editorRef]); + getDataGridApi: () => dataGridRef.current, + }), [model, resultIndex, editorRef, dataGridRef]); return styled(styles)( - + = observer(function DataGridTable({ onScroll={handleScroll} />
- + diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/TableColumnHeader/TableColumnHeader.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/TableColumnHeader/TableColumnHeader.tsx index 5711c245d6..4f8131426c 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/TableColumnHeader/TableColumnHeader.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/TableColumnHeader/TableColumnHeader.tsx @@ -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> = 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> = observer(fu const currentSortMode = gridSortingContext.getSortMode(columnName); const handleSort = (e: React.MouseEvent) => { + e.stopPropagation(); if (loading) { return; } @@ -117,8 +121,12 @@ export const TableColumnHeader: React.FC> = observer(fu gridSortingContext.setSortMode(columnName, nextSort, e.ctrlKey || e.metaKey); }; + const handleColumnSelection = (e: React.MouseEvent) => { + gridSelectionContext.selectColumn(calculatedColumn.key, e.ctrlKey); + }; + return styled(headerStyles)( - + diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/TableColumnHeader/TableIndexColumnHeader.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/TableColumnHeader/TableIndexColumnHeader.tsx new file mode 100644 index 0000000000..f9be7b6f5c --- /dev/null +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/TableColumnHeader/TableIndexColumnHeader.tsx @@ -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> = function TableIndexColumnHeader(props) { + const selectionContext = useContext(DataGridSelectionContext); + + return ( +
selectionContext?.selectTable()}>{props.column.name}
+ ); +}; diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridDragging.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridDragging.ts new file mode 100644 index 0000000000..e86f9f467b --- /dev/null +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridDragging.ts @@ -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 | 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 | 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) { + 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({ + startDraggingCell: null, + currentDraggingCell: null, + startMousePosition: null, + dragging: false, + mouseDown: false, + }, {}); + + const onMouseDownHandler = useCallback((event: React.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) => { + 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 | 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, + }; +} diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTableContainer.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridSelectedCellsCopy.ts similarity index 63% rename from webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTableContainer.tsx rename to webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridSelectedCellsCopy.ts index 354e55dfc0..fa3c2fb112 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridTableContainer.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridSelectedCellsCopy.ts @@ -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 & KeyboardEvent; -export const DataGridTableContainer: React.FC = 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)( - - {children} - - ); -}; + return { onKeydownHandler }; +} diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useTableData.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useTableData.ts new file mode 100644 index 0000000000..a2b8ed51d7 --- /dev/null +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useTableData.ts @@ -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 = { + key: Number.MAX_SAFE_INTEGER + '', + name: '#', + minWidth: 60, + width: 60, + resizable: false, + frozen: true, + headerRenderer: TableIndexColumnHeader, + formatter: IndexFormatter, +}; + +export interface ITableData { + columns: Array>; + rows: any[][]; + getColumnsInRange: (startIndex: number, endIndex: number) => Array>; + isIndexColumn: (columnKey: string) => boolean; + isIndexColumnInRange: (columnsRange: Array>) => 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>((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>) => 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, + }; +} diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/styles/base.scss b/webapp/packages/plugin-data-spreadsheet-new/src/styles/base.scss index af2491586e..7e1cca6ad8 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/styles/base.scss +++ b/webapp/packages/plugin-data-spreadsheet-new/src/styles/base.scss @@ -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;