@mui/x-data-grid-premium
Version:
The Premium plan edition of the MUI X Data Grid Components.
1,283 lines (1,237 loc) • 53 kB
JavaScript
'use client';
import _extends from "@babel/runtime/helpers/esm/extends";
import * as React from 'react';
import ownerDocument from '@mui/utils/ownerDocument';
import useEventCallback from '@mui/utils/useEventCallback';
import { getGridCellElement, getTotalHeaderHeight, getVisibleRows, isFillDownShortcut, isFillRightShortcut, isNavigationKey, serializeCellValue, useGridRegisterPipeProcessor } from '@mui/x-data-grid-pro/internals';
import { useGridEvent, useGridApiMethod, GRID_ACTIONS_COLUMN_TYPE, GRID_CHECKBOX_SELECTION_COL_DEF, GRID_DETAIL_PANEL_TOGGLE_FIELD, gridClasses, gridFocusCellSelector, GRID_REORDER_COL_DEF, gridSortedRowIdsSelector, gridDimensionsSelector, GridCellModes } from '@mui/x-data-grid-pro';
import { gridCellSelectionStateSelector } from "./gridCellSelectionSelector.mjs";
import { CellValueUpdater } from "../clipboard/useGridClipboardImport.mjs";
export const cellSelectionStateInitializer = (state, props) => _extends({}, state, {
cellSelection: _extends({}, props.cellSelectionModel ?? props.initialState?.cellSelection)
});
function isKeyboardEvent(event) {
return !!event.key;
}
const AUTO_SCROLL_SENSITIVITY = 50; // The distance from the edge to start scrolling
const AUTO_SCROLL_SPEED = 20; // The speed to scroll once the mouse enters the sensitivity area
const FILL_HANDLE_HIT_AREA = 16; // px — size of the interactive hit area for the fill handle
function getSelectedOrFocusedCells(apiRef) {
let selectedCells = apiRef.current.getSelectedCellsAsArray();
if (selectedCells.length === 0) {
const focusedCell = gridFocusCellSelector(apiRef);
if (focusedCell) {
selectedCells = [{
id: focusedCell.id,
field: focusedCell.field
}];
}
}
return selectedCells;
}
function createInitialFillDragState() {
return {
isDragging: false,
direction: null,
targetRowIds: [],
targetFields: [],
decoratedElements: new Set(),
moveRAF: null,
doc: null,
moveHandler: null,
upHandler: null
};
}
export const useGridCellSelection = (apiRef, props) => {
const hasRootReference = apiRef.current.rootElementRef.current !== null;
const cellWithVirtualFocus = React.useRef(null);
const lastMouseDownCell = React.useRef(null);
const mousePosition = React.useRef({
x: 0,
y: 0
});
const autoScrollRAF = React.useRef(null);
const totalHeaderHeight = getTotalHeaderHeight(apiRef, props);
// Fill handle state — grouped by lifecycle:
// fillSource: set on mousedown, read-only during drag, cleared on mouseup
// fillDrag: managed during active drag, reset on mouseup
const fillSource = React.useRef(null);
const fillDrag = React.useRef(createInitialFillDragState());
const skipNextCellClick = React.useRef(false);
const ignoreValueFormatterProp = props.ignoreValueFormatterDuringExport;
const ignoreValueFormatter = (typeof ignoreValueFormatterProp === 'object' ? ignoreValueFormatterProp?.clipboardExport : ignoreValueFormatterProp) || false;
const clipboardCopyCellDelimiter = props.clipboardCopyCellDelimiter;
apiRef.current.registerControlState({
stateId: 'cellSelection',
propModel: props.cellSelectionModel,
propOnChange: props.onCellSelectionModelChange,
stateSelector: gridCellSelectionStateSelector,
changeEvent: 'cellSelectionChange'
});
const runIfCellSelectionIsEnabled = callback => (...args) => {
if (props.cellSelection) {
callback(...args);
}
};
const isCellSelected = React.useCallback((id, field) => {
if (!props.cellSelection) {
return false;
}
const cellSelectionModel = gridCellSelectionStateSelector(apiRef);
return cellSelectionModel[id] ? !!cellSelectionModel[id][field] : false;
}, [apiRef, props.cellSelection]);
const getCellSelectionModel = React.useCallback(() => {
return gridCellSelectionStateSelector(apiRef);
}, [apiRef]);
const setCellSelectionModel = React.useCallback(newModel => {
if (!props.cellSelection) {
return;
}
apiRef.current.setState(prevState => _extends({}, prevState, {
cellSelection: newModel
}));
}, [apiRef, props.cellSelection]);
const selectCellRange = React.useCallback((start, end, keepOtherSelected = false) => {
const startRowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(start.id);
const startColumnIndex = apiRef.current.getColumnIndex(start.field);
const endRowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(end.id);
const endColumnIndex = apiRef.current.getColumnIndex(end.field);
let finalStartRowIndex = startRowIndex;
let finalStartColumnIndex = startColumnIndex;
let finalEndRowIndex = endRowIndex;
let finalEndColumnIndex = endColumnIndex;
if (finalStartRowIndex > finalEndRowIndex) {
finalStartRowIndex = endRowIndex;
finalEndRowIndex = startRowIndex;
}
if (finalStartColumnIndex > finalEndColumnIndex) {
finalStartColumnIndex = endColumnIndex;
finalEndColumnIndex = startColumnIndex;
}
const visibleColumns = apiRef.current.getVisibleColumns();
const visibleRows = getVisibleRows(apiRef);
const rowsInRange = visibleRows.rows.slice(finalStartRowIndex, finalEndRowIndex + 1);
const columnsInRange = visibleColumns.slice(finalStartColumnIndex, finalEndColumnIndex + 1);
const newModel = keepOtherSelected ? _extends({}, apiRef.current.getCellSelectionModel()) : {};
rowsInRange.forEach(row => {
if (!newModel[row.id]) {
newModel[row.id] = {};
}
columnsInRange.forEach(column => {
newModel[row.id][column.field] = true;
}, {});
});
apiRef.current.setCellSelectionModel(newModel);
}, [apiRef]);
const getSelectedCellsAsArray = React.useCallback(() => {
const selectionModel = apiRef.current.getCellSelectionModel();
const currentVisibleRows = getVisibleRows(apiRef, props);
const sortedEntries = currentVisibleRows.rows.reduce((result, row) => {
if (row.id in selectionModel) {
result.push([row.id, selectionModel[row.id]]);
}
return result;
}, []);
return sortedEntries.reduce((selectedCells, [id, fields]) => {
selectedCells.push(...Object.entries(fields).reduce((selectedFields, [field, isSelected]) => {
if (isSelected) {
selectedFields.push({
id,
field
});
}
return selectedFields;
}, []));
return selectedCells;
}, []);
}, [apiRef, props]);
const cellSelectionApi = {
isCellSelected,
getCellSelectionModel,
setCellSelectionModel,
selectCellRange,
getSelectedCellsAsArray
};
useGridApiMethod(apiRef, cellSelectionApi, 'public');
const hasClickedValidCellForRangeSelection = React.useCallback(params => {
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
return false;
}
if (params.field === GRID_DETAIL_PANEL_TOGGLE_FIELD) {
return false;
}
const column = apiRef.current.getColumn(params.field);
if (column.type === GRID_ACTIONS_COLUMN_TYPE) {
return false;
}
return params.rowNode.type !== 'pinnedRow';
}, [apiRef]);
const handleMouseUp = useEventCallback(() => {
lastMouseDownCell.current = null;
apiRef.current.rootElementRef?.current?.classList.remove(gridClasses['root--disableUserSelection']);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
stopAutoScroll();
});
const handleCellMouseDown = React.useCallback((params, event) => {
// Skip if the click comes from the right-button or, only on macOS, Ctrl is pressed
// Fix for https://github.com/mui/mui-x/pull/6567#issuecomment-1329155578
const isMacOs = window.navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (event.button !== 0 || event.ctrlKey && isMacOs) {
return;
}
if (params.field === GRID_REORDER_COL_DEF.field) {
return;
}
const focusedCell = gridFocusCellSelector(apiRef);
if (hasClickedValidCellForRangeSelection(params) && event.shiftKey && focusedCell) {
event.preventDefault();
}
lastMouseDownCell.current = {
id: params.id,
field: params.field
};
apiRef.current.rootElementRef?.current?.classList.add(gridClasses['root--disableUserSelection']);
const document = ownerDocument(apiRef.current.rootElementRef?.current);
document.addEventListener('mouseup', handleMouseUp, {
once: true
});
}, [apiRef, handleMouseUp, hasClickedValidCellForRangeSelection]);
const stopAutoScroll = React.useCallback(() => {
if (autoScrollRAF.current) {
cancelAnimationFrame(autoScrollRAF.current);
autoScrollRAF.current = null;
}
}, []);
const handleCellFocusIn = React.useCallback(params => {
cellWithVirtualFocus.current = {
id: params.id,
field: params.field
};
}, []);
const startAutoScroll = React.useCallback(() => {
if (autoScrollRAF.current) {
return;
}
if (!apiRef.current.virtualScrollerRef?.current) {
return;
}
function autoScroll() {
if (!mousePosition.current || !apiRef.current.virtualScrollerRef?.current) {
return;
}
const dimensions = gridDimensionsSelector(apiRef);
const {
x: mouseX,
y: mouseY
} = mousePosition.current;
const {
width,
height: viewportOuterHeight
} = dimensions.viewportOuterSize;
const height = viewportOuterHeight - totalHeaderHeight;
let deltaX = 0;
let deltaY = 0;
let factor = 0;
if (mouseY <= AUTO_SCROLL_SENSITIVITY && dimensions.hasScrollY) {
// When scrolling up, the multiplier increases going closer to the top edge
factor = (AUTO_SCROLL_SENSITIVITY - mouseY) / -AUTO_SCROLL_SENSITIVITY;
deltaY = AUTO_SCROLL_SPEED;
} else if (mouseY >= height - AUTO_SCROLL_SENSITIVITY && dimensions.hasScrollY) {
// When scrolling down, the multiplier increases going closer to the bottom edge
factor = (mouseY - (height - AUTO_SCROLL_SENSITIVITY)) / AUTO_SCROLL_SENSITIVITY;
deltaY = AUTO_SCROLL_SPEED;
} else if (mouseX <= AUTO_SCROLL_SENSITIVITY && dimensions.hasScrollX) {
// When scrolling left, the multiplier increases going closer to the left edge
factor = (AUTO_SCROLL_SENSITIVITY - mouseX) / -AUTO_SCROLL_SENSITIVITY;
deltaX = AUTO_SCROLL_SPEED;
} else if (mouseX >= width - AUTO_SCROLL_SENSITIVITY && dimensions.hasScrollX) {
// When scrolling right, the multiplier increases going closer to the right edge
factor = (mouseX - (width - AUTO_SCROLL_SENSITIVITY)) / AUTO_SCROLL_SENSITIVITY;
deltaX = AUTO_SCROLL_SPEED;
}
if (deltaX !== 0 || deltaY !== 0) {
const {
scrollLeft,
scrollTop
} = apiRef.current.virtualScrollerRef.current;
apiRef.current.scroll({
top: scrollTop + deltaY * factor,
left: scrollLeft + deltaX * factor
});
}
autoScrollRAF.current = requestAnimationFrame(autoScroll);
}
autoScroll();
}, [apiRef, totalHeaderHeight]);
const handleCellMouseOver = React.useCallback((params, event) => {
if (!lastMouseDownCell.current) {
return;
}
const {
id,
field
} = params;
apiRef.current.selectCellRange(lastMouseDownCell.current, {
id,
field
}, event.ctrlKey || event.metaKey);
const virtualScrollerRect = apiRef.current.virtualScrollerRef?.current?.getBoundingClientRect();
if (!virtualScrollerRect) {
return;
}
const dimensions = gridDimensionsSelector(apiRef);
const {
x,
y
} = virtualScrollerRect;
const {
width,
height: viewportOuterHeight
} = dimensions.viewportOuterSize;
const height = viewportOuterHeight - totalHeaderHeight;
const mouseX = event.clientX - x;
const mouseY = event.clientY - y - totalHeaderHeight;
mousePosition.current = {
x: mouseX,
y: mouseY
};
const hasEnteredVerticalSensitivityArea = mouseY <= AUTO_SCROLL_SENSITIVITY || mouseY >= height - AUTO_SCROLL_SENSITIVITY;
const hasEnteredHorizontalSensitivityArea = mouseX <= AUTO_SCROLL_SENSITIVITY || mouseX >= width - AUTO_SCROLL_SENSITIVITY;
const hasEnteredSensitivityArea = hasEnteredVerticalSensitivityArea || hasEnteredHorizontalSensitivityArea;
if (hasEnteredSensitivityArea) {
// Mouse has entered the sensitity area for the first time
startAutoScroll();
} else {
// Mouse has left the sensitivity area while auto scroll is on
stopAutoScroll();
}
}, [apiRef, startAutoScroll, stopAutoScroll, totalHeaderHeight]);
const handleCellClick = useEventCallback((params, event) => {
// After a fill handle mousedown+mouseup (click without drag), skip the
// subsequent cell click so it doesn't replace the multi-cell selection.
if (skipNextCellClick.current) {
skipNextCellClick.current = false;
return;
}
const {
id,
field
} = params;
if (!hasClickedValidCellForRangeSelection(params)) {
return;
}
const focusedCell = gridFocusCellSelector(apiRef);
if (event.shiftKey && focusedCell) {
apiRef.current.selectCellRange(focusedCell, {
id,
field
});
cellWithVirtualFocus.current = {
id,
field
};
return;
}
if (event.ctrlKey || event.metaKey) {
// Add the clicked cell to the selection
const prevModel = apiRef.current.getCellSelectionModel();
apiRef.current.setCellSelectionModel(_extends({}, prevModel, {
[id]: _extends({}, prevModel[id], {
[field]: !apiRef.current.isCellSelected(id, field)
})
}));
} else {
// Clear the selection and keep only the clicked cell selected
apiRef.current.setCellSelectionModel({
[id]: {
[field]: true
}
});
}
});
const handleCellKeyDown = useEventCallback((params, event) => {
if (!isNavigationKey(event.key) || !cellWithVirtualFocus.current) {
return;
}
if (event.key === ' ' && params.cellMode === GridCellModes.Edit) {
return;
}
if (!event.shiftKey) {
apiRef.current.setCellSelectionModel({});
return;
}
const {
current: otherCell
} = cellWithVirtualFocus;
let endRowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(otherCell.id);
let endColumnIndex = apiRef.current.getColumnIndex(otherCell.field);
if (event.key === 'ArrowDown') {
endRowIndex += 1;
} else if (event.key === 'ArrowUp') {
endRowIndex -= 1;
} else if (event.key === 'ArrowRight') {
endColumnIndex += 1;
} else if (event.key === 'ArrowLeft') {
endColumnIndex -= 1;
}
const visibleRows = getVisibleRows(apiRef);
if (endRowIndex < 0 || endRowIndex >= visibleRows.rows.length) {
return;
}
const visibleColumns = apiRef.current.getVisibleColumns();
if (endColumnIndex < 0 || endColumnIndex >= visibleColumns.length) {
return;
}
cellWithVirtualFocus.current = {
id: visibleRows.rows[endRowIndex].id,
field: visibleColumns[endColumnIndex].field
};
apiRef.current.scrollToIndexes({
rowIndex: endRowIndex,
colIndex: endColumnIndex
});
const {
id,
field
} = params;
apiRef.current.selectCellRange({
id,
field
}, cellWithVirtualFocus.current);
});
const serializeCellForClipboard = useEventCallback((id, field) => {
const cellParams = apiRef.current.getCellParams(id, field);
return serializeCellValue(cellParams, {
csvOptions: {
delimiter: clipboardCopyCellDelimiter,
shouldAppendQuotes: false,
escapeFormulas: false
},
ignoreValueFormatter
});
});
// Helper: get source values for a specific field from stored source cells
const getSourceValuesForField = React.useCallback(field => {
const sourceValues = [];
for (const cell of fillSource.current?.cells ?? []) {
if (cell.field === field) {
sourceValues.push(serializeCellForClipboard(cell.id, cell.field));
}
}
return sourceValues;
}, [serializeCellForClipboard]);
const getFillSourceData = React.useCallback(() => {
const selectedCells = fillSource.current?.cells ?? [];
if (selectedCells.length === 0) {
return [];
}
const visibleRows = getVisibleRows(apiRef).rows;
const visibleColumns = apiRef.current.getVisibleColumns();
const rowIndexLookup = new Map(visibleRows.map((row, index) => [String(row.id), index]));
const columnIndexLookup = new Map(visibleColumns.map((column, index) => [column.field, index]));
const orderedRowIds = [...new Set(selectedCells.map(cell => cell.id))].sort((a, b) => (rowIndexLookup.get(String(a)) ?? 0) - (rowIndexLookup.get(String(b)) ?? 0));
const orderedFields = [...new Set(selectedCells.map(cell => cell.field))].sort((a, b) => (columnIndexLookup.get(a) ?? 0) - (columnIndexLookup.get(b) ?? 0));
const valueLookup = new Map();
selectedCells.forEach(cell => {
const rowKey = String(cell.id);
let rowValues = valueLookup.get(rowKey);
if (!rowValues) {
rowValues = new Map();
valueLookup.set(rowKey, rowValues);
}
rowValues.set(cell.field, serializeCellForClipboard(cell.id, cell.field));
});
return orderedRowIds.map(rowId => {
const rowValues = valueLookup.get(String(rowId));
return orderedFields.map(field => rowValues?.get(field) ?? '');
});
}, [apiRef, serializeCellForClipboard]);
const getFillDownSourceData = React.useCallback(selectedCells => {
if (selectedCells.length === 0) {
return [];
}
const visibleRows = getVisibleRows(apiRef).rows;
const visibleColumns = apiRef.current.getVisibleColumns();
const rowIndexLookup = new Map(visibleRows.map((row, index) => [String(row.id), index]));
const columnIndexLookup = new Map(visibleColumns.map((column, index) => [column.field, index]));
const topCellByField = new Map();
selectedCells.forEach(cell => {
const rowIndex = rowIndexLookup.get(String(cell.id)) ?? Number.MAX_SAFE_INTEGER;
const currentTopCell = topCellByField.get(cell.field);
if (!currentTopCell || rowIndex < currentTopCell.rowIndex) {
topCellByField.set(cell.field, {
id: cell.id,
rowIndex
});
}
});
const orderedFields = [...topCellByField.keys()].sort((a, b) => (columnIndexLookup.get(a) ?? 0) - (columnIndexLookup.get(b) ?? 0));
return [orderedFields.map(field => {
const sourceCell = topCellByField.get(field);
return serializeCellForClipboard(sourceCell.id, field);
})];
}, [apiRef, serializeCellForClipboard]);
// Fill handle: apply fill using CellValueUpdater
const applyFill = React.useCallback(() => {
const targetRowIds = fillDrag.current.targetRowIds;
const targetFields = fillDrag.current.targetFields;
const direction = fillDrag.current.direction;
if (targetRowIds.length === 0 || targetFields.length === 0 || !direction) {
return;
}
apiRef.current.publishEvent('clipboardPasteStart', {
data: getFillSourceData()
});
const cellUpdater = new CellValueUpdater({
apiRef,
processRowUpdate: props.processRowUpdate,
onProcessRowUpdateError: props.onProcessRowUpdateError,
getRowId: props.getRowId
});
if (direction === 'vertical') {
// Each source column fills its own target rows independently
for (const field of targetFields) {
const sourceValues = getSourceValuesForField(field);
if (sourceValues.length === 0) {
continue;
}
targetRowIds.forEach((rowId, i) => {
const pastedCellValue = sourceValues[i % sourceValues.length];
cellUpdater.updateCell({
rowId,
field,
pastedCellValue
});
});
}
} else if (direction === 'horizontal') {
// Map source columns to target columns by position offset
const sourceFields = fillSource.current?.fields ?? [];
targetFields.forEach((targetField, colOffset) => {
const sourceField = sourceFields[colOffset % sourceFields.length];
if (!sourceField) {
return;
}
const sourceValues = getSourceValuesForField(sourceField);
if (sourceValues.length === 0) {
return;
}
targetRowIds.forEach((rowId, rowIdx) => {
const pastedCellValue = sourceValues[rowIdx % sourceValues.length];
cellUpdater.updateCell({
rowId,
field: targetField,
pastedCellValue
});
});
});
}
cellUpdater.applyUpdates();
// Extend cell selection to include filled cells
const currentModel = apiRef.current.getCellSelectionModel();
const newModel = _extends({}, currentModel);
targetRowIds.forEach(rowId => {
if (!newModel[rowId]) {
newModel[rowId] = {};
}
targetFields.forEach(field => {
newModel[rowId][field] = true;
});
});
apiRef.current.setCellSelectionModel(newModel);
}, [apiRef, props.processRowUpdate, props.onProcessRowUpdateError, props.getRowId, getFillSourceData, getSourceValuesForField]);
// Helper: clear fill preview classes from previously decorated elements
const clearFillPreviewClasses = React.useCallback(() => {
const previewClasses = [gridClasses['cell--fillPreview'], gridClasses['cell--fillPreviewTop'], gridClasses['cell--fillPreviewBottom'], gridClasses['cell--fillPreviewLeft'], gridClasses['cell--fillPreviewRight']];
for (const el of fillDrag.current.decoratedElements) {
el.classList.remove(...previewClasses);
}
fillDrag.current.decoratedElements.clear();
}, []);
// Helper: clean up fill drag state (used on mouseup and unmount)
const cleanupFillDrag = React.useCallback(() => {
if (fillDrag.current.moveRAF != null) {
cancelAnimationFrame(fillDrag.current.moveRAF);
}
const doc = fillDrag.current.doc;
if (doc) {
if (fillDrag.current.moveHandler) {
doc.removeEventListener('mousemove', fillDrag.current.moveHandler);
}
if (fillDrag.current.upHandler) {
doc.removeEventListener('mouseup', fillDrag.current.upHandler);
}
}
clearFillPreviewClasses();
// If actual dragging occurred, the click guard is not needed — reset it
// so the next click on a cell works normally.
if (fillDrag.current.isDragging) {
skipNextCellClick.current = false;
}
fillDrag.current = createInitialFillDragState();
fillSource.current = null;
apiRef.current.rootElementRef?.current?.classList.remove(gridClasses['root--disableUserSelection']);
}, [apiRef, clearFillPreviewClasses]);
// Fill handle: mousedown on the fill handle
const handleFillHandleMouseDown = React.useCallback((params, event) => {
if (!props.cellSelectionFillHandle || !props.cellSelection) {
return;
}
// Check if the click is on the fill handle (::after pseudo-element at bottom-right)
const rootEl = apiRef.current.rootElementRef?.current;
if (!rootEl) {
return;
}
const cellElement = apiRef.current.getCellElement(params.id, params.field);
if (!cellElement || !cellElement.classList.contains(gridClasses['cell--withFillHandle'])) {
return;
}
const rect = cellElement.getBoundingClientRect();
const clickX = event.clientX;
const clickY = event.clientY;
const isRtl = apiRef.current.state.isRtl;
// Check if click is near the inline-end bottom corner (within hit area)
const isNearHandle = (isRtl ? clickX <= rect.left + FILL_HANDLE_HIT_AREA : clickX >= rect.right - FILL_HANDLE_HIT_AREA) && clickY >= rect.bottom - FILL_HANDLE_HIT_AREA && clickY >= rect.top; // Ensure click is within cell bounds
if (!isNearHandle) {
return;
}
// Prevent default cell selection behavior
event.preventDefault();
event.stopPropagation();
event.defaultMuiPrevented = true;
// Skip the cell click that fires after this mousedown+mouseup so it
// doesn't replace the multi-cell selection with a single cell.
skipNextCellClick.current = true;
// Store selected cells as source (fall back to focused cell if no selection)
const selectedCells = getSelectedOrFocusedCells(apiRef);
if (selectedCells.length === 0) {
return;
}
// Compute all source fields in visible column order
const visibleColumns = apiRef.current.getVisibleColumns();
const columnFieldToIndex = new Map(visibleColumns.map((col, i) => [col.field, i]));
const sourceFields = [...new Set(selectedCells.map(c => c.field))];
sourceFields.sort((a, b) => (columnFieldToIndex.get(a) ?? 0) - (columnFieldToIndex.get(b) ?? 0));
// Pre-compute source column index range
const sourceColIndices = sourceFields.map(f => columnFieldToIndex.get(f) ?? 0);
// Pre-compute source row range (doesn't change during drag)
const sourceRowIds = [...new Set(selectedCells.map(c => c.id))];
const sourceRowIndices = sourceRowIds.map(id => apiRef.current.getRowIndexRelativeToVisibleRows(id));
// Build row ID lookup map for O(1) resolution during mousemove
const visibleRows = getVisibleRows(apiRef);
const idMap = new Map();
for (const row of visibleRows.rows) {
idMap.set(String(row.id), row.id);
}
fillSource.current = {
cells: selectedCells,
fields: sourceFields,
columnIndexRange: {
start: Math.min(...sourceColIndices),
end: Math.max(...sourceColIndices)
},
rowIndexRange: {
start: Math.min(...sourceRowIndices),
end: Math.max(...sourceRowIndices)
},
rowIdMap: idMap
};
fillDrag.current.targetFields = [];
fillDrag.current.targetRowIds = [];
fillDrag.current.direction = null;
rootEl.classList.add(gridClasses['root--disableUserSelection']);
const doc = ownerDocument(rootEl);
fillDrag.current.doc = doc;
const handleFillMouseMove = moveEvent => {
// Activate dragging on the first mousemove (not on mousedown) so that a
// click-without-drag never sets isFillDragging — which would cause
// addClassesToCells to hide the fill handle indicator.
if (!fillDrag.current.isDragging) {
fillDrag.current.isDragging = true;
}
// Throttle via rAF to avoid layout thrashing
if (fillDrag.current.moveRAF != null) {
return;
}
fillDrag.current.moveRAF = requestAnimationFrame(() => {
fillDrag.current.moveRAF = null;
if (!fillDrag.current.isDragging || !fillSource.current) {
return;
}
const currentRootEl = apiRef.current.rootElementRef?.current;
const source = fillSource.current;
// Find which row and field the mouse is over
const elements = doc.elementsFromPoint(moveEvent.clientX, moveEvent.clientY);
let targetRowId = null;
let targetField = null;
for (const el of elements) {
const cellEl = el.closest('[data-field]');
if (cellEl) {
targetField = cellEl.getAttribute('data-field');
const rowEl = cellEl.closest('[data-id]');
if (rowEl) {
const idStr = rowEl.getAttribute('data-id');
if (idStr != null) {
// O(1) lookup via pre-built map
const resolved = source.rowIdMap.get(idStr);
if (resolved != null) {
targetRowId = resolved;
}
}
break;
}
}
}
if (targetRowId == null || targetField == null) {
return;
}
const {
start: minSourceRowIdx,
end: maxSourceRowIdx
} = source.rowIndexRange;
const {
start: minSourceColIdx,
end: maxSourceColIdx
} = source.columnIndexRange;
const currentVisibleRows = getVisibleRows(apiRef);
const currentVisibleColumns = apiRef.current.getVisibleColumns();
const targetRowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(targetRowId);
const targetColIndex = apiRef.current.getColumnIndex(targetField);
const isOutsideRowRange = targetRowIndex > maxSourceRowIdx || targetRowIndex < minSourceRowIdx;
const isOutsideColRange = targetColIndex > maxSourceColIdx || targetColIndex < minSourceColIdx;
// Determine fill direction and target cells
const newTargetRowIds = [];
let newTargetFields = [];
if (isOutsideRowRange) {
// Vertical fill: extend rows, keep all source columns
fillDrag.current.direction = 'vertical';
newTargetFields = source.fields;
if (targetRowIndex > maxSourceRowIdx) {
// Filling down
for (let i = maxSourceRowIdx + 1; i <= targetRowIndex; i += 1) {
if (i < currentVisibleRows.rows.length) {
newTargetRowIds.push(currentVisibleRows.rows[i].id);
}
}
} else {
// Filling up
for (let i = targetRowIndex; i < minSourceRowIdx; i += 1) {
if (i >= 0) {
newTargetRowIds.push(currentVisibleRows.rows[i].id);
}
}
}
} else if (isOutsideColRange) {
// Horizontal fill: extend columns, keep source rows
fillDrag.current.direction = 'horizontal';
const sourceRowIdSet = new Set(source.cells.map(c => String(c.id)));
for (let i = minSourceRowIdx; i <= maxSourceRowIdx; i += 1) {
if (i < currentVisibleRows.rows.length) {
const rowId = currentVisibleRows.rows[i].id;
if (sourceRowIdSet.has(String(rowId))) {
newTargetRowIds.push(rowId);
}
}
}
if (targetColIndex > maxSourceColIdx) {
// Filling right
for (let i = maxSourceColIdx + 1; i <= targetColIndex; i += 1) {
if (i < currentVisibleColumns.length) {
newTargetFields.push(currentVisibleColumns[i].field);
}
}
} else {
// Filling left
for (let i = targetColIndex; i < minSourceColIdx; i += 1) {
if (i >= 0) {
newTargetFields.push(currentVisibleColumns[i].field);
}
}
}
} else {
// Mouse is within source range — no fill
fillDrag.current.direction = null;
}
fillDrag.current.targetRowIds = newTargetRowIds;
fillDrag.current.targetFields = newTargetFields;
// Apply fill preview classes directly to DOM for immediate visual feedback
if (currentRootEl) {
const nextDecorated = new Set();
newTargetRowIds.forEach((rowId, rowIdx) => {
newTargetFields.forEach((field, colIdx) => {
const cellEl = getGridCellElement(currentRootEl, {
id: rowId,
field
});
if (cellEl) {
nextDecorated.add(cellEl);
cellEl.classList.add(gridClasses['cell--fillPreview']);
if (rowIdx === 0) {
cellEl.classList.add(gridClasses['cell--fillPreviewTop']);
}
if (rowIdx === newTargetRowIds.length - 1) {
cellEl.classList.add(gridClasses['cell--fillPreviewBottom']);
}
if (colIdx === 0) {
cellEl.classList.add(gridClasses['cell--fillPreviewLeft']);
}
if (colIdx === newTargetFields.length - 1) {
cellEl.classList.add(gridClasses['cell--fillPreviewRight']);
}
}
});
});
// Remove classes only from elements no longer in the target set
for (const el of fillDrag.current.decoratedElements) {
if (!nextDecorated.has(el)) {
el.classList.remove(gridClasses['cell--fillPreview'], gridClasses['cell--fillPreviewTop'], gridClasses['cell--fillPreviewBottom'], gridClasses['cell--fillPreviewLeft'], gridClasses['cell--fillPreviewRight']);
}
}
fillDrag.current.decoratedElements = nextDecorated;
}
// Auto-scroll: trigger for both vertical and horizontal edges
const virtualScrollerRect = apiRef.current.virtualScrollerRef?.current?.getBoundingClientRect();
if (virtualScrollerRect) {
const dimensions = gridDimensionsSelector(apiRef);
const mouseX = moveEvent.clientX - virtualScrollerRect.x;
const mouseY = moveEvent.clientY - virtualScrollerRect.y - totalHeaderHeight;
const height = dimensions.viewportOuterSize.height - totalHeaderHeight;
const width = dimensions.viewportOuterSize.width;
mousePosition.current.x = mouseX;
mousePosition.current.y = mouseY;
const isInVerticalSensitivity = mouseY <= AUTO_SCROLL_SENSITIVITY || mouseY >= height - AUTO_SCROLL_SENSITIVITY;
const isInHorizontalSensitivity = mouseX <= AUTO_SCROLL_SENSITIVITY || mouseX >= width - AUTO_SCROLL_SENSITIVITY;
if (isInVerticalSensitivity || isInHorizontalSensitivity) {
startAutoScroll();
} else {
stopAutoScroll();
}
}
});
};
const handleFillMouseUp = () => {
stopAutoScroll();
if (fillDrag.current.isDragging) {
applyFill();
}
cleanupFillDrag();
};
// Store refs for cleanup on unmount
fillDrag.current.moveHandler = handleFillMouseMove;
fillDrag.current.upHandler = handleFillMouseUp;
doc.addEventListener('mousemove', handleFillMouseMove);
doc.addEventListener('mouseup', handleFillMouseUp);
}, [apiRef, props.cellSelectionFillHandle, props.cellSelection, applyFill, cleanupFillDrag, startAutoScroll, stopAutoScroll, totalHeaderHeight]);
// Fill handle: Ctrl+D to fill down
const handleFillKeyDown = useEventCallback((_params, event) => {
if (!isFillDownShortcut(event)) {
return;
}
const selectedCells = getSelectedOrFocusedCells(apiRef);
if (selectedCells.length === 0) {
return;
}
event.preventDefault();
event.defaultMuiPrevented = true;
// Group selected cells by field (column)
const cellsByField = new Map();
for (const cell of selectedCells) {
const list = cellsByField.get(cell.field) ?? [];
list.push(cell);
cellsByField.set(cell.field, list);
}
const visibleRows = getVisibleRows(apiRef);
const fillDownSourceData = getFillDownSourceData(selectedCells);
if (selectedCells.length === 1) {
// Single cell selected: extend selection down by one row and fill
const cell = selectedCells[0];
const colDef = apiRef.current.getColumn(cell.field);
if (!colDef?.editable) {
return;
}
const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(cell.id);
const nextRowIndex = rowIndex + 1;
if (nextRowIndex >= visibleRows.rows.length) {
return;
}
const nextRowId = visibleRows.rows[nextRowIndex].id;
const sourceValue = serializeCellForClipboard(cell.id, cell.field);
apiRef.current.publishEvent('clipboardPasteStart', {
data: fillDownSourceData
});
const cellUpdater = new CellValueUpdater({
apiRef,
processRowUpdate: props.processRowUpdate,
onProcessRowUpdateError: props.onProcessRowUpdateError,
getRowId: props.getRowId
});
cellUpdater.updateCell({
rowId: nextRowId,
field: cell.field,
pastedCellValue: sourceValue
});
cellUpdater.applyUpdates();
// Move selection and focus to the filled cell
apiRef.current.setCellSelectionModel({
[nextRowId]: {
[cell.field]: true
}
});
const colIndex = apiRef.current.getColumnIndex(cell.field);
apiRef.current.scrollToIndexes({
rowIndex: nextRowIndex,
colIndex
});
apiRef.current.setCellFocus(nextRowId, cell.field);
cellWithVirtualFocus.current = {
id: nextRowId,
field: cell.field
};
return;
}
// Check if this is a single-row multi-column selection
const isSingleRowMultiColumn = selectedCells.length > 1 && [...cellsByField.values()].every(cells => cells.length === 1);
if (isSingleRowMultiColumn) {
// All cells are in the same row — extend down by one row
const firstCell = selectedCells[0];
const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(firstCell.id);
const nextRowIndex = rowIndex + 1;
if (nextRowIndex >= visibleRows.rows.length) {
return;
}
const nextRowId = visibleRows.rows[nextRowIndex].id;
apiRef.current.publishEvent('clipboardPasteStart', {
data: fillDownSourceData
});
const cellUpdater = new CellValueUpdater({
apiRef,
processRowUpdate: props.processRowUpdate,
onProcessRowUpdateError: props.onProcessRowUpdateError,
getRowId: props.getRowId
});
const newSelectionModel = {};
for (const [field, cells] of cellsByField) {
const colDef = apiRef.current.getColumn(field);
if (!colDef?.editable) {
continue;
}
const sourceValue = serializeCellForClipboard(cells[0].id, field);
cellUpdater.updateCell({
rowId: nextRowId,
field,
pastedCellValue: sourceValue
});
if (!newSelectionModel[nextRowId]) {
newSelectionModel[nextRowId] = {};
}
newSelectionModel[nextRowId][field] = true;
}
cellUpdater.applyUpdates();
apiRef.current.setCellSelectionModel(newSelectionModel);
// Focus first editable cell in the filled row
const firstEditableField = [...cellsByField.keys()].find(f => apiRef.current.getColumn(f)?.editable);
if (firstEditableField) {
const colIndex = apiRef.current.getColumnIndex(firstEditableField);
apiRef.current.scrollToIndexes({
rowIndex: nextRowIndex,
colIndex
});
apiRef.current.setCellFocus(nextRowId, firstEditableField);
cellWithVirtualFocus.current = {
id: nextRowId,
field: firstEditableField
};
}
return;
}
let cellUpdater = null;
// Multiple cells selected: for each column, top row = source, remaining = targets
for (const [field, cells] of cellsByField) {
const colDef = apiRef.current.getColumn(field);
if (!colDef?.editable) {
continue;
}
// Sort cells by row index
const sortedCells = cells.map(cell => _extends({}, cell, {
rowIndex: apiRef.current.getRowIndexRelativeToVisibleRows(cell.id)
})).sort((a, b) => a.rowIndex - b.rowIndex);
if (sortedCells.length < 2) {
continue;
}
// Top row is the source
const sourceCell = sortedCells[0];
const sourceValue = serializeCellForClipboard(sourceCell.id, sourceCell.field);
if (!cellUpdater) {
apiRef.current.publishEvent('clipboardPasteStart', {
data: fillDownSourceData
});
cellUpdater = new CellValueUpdater({
apiRef,
processRowUpdate: props.processRowUpdate,
onProcessRowUpdateError: props.onProcessRowUpdateError,
getRowId: props.getRowId
});
}
// Fill all cells below the source
for (let i = 1; i < sortedCells.length; i += 1) {
cellUpdater.updateCell({
rowId: sortedCells[i].id,
field,
pastedCellValue: sourceValue
});
}
}
cellUpdater?.applyUpdates();
});
// Fill handle: Ctrl+R to fill right
const handleFillRightKeyDown = useEventCallback((_params, event) => {
if (!isFillRightShortcut(event)) {
return;
}
const selectedCells = getSelectedOrFocusedCells(apiRef);
if (selectedCells.length === 0) {
return;
}
event.preventDefault();
event.defaultMuiPrevented = true;
const visibleColumns = apiRef.current.getVisibleColumns();
const columnFieldToIndex = new Map(visibleColumns.map((col, i) => [col.field, i]));
// Group selected cells by row
const cellsByRow = new Map();
for (const cell of selectedCells) {
const list = cellsByRow.get(cell.id) ?? [];
list.push(cell);
cellsByRow.set(cell.id, list);
}
if (selectedCells.length === 1) {
// Single cell: extend selection right by one column and fill
const cell = selectedCells[0];
const colIndex = columnFieldToIndex.get(cell.field) ?? -1;
const nextColIndex = colIndex + 1;
if (nextColIndex >= visibleColumns.length) {
return;
}
const nextField = visibleColumns[nextColIndex].field;
const nextColDef = apiRef.current.getColumn(nextField);
if (!nextColDef?.editable) {
return;
}
const sourceValue = serializeCellForClipboard(cell.id, cell.field);
apiRef.current.publishEvent('clipboardPasteStart', {
data: [[sourceValue]]
});
const cellUpdater = new CellValueUpdater({
apiRef,
processRowUpdate: props.processRowUpdate,
onProcessRowUpdateError: props.onProcessRowUpdateError,
getRowId: props.getRowId
});
cellUpdater.updateCell({
rowId: cell.id,
field: nextField,
pastedCellValue: sourceValue
});
cellUpdater.applyUpdates();
// Move selection and focus to the filled cell
apiRef.current.setCellSelectionModel({
[cell.id]: {
[nextField]: true
}
});
const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(cell.id);
apiRef.current.scrollToIndexes({
rowIndex,
colIndex: nextColIndex
});
apiRef.current.setCellFocus(cell.id, nextField);
cellWithVirtualFocus.current = {
id: cell.id,
field: nextField
};
return;
}
// Check if single-column multi-row selection (extend right by one column)
const isSingleColumnMultiRow = [...cellsByRow.values()].every(cells => cells.length === 1);
if (isSingleColumnMultiRow) {
const firstCell = selectedCells[0];
const colIndex = columnFieldToIndex.get(firstCell.field) ?? -1;
const nextColIndex = colIndex + 1;
if (nextColIndex >= visibleColumns.length) {
return;
}
const nextField = visibleColumns[nextColIndex].field;
const nextColDef = apiRef.current.getColumn(nextField);
if (!nextColDef?.editable) {
return;
}
apiRef.current.publishEvent('clipboardPasteStart', {
data: [...cellsByRow.entries()].map(([, cells]) => [serializeCellForClipboard(cells[0].id, cells[0].field)])
});
const cellUpdater = new CellValueUpdater({
apiRef,
processRowUpdate: props.processRowUpdate,
onProcessRowUpdateError: props.onProcessRowUpdateError,
getRowId: props.getRowId
});
const newSelectionModel = {};
for (const [rowId, cells] of cellsByRow) {
const sourceValue = serializeCellForClipboard(cells[0].id, cells[0].field);
cellUpdater.updateCell({
rowId,
field: nextField,
pastedCellValue: sourceValue
});
if (!newSelectionModel[rowId]) {
newSelectionModel[rowId] = {};
}
newSelectionModel[rowId][nextField] = true;
}
cellUpdater.applyUpdates();
apiRef.current.setCellSelectionModel(newSelectionModel);
return;
}
// Multiple cells per row: for each row, leftmost = source, rest = targets
let cellUpdater = null;
for (const [rowId, cells] of cellsByRow) {
// Sort cells by column index
const sortedCells = cells.map(cell => _extends({}, cell, {
colIndex: columnFieldToIndex.get(cell.field) ?? 0
})).sort((a, b) => a.colIndex - b.colIndex);
if (sortedCells.length < 2) {
continue;
}
const sourceCell = sortedCells[0];
const sourceValue = serializeCellForClipboard(sourceCell.id, sourceCell.field);
if (!cellUpdater) {
apiRef.current.publishEvent('clipboardPasteStart', {
data: [...cellsByRow.entries()].map(([, rowCells]) => {
const sorted = rowCells.map(c => _extends({}, c, {
colIndex: columnFieldToIndex.get(c.field) ?? 0
})).sort((a, b) => a.colIndex - b.colIndex);
return [serializeCellForClipboard(sorted[0].id, sorted[0].field)];
})
});
cellUpdater = new CellValueUpdater({
apiRef,
processRowUpdate: props.processRowUpdate,
onProcessRowUpdateError: props.onProcessRowUpdateError,
getRowId: props.getRowId
});
}
// Fill all cells to the right of the source
for (let i = 1; i < sortedCells.length; i += 1) {
const colDef = apiRef.current.getColumn(sortedCells[i].field);
if (!colDef?.editable) {
continue;
}
cellUpdater.updateCell({
rowId,
field: sortedCells[i].field,
pastedCellValue: sourceValue
});
}
}
cellUpdater?.applyUpdates();
});
useGridEvent(apiRef, 'cellMouseDown', runIfCellSelectionIsEnabled(handleFillHandleMouseDown));
useGridEvent(apiRef, 'cellClick', runIfCellSelectionIsEnabled(handleCellClick));
useGridEvent(apiRef, 'cellFocusIn', runIfCellSelectionIsEnabled(handleCellFocusIn));
useGridEvent(apiRef, 'cellKeyDown', runIfCellSelectionIsEnabled(handleCellKeyDown));
useGridEvent(apiRef, 'cellKeyDown', runIfCellSelectionIsEnabled(handleFillKeyDown));
useGridEvent(apiRef, 'cellKeyDown', runIfCellSelectionIsEnabled(handleFillRightKeyDown));
useGridEvent(apiRef, 'cellMouseDown', runIfCellSelectionIsEnabled(handleCellMouseDown));
useGridEvent(apiRef, 'cellMouseOver', runIfCellSelectionIsEnabled(handleCellMouseOver));
React.useEffect(() => {
if (props.cellSelectionModel) {
apiRef.current.setCellSelectionModel(props.cellSelectionModel);
}
}, [apiRef, props.cellSelectionModel]);
React.useEffect(() => {
const rootRef = apiRef.current.rootElementRef?.current;
return () => {
stopAutoScroll();
cleanupFillDrag();
const document = ownerDocument(rootRef);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [apiRef, hasRootReference, handleMouseUp, stopAutoScroll, cleanupFillDrag]);
const checkIfCellIsSelected = React.useCallback((isSelected, {
id,
field
}) => {
return apiRef.current.isCellSelected(id, field);
}, [apiRef]);
const addClassesToCells = React.useCallback((classes, {
id,
field
}) => {
const visibleRows = getVisibleRows(apiRef);
// Note: Fill preview classes during drag are applied via direct DOM manipulation
// in handleFillMouseMove for performance. The pipe processor only handles
// the fill handle indicator (cell--withFillHandle) on the selection's bottom-right cell.
if (!visibleRows.range || !apiRef.current.isCellSelected(id, field)) {
// Show fill handle on the focused cell when no cell selection exists
if (props.cellSelectionFillHandle && !fillDrag.current.isDragging) {
const focusedCell = gridFocusCellSelector(apiRef);
if (focusedCell && focusedCell.id === id && focusedCell.field === field) {
const selectionModel = apiRef.current.getCellSelectionModel();
const hasSelection = Object.keys(selectionModel).some(rowId => Object.values(selectionModel[rowId]).some(Boolean));
if (!hasSelection) {
const col = apiRef.current.getColumn(field);
if (col?.editable) {
return [...classes, gridClasses['cell--withFillHandle']];
}
}
}
}
return classes;
}
const newClasses = [...classes];
const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(id);
const columnIndex = apiRef.current.getColumnIndex(field);
const visibleColumns = apiRef.current.getVisibleColumns();
let isBottom = false;
let isRight = false;
if (rowIndex > 0) {
const {
id: previousRowId
} = visibleRows.rows[rowIndex - 1];
if (!apiRef.current.isCellSelected(previousRowId, field)) {
newClasses.push(gridClasses['cell--rangeTop']);
}
} else {
newClasses.push(gridClasses['cell--rangeTop']);
}
if (rowIndex + visibleRows.range.firstRowIndex < visibleRows.range.lastRowIndex) {
const {
id: nextRowId
} = visibleRows.rows[rowIndex + 1];
if (!apiRef.current.isCellSelected(nextRowId, field)) {
newClasses.push(gridClasses['cell--rangeBottom']);
isBottom = true;
}
} else {
newClasses.push(gridClasses['cell--rangeBottom']);
isBottom = true;
}
if (columnIndex > 0) {
const {
field: previousColumnField
} = visibleColumns[columnIndex - 1];
if (!apiRef.current.isCellSelected(id, previousColumnField)) {
newClasses.push(gridClasses['cell--rangeLeft']);
}
} else {
newClasses.push(gridClasses['cell--rangeLeft']);
}
if (columnIndex < visibleColumns.length - 1) {
const {
field: nextColumnField
} = visibleColumns[columnIndex + 1];
if (!apiRef.current.isCellSelected(id, nextColumnField)) {
newClasses.push(gridClasses['cell--rangeRight']);
isRight = true;
}
} else {
newClasses.push(gridClasses['cell--rangeRight']);
isRight = true;