diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index fbfa3a3e..6ed3b7af 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -19,10 +19,15 @@ import { import { SortableContext, useSortable, - horizontalListSortingStrategy, - arrayMove + horizontalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { + DATA_GRID_COLUMN_ORDER_DRAG_MIME, + decodeDataGridColumnOrderDragPayload, + hasDataGridColumnOrderDragPayload, + moveDataGridColumnInVisibleOrder, +} from './dataGridColumnOrder'; import { ImportData, ExportDataWithOptions, ExportQueryWithOptions, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App'; import ImportPreviewModal from './ImportPreviewModal'; import { useStore } from '../store'; @@ -576,42 +581,29 @@ const DataGrid: React.FC = ({ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), ); - const handleDragEnd = (event: DragEndEvent) => { - // 防御性检查:若正在调整列宽,忽略拖拽排序事件 - if (isResizingRef.current) return; - const { active, over } = event; - if (active.id !== over?.id && over) { + const columnOrderDragScopeRef = useRef(generateUuid()); + const reorderVisibleColumns = useCallback((sourceColumnName: string, targetColumnName: string) => { setAllOrderedColumnNames((prevAllOrder) => { - // Calculate the new order of all columns by applying the movement - // We only move the visible columns relative to each other, but the easiest way - // is to map the visible column movement back to the full array. - const hiddenSet = new Set(localHiddenColumns); - const visibleOrder = prevAllOrder.filter(col => !hiddenSet.has(col)); - - const oldVisibleIndex = visibleOrder.indexOf(active.id as string); - const newVisibleIndex = visibleOrder.indexOf(over.id as string); - - if (oldVisibleIndex === -1 || newVisibleIndex === -1) return prevAllOrder; - - const nextVisibleOrder = arrayMove(visibleOrder, oldVisibleIndex, newVisibleIndex); - - // Reconstruct allOrderedColumnNames by inserting hidden columns back to their original relative positions - // Or simpler: just keep hidden columns at the end, but that ruins user's layout. - // Better approach: build a new array - let vIndex = 0; - const nextOrder = prevAllOrder.map(col => { - if (hiddenSet.has(col)) { - return col; // Hidden columns stay at their absolute index in the master list - } else { - return nextVisibleOrder[vIndex++]; - } - }); - + const nextOrder = moveDataGridColumnInVisibleOrder( + prevAllOrder, + new Set(localHiddenColumns), + sourceColumnName, + targetColumnName, + ); + if (nextOrder === prevAllOrder) return prevAllOrder; if (enableColumnOrderMemory && connectionId && dbName && tableName) { setTableColumnOrder(connectionId, dbName, tableName, nextOrder); } return nextOrder; }); + }, [connectionId, dbName, enableColumnOrderMemory, localHiddenColumns, setTableColumnOrder, tableName]); + + const handleDragEnd = (event: DragEndEvent) => { + // 防御性检查:若正在调整列宽,忽略拖拽排序事件 + if (isResizingRef.current) return; + const { active, over } = event; + if (active.id !== over?.id && over) { + reorderVisibleColumns(String(active.id), String(over.id)); } }; @@ -3110,11 +3102,28 @@ const DataGrid: React.FC = ({ }, onHeaderCell: (column: any) => ({ id: key, + columnOrderDragScope: columnOrderDragScopeRef.current, width: column.width, className: `gonavi-sortable-header-cell${showColumnComment || showColumnType ? '' : ' is-single-line-title'}`, 'data-i18n-language': language, onResizeStart: handleResizeStart(key), // Only need start onResizeAutoFit: handleResizeAutoFit(key), + onDragOver: (event: React.DragEvent) => { + if (!hasDataGridColumnOrderDragPayload(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + }, + onDrop: (event: React.DragEvent) => { + if (!hasDataGridColumnOrderDragPayload(event.dataTransfer)) return; + const payload = decodeDataGridColumnOrderDragPayload( + event.dataTransfer.getData(DATA_GRID_COLUMN_ORDER_DRAG_MIME), + ); + if (!payload || payload.scope !== columnOrderDragScopeRef.current) return; + event.preventDefault(); + event.stopPropagation(); + reorderVisibleColumns(payload.columnName, key); + }, onContextMenu: (event: React.MouseEvent) => { if (!isV2Ui) return; showColumnHeaderContextMenu(event, key); @@ -3155,7 +3164,7 @@ const DataGrid: React.FC = ({ }, }), })); - }, [canModifyData, cellEditMode, columnWidths, currentConnConfig, dataTableDensity, displayColumnNames, displayColumnTypeMap, effectiveEditLocator, enableVirtual, handleResizeAutoFit, handleResizeStart, isV2Ui, language, normalizedPageFindText, onSort, pinnedLeftColumnSet, renderColumnTitle, selectEditableColumnCells, showColumnComment, showColumnHeaderContextMenu, showColumnType, sortInfo]); + }, [canModifyData, cellEditMode, columnWidths, currentConnConfig, dataTableDensity, displayColumnNames, displayColumnTypeMap, effectiveEditLocator, enableVirtual, handleResizeAutoFit, handleResizeStart, isV2Ui, language, normalizedPageFindText, onSort, pinnedLeftColumnSet, renderColumnTitle, reorderVisibleColumns, selectEditableColumnCells, showColumnComment, showColumnHeaderContextMenu, showColumnType, sortInfo]); const mergedColumns = useMemo(() => columns.map((col): ColumnType => { const dataIndex = String(col.dataIndex); diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index b0b688ed..79a74210 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -77,6 +77,11 @@ import { DEFAULT_SHORTCUT_OPTIONS, getShortcutPlatform, resolveShortcutDisplay } import { formatMongoValueForDisplay } from '../utils/mongodb'; import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; import { SQL_FIELD_DRAG_MIME } from '../utils/sqlFieldDrop'; +import { + DATA_GRID_COLUMN_ORDER_DRAG_MIME, + encodeDataGridColumnOrderDragPayload, + shouldBypassDndKitForNativeColumnHeaderDrag, +} from './dataGridColumnOrder'; import { TEMPORAL_FORMATS, formatFromDayjs, @@ -784,6 +789,7 @@ const ResizableTitle = React.forwardRef((props, ref) // --- Sortable Header Cell --- interface SortableHeaderCellProps extends React.HTMLAttributes { id?: string; + columnOrderDragScope?: string; } // --- Sortable Header Cell --- @@ -817,7 +823,7 @@ const sortableHeaderStaticStyles = ` `; const SortableHeaderCell: React.FC = React.memo((props) => { - const { id, children, style: propStyle, className: propClassName, ...restProps } = props; + const { id, children, style: propStyle, className: propClassName, columnOrderDragScope, ...restProps } = props; const [isPressed, setIsPressed] = useState(false); const { attributes, @@ -875,6 +881,12 @@ const SortableHeaderCell: React.FC = React.memo((props) {...listeners} onPointerDown={(e: any) => { setIsPressed(true); + if ( + (e.target as HTMLElement | null)?.closest?.('.sortable-header-cell-drag-handle') + && shouldBypassDndKitForNativeColumnHeaderDrag(e.pointerType) + ) { + return; + } if (listeners?.onPointerDown) listeners.onPointerDown(e); }} > @@ -887,13 +899,22 @@ const SortableHeaderCell: React.FC = React.memo((props) const columnName = String(id || '').trim(); if (!columnName || !event.dataTransfer) return; event.stopPropagation(); - event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.effectAllowed = 'copyMove'; const payload = encodeSidebarSqlEditorDragPayload({ text: columnName, nodeType: 'column', }); event.dataTransfer.setData(SIDEBAR_SQL_EDITOR_DRAG_MIME, payload); event.dataTransfer.setData(SQL_FIELD_DRAG_MIME, columnName); + if (columnOrderDragScope) { + event.dataTransfer.setData( + DATA_GRID_COLUMN_ORDER_DRAG_MIME, + encodeDataGridColumnOrderDragPayload({ + scope: columnOrderDragScope, + columnName, + }), + ); + } event.dataTransfer.setData('text/plain', columnName); }} > diff --git a/frontend/src/components/dataGridColumnOrder.test.ts b/frontend/src/components/dataGridColumnOrder.test.ts new file mode 100644 index 00000000..d3f241cb --- /dev/null +++ b/frontend/src/components/dataGridColumnOrder.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { + DATA_GRID_COLUMN_ORDER_DRAG_MIME, + decodeDataGridColumnOrderDragPayload, + encodeDataGridColumnOrderDragPayload, + hasDataGridColumnOrderDragPayload, + moveDataGridColumnInVisibleOrder, + shouldBypassDndKitForNativeColumnHeaderDrag, +} from './dataGridColumnOrder'; + +describe('dataGridColumnOrder helpers', () => { + it('reorders a dragged visible column at the header drop target while hidden columns keep their slot', () => { + expect(moveDataGridColumnInVisibleOrder( + ['id', 'hidden_note', 'name', 'code'], + new Set(['hidden_note']), + 'code', + 'id', + )).toEqual(['code', 'hidden_note', 'id', 'name']); + }); + + it('keeps the column reorder payload scoped to its source result set', () => { + const payload = encodeDataGridColumnOrderDragPayload({ + scope: 'result-set-1', + columnName: 'title', + }); + + expect(decodeDataGridColumnOrderDragPayload(payload)).toEqual({ + scope: 'result-set-1', + columnName: 'title', + }); + expect(hasDataGridColumnOrderDragPayload({ + types: [DATA_GRID_COLUMN_ORDER_DRAG_MIME], + })).toBe(true); + expect(decodeDataGridColumnOrderDragPayload('{')).toBeNull(); + }); + + it('keeps touch and pen pointer input on dnd-kit instead of native HTML drag', () => { + expect(shouldBypassDndKitForNativeColumnHeaderDrag('mouse')).toBe(true); + expect(shouldBypassDndKitForNativeColumnHeaderDrag('touch')).toBe(false); + expect(shouldBypassDndKitForNativeColumnHeaderDrag('pen')).toBe(false); + }); +}); diff --git a/frontend/src/components/dataGridColumnOrder.ts b/frontend/src/components/dataGridColumnOrder.ts new file mode 100644 index 00000000..ec97f0a8 --- /dev/null +++ b/frontend/src/components/dataGridColumnOrder.ts @@ -0,0 +1,57 @@ +import { arrayMove } from '@dnd-kit/sortable'; + +export const DATA_GRID_COLUMN_ORDER_DRAG_MIME = 'application/x-gonavi-data-grid-column-order'; + +export type DataGridColumnOrderDragPayload = { + scope: string; + columnName: string; +}; + +export const encodeDataGridColumnOrderDragPayload = ( + payload: DataGridColumnOrderDragPayload, +): string => JSON.stringify(payload); + +export const decodeDataGridColumnOrderDragPayload = ( + rawPayload: string, +): DataGridColumnOrderDragPayload | null => { + try { + const parsed = JSON.parse(String(rawPayload || '')); + const scope = String(parsed?.scope || '').trim(); + const columnName = String(parsed?.columnName || '').trim(); + return scope && columnName ? { scope, columnName } : null; + } catch { + return null; + } +}; + +export const hasDataGridColumnOrderDragPayload = ( + dataTransfer: Pick | null | undefined, +): boolean => Array.from(dataTransfer?.types || []) + .some((type) => String(type || '').toLowerCase() === DATA_GRID_COLUMN_ORDER_DRAG_MIME); + +// Native HTML drag handles mouse reliably; touch and pen need dnd-kit's PointerSensor. +export const shouldBypassDndKitForNativeColumnHeaderDrag = (pointerType: string): boolean => ( + pointerType === 'mouse' +); + +export const moveDataGridColumnInVisibleOrder = ( + allColumnNames: string[], + hiddenColumnNames: ReadonlySet, + sourceColumnName: string, + targetColumnName: string, +): string[] => { + const source = String(sourceColumnName || '').trim(); + const target = String(targetColumnName || '').trim(); + if (!source || !target || source === target) return allColumnNames; + + const visibleColumnNames = allColumnNames.filter((columnName) => !hiddenColumnNames.has(columnName)); + const sourceIndex = visibleColumnNames.indexOf(source); + const targetIndex = visibleColumnNames.indexOf(target); + if (sourceIndex < 0 || targetIndex < 0) return allColumnNames; + + const nextVisibleColumnNames = arrayMove(visibleColumnNames, sourceIndex, targetIndex); + let visibleIndex = 0; + return allColumnNames.map((columnName) => ( + hiddenColumnNames.has(columnName) ? columnName : nextVisibleColumnNames[visibleIndex++] + )); +};