From 84cc6fcf60323d2568db75d7ca30cce763abd3ea Mon Sep 17 00:00:00 2001 From: Alexey Date: Fri, 23 May 2025 18:52:22 +0800 Subject: [PATCH] dbeaver/pro#5753 feat: react-dnd (#3454) * dbeaver/pro#5753 feat: react-dnd * chore: fix after review --------- Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com> --- .../@dbeaver/react-data-grid/package.json | 1 + .../@dbeaver/react-data-grid/src/BaseCell.tsx | 21 +++-- .../@dbeaver/react-data-grid/src/DataGrid.tsx | 76 ++++++++------- .../src/DataGridHeaderCellContext.ts | 3 + .../@dbeaver/react-data-grid/src/index.ts | 1 + .../src/mapCellContentRenderer.tsx | 6 +- .../src/mapEditCellRenderer.tsx | 6 +- .../src/mapRenderHeaderCell.tsx | 6 +- .../renderers/HeaderCellContentRenderer.tsx | 50 +++++++++- .../react-data-grid/src/reorderArray.ts | 11 +++ .../react-data-grid/src/useHeaderDnD.ts | 94 +++++++++++++++++++ .../@dbeaver/react-data-grid/tsconfig.json | 3 + common-react/@dbeaver/react-dnd/README.md | 1 + common-react/@dbeaver/react-dnd/package.json | 36 +++++++ common-react/@dbeaver/react-dnd/src/index.ts | 3 + common-react/@dbeaver/react-dnd/src/store.ts | 46 +++++++++ .../@dbeaver/react-dnd/src/useDrag.ts | 67 +++++++++++++ .../@dbeaver/react-dnd/src/useDrop.ts | 61 ++++++++++++ common-react/@dbeaver/react-dnd/tsconfig.json | 16 ++++ common-react/tailwindcss.css | 2 + common-react/yarn.lock | 13 +++ webapp/yarn.lock | 13 +++ 22 files changed, 486 insertions(+), 50 deletions(-) create mode 100644 common-react/@dbeaver/react-data-grid/src/reorderArray.ts create mode 100644 common-react/@dbeaver/react-data-grid/src/useHeaderDnD.ts create mode 100644 common-react/@dbeaver/react-dnd/README.md create mode 100644 common-react/@dbeaver/react-dnd/package.json create mode 100644 common-react/@dbeaver/react-dnd/src/index.ts create mode 100644 common-react/@dbeaver/react-dnd/src/store.ts create mode 100644 common-react/@dbeaver/react-dnd/src/useDrag.ts create mode 100644 common-react/@dbeaver/react-dnd/src/useDrop.ts create mode 100644 common-react/@dbeaver/react-dnd/tsconfig.json create mode 100644 common-react/tailwindcss.css diff --git a/common-react/@dbeaver/react-data-grid/package.json b/common-react/@dbeaver/react-data-grid/package.json index ff26991bc4..11d89c9078 100644 --- a/common-react/@dbeaver/react-data-grid/package.json +++ b/common-react/@dbeaver/react-data-grid/package.json @@ -33,6 +33,7 @@ "typescript": "^5" }, "dependencies": { + "@dbeaver/react-dnd": "workspace:^", "@dbeaver/ui-kit": "workspace:^", "react": "^19", "react-data-grid": "file:./artifacts", diff --git a/common-react/@dbeaver/react-data-grid/src/BaseCell.tsx b/common-react/@dbeaver/react-data-grid/src/BaseCell.tsx index e8ab2e6917..27a8e70699 100644 --- a/common-react/@dbeaver/react-data-grid/src/BaseCell.tsx +++ b/common-react/@dbeaver/react-data-grid/src/BaseCell.tsx @@ -4,19 +4,24 @@ import { DataGridCellContext, type IDataGridCellRenderer } from './DataGridCellC import { createCellMouseEvent } from './eventUtils.js'; import { DataGridCellInnerContext, type IDataGridCellInnerContext } from './DataGridCellInnerContext.js'; import { useGridReactiveValue } from './useGridReactiveValue.js'; +import { HeaderDnDContext } from './useHeaderDnD.js'; export const BaseCell = memo(function BaseCell(props: CellRendererProps) { const cellContext = use(DataGridCellContext); - const tooltip = useGridReactiveValue(cellContext?.cellTooltip, props.rowIdx, props.column.idx); + const dndContext = use(HeaderDnDContext)!; + const virtualColIdx = props.column.idx; + const dataColIdx = dndContext.getDataColIdx(props.column.idx); + const rowIdx = props.rowIdx; + const tooltip = useGridReactiveValue(cellContext?.cellTooltip, rowIdx, dataColIdx); function handleClick(event: React.MouseEvent) { props.onClick?.( { - rowIdx: props.rowIdx, + rowIdx, row: props.row, column: props.column, selectCell(enableEditor) { - props.selectCell({ rowIdx: props.rowIdx, idx: props.column.idx }, enableEditor); + props.selectCell({ rowIdx, idx: virtualColIdx }, enableEditor); }, }, createCellMouseEvent(event), @@ -26,11 +31,11 @@ export const BaseCell = memo(function BaseCell(props: CellRen function handleDoubleClick(event: React.MouseEvent) { props.onDoubleClick?.( { - rowIdx: props.rowIdx, + rowIdx, row: props.row, column: props.column, selectCell(enableEditor) { - props.selectCell({ rowIdx: props.rowIdx, idx: props.column.idx }, enableEditor); + props.selectCell({ rowIdx, idx: virtualColIdx }, enableEditor); }, }, createCellMouseEvent(event), @@ -40,11 +45,11 @@ export const BaseCell = memo(function BaseCell(props: CellRen function handleContextMenu(event: React.MouseEvent) { props.onContextMenu?.( { - rowIdx: props.rowIdx, + rowIdx, row: props.row, column: props.column, selectCell(enableEditor) { - props.selectCell({ rowIdx: props.rowIdx, idx: props.column.idx }, enableEditor); + props.selectCell({ rowIdx, idx: virtualColIdx }, enableEditor); }, }, createCellMouseEvent(event), @@ -74,7 +79,7 @@ export const BaseCell = memo(function BaseCell(props: CellRen [...Object.values(props), tooltip], ); - const cellElement = useGridReactiveValue(cellContext?.cellElement, props.rowIdx, props.column.idx, mappedProps, renderDefaultCell); + const cellElement = useGridReactiveValue(cellContext?.cellElement, rowIdx, dataColIdx, mappedProps, renderDefaultCell); const innerCellContext = useMemo(() => ({ isFocused: props.isCellSelected }), [props.isCellSelected]); return {cellElement ?? }; diff --git a/common-react/@dbeaver/react-data-grid/src/DataGrid.tsx b/common-react/@dbeaver/react-data-grid/src/DataGrid.tsx index 866a38d1bb..f253bf9eae 100644 --- a/common-react/@dbeaver/react-data-grid/src/DataGrid.tsx +++ b/common-react/@dbeaver/react-data-grid/src/DataGrid.tsx @@ -12,6 +12,7 @@ import { mapRenderHeaderCell } from './mapRenderHeaderCell.js'; import { mapEditCellRenderer } from './mapEditCellRenderer.js'; import { DataGridRowContext, type IDataGridRowContext } from './DataGridRowContext.js'; import './DataGrid.css'; +import { HeaderDnDContext, useHeaderDnD } from './useHeaderDnD.js'; export interface ICellPosition { rowIdx: number; @@ -46,9 +47,11 @@ export const DataGrid = forwardRef(function DataGrid headerElement, getHeaderWidth, headerText, + getHeaderOrder, getHeaderResizable, getHeaderHeight, getHeaderPinned, + getHeaderDnD, cell, cellText, cellElement, @@ -59,6 +62,7 @@ export const DataGrid = forwardRef(function DataGrid rowCount, getRowId, getRowHeight, + onHeaderReorder, onScroll, onScrollToBottom, onFocus, @@ -87,21 +91,23 @@ export const DataGrid = forwardRef(function DataGrid minWidth: 26, editable: row => getCellEditable?.(row.idx, i) ?? false, frozen: getHeaderPinned?.(i), - renderHeaderCell: mapRenderHeaderCell, - renderCell: mapCellContentRenderer, - renderEditCell: mapEditCellRenderer, + renderHeaderCell: mapRenderHeaderCell(i), + renderCell: mapCellContentRenderer(i), + renderEditCell: mapEditCellRenderer(i), }; }); + const dndHeaderContext = useHeaderDnD({ columns, onReorder: onHeaderReorder, getCanDrag: getHeaderDnD, getHeaderOrder }); + useImperativeHandle(ref, () => ({ selectCell: (position: ICellPosition) => { - innerGridRef.current?.selectCell({ idx: position.colIdx, rowIdx: position.rowIdx }); + innerGridRef.current?.selectCell({ idx: dndHeaderContext.getDataColIdx(position.colIdx), rowIdx: position.rowIdx }); }, scrollToCell: (position: Partial) => { - innerGridRef.current?.scrollToCell({ idx: position.colIdx, rowIdx: position.rowIdx }); + innerGridRef.current?.scrollToCell({ idx: position.colIdx && dndHeaderContext.getDataColIdx(position.colIdx), rowIdx: position.rowIdx }); }, openEditor: (position: ICellPosition) => { - innerGridRef.current?.selectCell({ idx: position.colIdx, rowIdx: position.rowIdx }, true); + innerGridRef.current?.selectCell({ idx: dndHeaderContext.getDataColIdx(position.colIdx), rowIdx: position.rowIdx }, true); }, })); @@ -124,34 +130,40 @@ export const DataGrid = forwardRef(function DataGrid ); function handleCellFocus(args: CellSelectArgs) { - onFocus?.({ colIdx: args.column.idx, rowIdx: args.rowIdx }); + onFocus?.({ colIdx: dndHeaderContext.getDataColIdx(args.column.idx), rowIdx: args.rowIdx }); + } + + function handleCellKeyDown(args: CellSelectArgs, event: DataGridCellKeyboardEvent) { + onCellKeyDown?.({ colIdx: dndHeaderContext.getDataColIdx(args.column.idx), rowIdx: args.rowIdx }, event); } return ( - - - - getRowHeight(row.idx) : undefined} - rowKeyGetter={getRowId ? row => getRowId(row.idx) : undefined} - onSelectedCellChange={handleCellFocus} - onCellKeyDown={onCellKeyDown ? (args, event) => onCellKeyDown({ rowIdx: args.rowIdx, colIdx: args.column.idx }, event) : undefined} - renderers={{ - renderRow: rowRenderer, - renderCell: cellRenderer, - noRowsFallback: children, - }} - minimumRowsToRender={100} - /> - - - + + + + + getRowHeight(row.idx) : undefined} + rowKeyGetter={getRowId ? row => getRowId(row.idx) : undefined} + onSelectedCellChange={handleCellFocus} + onCellKeyDown={handleCellKeyDown} + renderers={{ + renderRow: rowRenderer, + renderCell: cellRenderer, + noRowsFallback: children, + }} + minimumRowsToRender={100} + /> + + + + ); }); diff --git a/common-react/@dbeaver/react-data-grid/src/DataGridHeaderCellContext.ts b/common-react/@dbeaver/react-data-grid/src/DataGridHeaderCellContext.ts index 6eb24c4ffa..2770af7873 100644 --- a/common-react/@dbeaver/react-data-grid/src/DataGridHeaderCellContext.ts +++ b/common-react/@dbeaver/react-data-grid/src/DataGridHeaderCellContext.ts @@ -4,10 +4,13 @@ import type { IGridReactiveValue } from './IGridReactiveValue.js'; export interface IDataGridHeaderCellContext { headerElement?: IGridReactiveValue; headerText?: IGridReactiveValue; + getHeaderOrder?: () => number[]; getHeaderWidth?: (colIdx: number) => number | string | null; getHeaderResizable?: (colIdx: number) => boolean; getHeaderHeight?: () => number; getHeaderPinned?: (colIdx: number) => boolean; + getHeaderDnD?: (colIdx: number) => boolean; + onHeaderReorder?: (from: number, to: number) => void; } export const DataGridCellHeaderContext = createContext(null); diff --git a/common-react/@dbeaver/react-data-grid/src/index.ts b/common-react/@dbeaver/react-data-grid/src/index.ts index 39ddd99b97..91e5133e7a 100644 --- a/common-react/@dbeaver/react-data-grid/src/index.ts +++ b/common-react/@dbeaver/react-data-grid/src/index.ts @@ -4,6 +4,7 @@ export * from './DataGridCellInnerContext.js'; export * from './IGridReactiveValue.js'; export * from './useCreateGridReactiveValue.js'; export * from './useGridReactiveValue.js'; +export * from './reorderArray.js'; export * from './formatters/NullFormatter.js'; export * from './formatters/BooleanFormatter.js'; diff --git a/common-react/@dbeaver/react-data-grid/src/mapCellContentRenderer.tsx b/common-react/@dbeaver/react-data-grid/src/mapCellContentRenderer.tsx index 9a08ba983f..16c5121589 100644 --- a/common-react/@dbeaver/react-data-grid/src/mapCellContentRenderer.tsx +++ b/common-react/@dbeaver/react-data-grid/src/mapCellContentRenderer.tsx @@ -2,6 +2,8 @@ import type { RenderCellProps } from 'react-data-grid'; import type { IInnerRow } from './IInnerRow.js'; import { CellContentRenderer } from './renderers/CellContentRenderer.js'; -export function mapCellContentRenderer({ rowIdx, column }: RenderCellProps) { - return ; +export function mapCellContentRenderer(colIdx: number) { + return function RenderCell({ rowIdx }: RenderCellProps) { + return ; + }; } diff --git a/common-react/@dbeaver/react-data-grid/src/mapEditCellRenderer.tsx b/common-react/@dbeaver/react-data-grid/src/mapEditCellRenderer.tsx index 79f38991df..3456d4b05e 100644 --- a/common-react/@dbeaver/react-data-grid/src/mapEditCellRenderer.tsx +++ b/common-react/@dbeaver/react-data-grid/src/mapEditCellRenderer.tsx @@ -2,6 +2,8 @@ import type { RenderEditCellProps } from 'react-data-grid'; import type { IInnerRow } from './IInnerRow.js'; import { TextEditor } from './editors/TextEditor.js'; -export function mapEditCellRenderer({ rowIdx, column, onClose }: RenderEditCellProps) { - return onClose()} />; +export function mapEditCellRenderer(colIdx: number) { + return function RenderEditCell({ rowIdx, onClose }: RenderEditCellProps) { + return onClose()} />; + }; } diff --git a/common-react/@dbeaver/react-data-grid/src/mapRenderHeaderCell.tsx b/common-react/@dbeaver/react-data-grid/src/mapRenderHeaderCell.tsx index 1325e32006..6c98d86727 100644 --- a/common-react/@dbeaver/react-data-grid/src/mapRenderHeaderCell.tsx +++ b/common-react/@dbeaver/react-data-grid/src/mapRenderHeaderCell.tsx @@ -2,6 +2,8 @@ import type { RenderHeaderCellProps } from 'react-data-grid'; import type { IInnerRow } from './IInnerRow.js'; import { HeaderCellContentRenderer } from './renderers/HeaderCellContentRenderer.js'; -export function mapRenderHeaderCell({ column }: RenderHeaderCellProps) { - return ; +export function mapRenderHeaderCell(colIdx: number) { + return function RenderHeaderCell({}: RenderHeaderCellProps) { + return ; + }; } diff --git a/common-react/@dbeaver/react-data-grid/src/renderers/HeaderCellContentRenderer.tsx b/common-react/@dbeaver/react-data-grid/src/renderers/HeaderCellContentRenderer.tsx index e2f7f10688..3833e6d18c 100644 --- a/common-react/@dbeaver/react-data-grid/src/renderers/HeaderCellContentRenderer.tsx +++ b/common-react/@dbeaver/react-data-grid/src/renderers/HeaderCellContentRenderer.tsx @@ -1,13 +1,55 @@ -import { use } from 'react'; +import { memo, use } from 'react'; +import { useDrag, useDrop, type DnDStoreProvider } from '@dbeaver/react-dnd'; import { DataGridCellHeaderContext } from '../DataGridHeaderCellContext.js'; import { useGridReactiveValue } from '../useGridReactiveValue.js'; +import { HeaderDnDContext } from '../useHeaderDnD.js'; interface Props { colIdx: number; } -export function HeaderCellContentRenderer({ colIdx }: Props) { +export const HeaderCellContentRenderer = memo(function HeaderCellContentRenderer({ colIdx }: Props) { + const dndHeaderContext = use(HeaderDnDContext); const cellHeaderContext = use(DataGridCellHeaderContext); const headerElement = useGridReactiveValue(cellHeaderContext?.headerElement, colIdx); const getHeaderText = useGridReactiveValue(headerElement ? undefined : cellHeaderContext?.headerText, colIdx); - return headerElement ?? getHeaderText ?? ''; -} + + const draggable = dndHeaderContext?.getCanDrag?.(colIdx) ?? false; + const drag = useDrag({ + draggable, + onDragStart(event, store) { + if (dndHeaderContext?.id) { + store.setData('dbeaver-react-data-grid-id', dndHeaderContext?.id); + } + store.setData('dbeaver-react-data-grid-col-idx', colIdx); + }, + onDragEnd() { + dndHeaderContext?.onDragEnd(colIdx); + }, + }); + + function canDrop(event: React.DragEvent, store: DnDStoreProvider | null) { + const gridId = store?.getData('dbeaver-react-data-grid-id'); + return gridId === dndHeaderContext?.id; + } + const drop = useDrop({ + canDrop, + onDragOver(event, store) { + if (!canDrop(event, store)) { + return; + } + + const dragColIdx = store?.getData('dbeaver-react-data-grid-col-idx'); + const position = event.clientX - event.currentTarget.getBoundingClientRect().left; + const width = event.currentTarget.offsetWidth; + const isAfter = position > width / 2; + + dndHeaderContext?.onDragOver(dragColIdx, colIdx, isAfter); + }, + }); + + return ( +
+ {headerElement ?? getHeaderText ?? ''} +
+ ); +}); diff --git a/common-react/@dbeaver/react-data-grid/src/reorderArray.ts b/common-react/@dbeaver/react-data-grid/src/reorderArray.ts new file mode 100644 index 0000000000..43815c6007 --- /dev/null +++ b/common-react/@dbeaver/react-data-grid/src/reorderArray.ts @@ -0,0 +1,11 @@ +export function reorderArray(array: T[], from: number, to: number): T[] { + const newArray = [...array]; + if (from < to) { + newArray.splice(to, 0, array[from]!); + newArray.splice(from, 1); + } else { + newArray.splice(from, 1); + newArray.splice(to, 0, array[from]!); + } + return newArray; +} diff --git a/common-react/@dbeaver/react-data-grid/src/useHeaderDnD.ts b/common-react/@dbeaver/react-data-grid/src/useHeaderDnD.ts new file mode 100644 index 0000000000..f8f322530e --- /dev/null +++ b/common-react/@dbeaver/react-data-grid/src/useHeaderDnD.ts @@ -0,0 +1,94 @@ +import type { ColumnOrColumnGroup } from 'react-data-grid'; +import type { IInnerRow } from './IInnerRow.js'; +import { createContext, useId, useMemo, useState } from 'react'; +import { reorderArray } from './reorderArray.js'; + +interface IHeaderDnDOptions { + columns: Array>; + getCanDrag?: (colIdx: number) => boolean; + onReorder?: (from: number, to: number) => void; + getHeaderOrder?: () => number[]; +} + +interface IHeaderDnD { + id: string; + columns: Array>; + getDataColIdx: (virtualColIdx: number) => number; + getVirtualColIdx: (dataColIdx: number) => number; + getCanDrag?: (colIdx: number) => boolean; + onDragOver: (dragColIdx: number, dropColIdx: number, isAfter: boolean) => void; + onDragEnd: (colIdx: number) => void; +} + +export const HeaderDnDContext = createContext(null); + +export function useHeaderDnD({ columns, getCanDrag, onReorder, getHeaderOrder }: IHeaderDnDOptions): IHeaderDnD { + const id = useId(); + const [activeDnDElement, setActiveDnDElement] = useState(null); + const [dragOverElement, setDragOverElement] = useState<[number, boolean] | null>(null); + + function getDataColIdx(virtualColIdx: number) { + // Given a visible (ordered) column index, return the original data column index + const order = getHeaderOrder?.(); + if (order) { + return order[virtualColIdx] ?? virtualColIdx; + } + return virtualColIdx; + } + + function getVirtualColIdx(dataColIdx: number) { + // Given the original data column index, return its visible (ordered) index + const order = getHeaderOrder?.(); + if (order) { + return order.indexOf(dataColIdx); + } + return dataColIdx; + } + + function onDragEnd(dragColIdx: number) { + setActiveDnDElement(null); + setDragOverElement(null); + + if (dragOverElement) { + const isAfter = dragOverElement?.[1]; + let to = dragOverElement?.[0]; + + if (isAfter) { + to = getDataColIdx(getVirtualColIdx(to) + 1); + } + + onReorder?.(dragColIdx, to); + } + } + + function onDragOver(dragColIdx: number, dropColIdx: number, isAfter: boolean) { + setActiveDnDElement(dragColIdx); + if (dragColIdx !== dropColIdx) { + setDragOverElement([dropColIdx, isAfter]); + } + } + + columns = useMemo(() => columns.map((col, idx) => columns[getDataColIdx(idx)]!), [columns, getHeaderOrder]); + + if (activeDnDElement !== null && dragOverElement !== null) { + const from = getVirtualColIdx(activeDnDElement); + let to = getVirtualColIdx(dragOverElement[0]); + const isAfter = dragOverElement[1]; + + if (isAfter) { + to++; + } + + columns = reorderArray(columns, from, to); + } + + return { + id, + columns, + getDataColIdx, + getVirtualColIdx, + getCanDrag, + onDragOver, + onDragEnd, + }; +} diff --git a/common-react/@dbeaver/react-data-grid/tsconfig.json b/common-react/@dbeaver/react-data-grid/tsconfig.json index 922518f373..c3fed228a6 100644 --- a/common-react/@dbeaver/react-data-grid/tsconfig.json +++ b/common-react/@dbeaver/react-data-grid/tsconfig.json @@ -14,6 +14,9 @@ "src" ], "references": [ + { + "path": "../react-dnd" + }, { "path": "../ui-kit" } diff --git a/common-react/@dbeaver/react-dnd/README.md b/common-react/@dbeaver/react-dnd/README.md new file mode 100644 index 0000000000..bbe41e42a1 --- /dev/null +++ b/common-react/@dbeaver/react-dnd/README.md @@ -0,0 +1 @@ +# @dbeaver/react-dnd diff --git a/common-react/@dbeaver/react-dnd/package.json b/common-react/@dbeaver/react-dnd/package.json new file mode 100644 index 0000000000..e5f340fd20 --- /dev/null +++ b/common-react/@dbeaver/react-dnd/package.json @@ -0,0 +1,36 @@ +{ + "name": "@dbeaver/react-dnd", + "type": "module", + "sideEffects": [], + "exports": { + ".": "./lib/index.js", + "./*": "./lib/*.js" + }, + "scripts": { + "clear": "rimraf lib" + }, + "files": [ + "package.json", + "LICENSE", + "README.md", + "CHANGELOG.md", + "lib", + "!lib/**/*.d.ts.map", + "!lib/**/*.test.js", + "!lib/**/*.test.d.ts", + "!lib/**/*.test.d.ts.map", + "!lib/**/*.test.js.map", + "!lib/tests", + "!.tsbuildinfo" + ], + "packageManager": "yarn@4.6.0", + "devDependencies": { + "@dbeaver/tsconfig": "workspace:^", + "@types/react": "^19", + "rimraf": "^6", + "typescript": "^5" + }, + "dependencies": { + "react": "^19" + } +} diff --git a/common-react/@dbeaver/react-dnd/src/index.ts b/common-react/@dbeaver/react-dnd/src/index.ts new file mode 100644 index 0000000000..819fba8245 --- /dev/null +++ b/common-react/@dbeaver/react-dnd/src/index.ts @@ -0,0 +1,3 @@ +export * from './useDrag.js'; +export * from './useDrop.js'; +export { type DnDStoreProvider } from './store.js'; diff --git a/common-react/@dbeaver/react-dnd/src/store.ts b/common-react/@dbeaver/react-dnd/src/store.ts new file mode 100644 index 0000000000..1793f2fcdc --- /dev/null +++ b/common-react/@dbeaver/react-dnd/src/store.ts @@ -0,0 +1,46 @@ +export const DND_STORE_PREFIX = 'application/dbeaver-react-dnd-'; + +export interface DnDStoreProvider { + getData: (key: string) => any; + setData: (key: string, value: any) => void; + removeData: () => void; +} + +class DnDStore { + private data: Map> = new Map(); + setData(id: string, key: string, value: any) { + if (!this.data.has(id)) { + this.data.set(id, {}); + } + this.data.get(id)![key] = value; + } + getData(id: string, key: string) { + return this.data.get(id)?.[key]; + } + removeData(id: string) { + this.data.delete(id); + } + + getProvider(id: string) { + return { + getData: (key: string) => this.getData(id, key), + setData: (key: string, value: any) => { + this.setData(id, key, value); + }, + removeData: () => { + this.removeData(id); + }, + }; + } +} + +export function getStoreProvider(event: React.DragEvent): DnDStoreProvider | null { + const id = event.dataTransfer.types.find(type => type.startsWith(DND_STORE_PREFIX))?.slice(DND_STORE_PREFIX.length); + + return id ? globalDnDStore.getProvider(id) : null; +} + +// TODO: do we want to use react context here? +// the main idea was to use native DataTransfer, but it's available only for drag start and drop events +// so we need to use a custom store +export const globalDnDStore = new DnDStore(); diff --git a/common-react/@dbeaver/react-dnd/src/useDrag.ts b/common-react/@dbeaver/react-dnd/src/useDrag.ts new file mode 100644 index 0000000000..200367b99f --- /dev/null +++ b/common-react/@dbeaver/react-dnd/src/useDrag.ts @@ -0,0 +1,67 @@ +import { useId, useMemo, useRef, useState } from 'react'; +import { DND_STORE_PREFIX, globalDnDStore, type DnDStoreProvider } from './store.js'; + +interface IDragOptions { + draggable?: boolean; + onDrag?: (event: React.DragEvent, provider: DnDStoreProvider) => void; + onDragStart?: (event: React.DragEvent, provider: DnDStoreProvider) => void; + onDragEnd?: (event: React.DragEvent, provider: DnDStoreProvider) => void; +} +interface IDrag { + isDragging: boolean; + props: { + draggable?: boolean; + onDrag: (event: React.DragEvent) => void; + onDragStart: (event: React.DragEvent) => void; + onDragEnd: (event: React.DragEvent) => void; + }; +} + +export function useDrag({ draggable, onDragStart, onDrag, onDragEnd }: IDragOptions = {}): IDrag { + const id = useId(); + const [dragging, setDragging] = useState(false); + const optionsRef = useRef({ draggable, onDragStart, onDrag, onDragEnd }); + optionsRef.current = { draggable, onDragStart, onDrag, onDragEnd }; + + function handleDragStart(event: React.DragEvent) { + if (!optionsRef.current.draggable) { + return; + } + event.stopPropagation(); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData(`${DND_STORE_PREFIX}${id}`, id); + setDragging(true); + optionsRef.current.onDragStart?.(event, globalDnDStore.getProvider(id)); + } + + function handleDrag(event: React.DragEvent) { + if (!optionsRef.current.draggable) { + return; + } + optionsRef.current.onDrag?.(event, globalDnDStore.getProvider(id)); + } + + function handleDragEnd(event: React.DragEvent) { + if (!optionsRef.current.draggable) { + return; + } + setDragging(false); + optionsRef.current.onDragEnd?.(event, globalDnDStore.getProvider(id)); + globalDnDStore.removeData(id); + } + + return { + isDragging: dragging, + props: { + draggable, + ...useMemo( + () => ({ + onDrag: handleDrag, + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, + }), + [], + ), + }, + }; +} diff --git a/common-react/@dbeaver/react-dnd/src/useDrop.ts b/common-react/@dbeaver/react-dnd/src/useDrop.ts new file mode 100644 index 0000000000..94adf6440d --- /dev/null +++ b/common-react/@dbeaver/react-dnd/src/useDrop.ts @@ -0,0 +1,61 @@ +import { useMemo, useRef } from 'react'; +import { getStoreProvider, type DnDStoreProvider } from './store.js'; + +interface IDropOptions { + canDrop?: (event: React.DragEvent, provider: DnDStoreProvider | null) => boolean; + onDrop?: (event: React.DragEvent, provider: DnDStoreProvider | null) => void; + onDragOver?: (event: React.DragEvent, provider: DnDStoreProvider | null) => void; + onDragEnter?: (event: React.DragEvent, provider: DnDStoreProvider | null) => void; + onDragLeave?: (event: React.DragEvent, provider: DnDStoreProvider | null) => void; +} + +interface IDrop { + props: { + onDrop: (event: React.DragEvent) => void; + onDragOver: (event: React.DragEvent) => void; + onDragEnter: (event: React.DragEvent) => void; + onDragLeave: (event: React.DragEvent) => void; + }; +} + +export function useDrop({ canDrop, onDrop, onDragOver, onDragEnter, onDragLeave }: IDropOptions = {}): IDrop { + const optionsRef = useRef({ canDrop, onDrop, onDragOver, onDragEnter, onDragLeave }); + optionsRef.current = { canDrop, onDrop, onDragOver, onDragEnter, onDragLeave }; + + function handleOver(event: React.DragEvent) { + const provider = getStoreProvider(event); + const isDroppable = optionsRef.current.canDrop?.(event, provider); + if (isDroppable) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + } else { + event.dataTransfer.dropEffect = 'none'; + } + optionsRef.current.onDragOver?.(event, provider); + } + + function handleEnter(event: React.DragEvent) { + const provider = getStoreProvider(event); + optionsRef.current.onDragEnter?.(event, provider); + } + function handleLeave(event: React.DragEvent) { + const provider = getStoreProvider(event); + optionsRef.current.onDragLeave?.(event, provider); + } + function handleDrop(event: React.DragEvent) { + const provider = getStoreProvider(event); + optionsRef.current.onDrop?.(event, provider); + } + + return { + props: useMemo( + () => ({ + onDrop: handleDrop, + onDragOver: handleOver, + onDragEnter: handleEnter, + onDragLeave: handleLeave, + }), + [], + ), + }; +} diff --git a/common-react/@dbeaver/react-dnd/tsconfig.json b/common-react/@dbeaver/react-dnd/tsconfig.json new file mode 100644 index 0000000000..9b5f89e66a --- /dev/null +++ b/common-react/@dbeaver/react-dnd/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@dbeaver/tsconfig/tsconfig.webview.json", + "compilerOptions": { + "tsBuildInfoFile": "./lib/.tsbuildinfo", + "rootDir": "src", + "rootDirs": [ + "src" + ], + "outDir": "lib", + "incremental": true, + "composite": true + }, + "include": [ + "src" + ] +} diff --git a/common-react/tailwindcss.css b/common-react/tailwindcss.css new file mode 100644 index 0000000000..4469cbc7b9 --- /dev/null +++ b/common-react/tailwindcss.css @@ -0,0 +1,2 @@ +/* this file required for tailwindcss vscode extension to work */ +@import './@dbeaver/ui-kit/.ladle/global.css'; diff --git a/common-react/yarn.lock b/common-react/yarn.lock index a9208fb902..650056dbd1 100644 --- a/common-react/yarn.lock +++ b/common-react/yarn.lock @@ -348,6 +348,7 @@ __metadata: version: 0.0.0-use.local resolution: "@dbeaver/react-data-grid@workspace:@dbeaver/react-data-grid" dependencies: + "@dbeaver/react-dnd": "workspace:^" "@dbeaver/tsconfig": "workspace:^" "@dbeaver/ui-kit": "workspace:^" "@types/react": "npm:^19" @@ -360,6 +361,18 @@ __metadata: languageName: unknown linkType: soft +"@dbeaver/react-dnd@workspace:@dbeaver/react-dnd, @dbeaver/react-dnd@workspace:^": + version: 0.0.0-use.local + resolution: "@dbeaver/react-dnd@workspace:@dbeaver/react-dnd" + dependencies: + "@dbeaver/tsconfig": "workspace:^" + "@types/react": "npm:^19" + react: "npm:^19" + rimraf: "npm:^6" + typescript: "npm:^5" + languageName: unknown + linkType: soft + "@dbeaver/result-set-api@workspace:../common-typescript/@dbeaver/result-set-api": version: 0.0.0-use.local resolution: "@dbeaver/result-set-api@workspace:../common-typescript/@dbeaver/result-set-api" diff --git a/webapp/yarn.lock b/webapp/yarn.lock index d1a7c66e0d..b76cca1ffa 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -4491,6 +4491,7 @@ __metadata: version: 0.0.0-use.local resolution: "@dbeaver/react-data-grid@workspace:../common-react/@dbeaver/react-data-grid" dependencies: + "@dbeaver/react-dnd": "workspace:^" "@dbeaver/tsconfig": "workspace:^" "@dbeaver/ui-kit": "workspace:^" "@types/react": "npm:^19" @@ -4503,6 +4504,18 @@ __metadata: languageName: unknown linkType: soft +"@dbeaver/react-dnd@workspace:../common-react/@dbeaver/react-dnd, @dbeaver/react-dnd@workspace:^": + version: 0.0.0-use.local + resolution: "@dbeaver/react-dnd@workspace:../common-react/@dbeaver/react-dnd" + dependencies: + "@dbeaver/tsconfig": "workspace:^" + "@types/react": "npm:^19" + react: "npm:^19" + rimraf: "npm:^6" + typescript: "npm:^5" + languageName: unknown + linkType: soft + "@dbeaver/result-set-api@workspace:../common-typescript/@dbeaver/result-set-api, @dbeaver/result-set-api@workspace:^": version: 0.0.0-use.local resolution: "@dbeaver/result-set-api@workspace:../common-typescript/@dbeaver/result-set-api"