@mui/x-data-grid-premium 9.0.0-beta.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,10 @@ import _extends from "@babel/runtime/helpers/esm/extends";
4
4
  import * as React from 'react';
5
5
  import ownerDocument from '@mui/utils/ownerDocument';
6
6
  import useEventCallback from '@mui/utils/useEventCallback';
7
- import { getTotalHeaderHeight, getVisibleRows, isNavigationKey, serializeCellValue, useGridRegisterPipeProcessor } from '@mui/x-data-grid-pro/internals';
7
+ import { getGridCellElement, getTotalHeaderHeight, getVisibleRows, isFillDownShortcut, isFillRightShortcut, isNavigationKey, serializeCellValue, useGridRegisterPipeProcessor } from '@mui/x-data-grid-pro/internals';
8
8
  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';
9
9
  import { gridCellSelectionStateSelector } from "./gridCellSelectionSelector.mjs";
10
+ import { CellValueUpdater } from "../clipboard/useGridClipboardImport.mjs";
10
11
  export const cellSelectionStateInitializer = (state, props) => _extends({}, state, {
11
12
  cellSelection: _extends({}, props.cellSelectionModel ?? props.initialState?.cellSelection)
12
13
  });
@@ -15,14 +16,51 @@ function isKeyboardEvent(event) {
15
16
  }
16
17
  const AUTO_SCROLL_SENSITIVITY = 50; // The distance from the edge to start scrolling
17
18
  const AUTO_SCROLL_SPEED = 20; // The speed to scroll once the mouse enters the sensitivity area
19
+ const FILL_HANDLE_HIT_AREA = 16; // px — size of the interactive hit area for the fill handle
18
20
 
21
+ function getSelectedOrFocusedCells(apiRef) {
22
+ let selectedCells = apiRef.current.getSelectedCellsAsArray();
23
+ if (selectedCells.length === 0) {
24
+ const focusedCell = gridFocusCellSelector(apiRef);
25
+ if (focusedCell) {
26
+ selectedCells = [{
27
+ id: focusedCell.id,
28
+ field: focusedCell.field
29
+ }];
30
+ }
31
+ }
32
+ return selectedCells;
33
+ }
34
+ function createInitialFillDragState() {
35
+ return {
36
+ isDragging: false,
37
+ direction: null,
38
+ targetRowIds: [],
39
+ targetFields: [],
40
+ decoratedElements: new Set(),
41
+ moveRAF: null,
42
+ doc: null,
43
+ moveHandler: null,
44
+ upHandler: null
45
+ };
46
+ }
19
47
  export const useGridCellSelection = (apiRef, props) => {
20
48
  const hasRootReference = apiRef.current.rootElementRef.current !== null;
21
49
  const cellWithVirtualFocus = React.useRef(null);
22
50
  const lastMouseDownCell = React.useRef(null);
23
- const mousePosition = React.useRef(null);
51
+ const mousePosition = React.useRef({
52
+ x: 0,
53
+ y: 0
54
+ });
24
55
  const autoScrollRAF = React.useRef(null);
25
56
  const totalHeaderHeight = getTotalHeaderHeight(apiRef, props);
57
+
58
+ // Fill handle state — grouped by lifecycle:
59
+ // fillSource: set on mousedown, read-only during drag, cleared on mouseup
60
+ // fillDrag: managed during active drag, reset on mouseup
61
+ const fillSource = React.useRef(null);
62
+ const fillDrag = React.useRef(createInitialFillDragState());
63
+ const skipNextCellClick = React.useRef(false);
26
64
  const ignoreValueFormatterProp = props.ignoreValueFormatterDuringExport;
27
65
  const ignoreValueFormatter = (typeof ignoreValueFormatterProp === 'object' ? ignoreValueFormatterProp?.clipboardExport : ignoreValueFormatterProp) || false;
28
66
  const clipboardCopyCellDelimiter = props.clipboardCopyCellDelimiter;
@@ -273,6 +311,12 @@ export const useGridCellSelection = (apiRef, props) => {
273
311
  }
274
312
  }, [apiRef, startAutoScroll, stopAutoScroll, totalHeaderHeight]);
275
313
  const handleCellClick = useEventCallback((params, event) => {
314
+ // After a fill handle mousedown+mouseup (click without drag), skip the
315
+ // subsequent cell click so it doesn't replace the multi-cell selection.
316
+ if (skipNextCellClick.current) {
317
+ skipNextCellClick.current = false;
318
+ return;
319
+ }
276
320
  const {
277
321
  id,
278
322
  field
@@ -359,9 +403,785 @@ export const useGridCellSelection = (apiRef, props) => {
359
403
  field
360
404
  }, cellWithVirtualFocus.current);
361
405
  });
406
+ const serializeCellForClipboard = useEventCallback((id, field) => {
407
+ const cellParams = apiRef.current.getCellParams(id, field);
408
+ return serializeCellValue(cellParams, {
409
+ csvOptions: {
410
+ delimiter: clipboardCopyCellDelimiter,
411
+ shouldAppendQuotes: false,
412
+ escapeFormulas: false
413
+ },
414
+ ignoreValueFormatter
415
+ });
416
+ });
417
+
418
+ // Helper: get source values for a specific field from stored source cells
419
+ const getSourceValuesForField = React.useCallback(field => {
420
+ const sourceValues = [];
421
+ for (const cell of fillSource.current?.cells ?? []) {
422
+ if (cell.field === field) {
423
+ sourceValues.push(serializeCellForClipboard(cell.id, cell.field));
424
+ }
425
+ }
426
+ return sourceValues;
427
+ }, [serializeCellForClipboard]);
428
+ const getFillSourceData = React.useCallback(() => {
429
+ const selectedCells = fillSource.current?.cells ?? [];
430
+ if (selectedCells.length === 0) {
431
+ return [];
432
+ }
433
+ const visibleRows = getVisibleRows(apiRef).rows;
434
+ const visibleColumns = apiRef.current.getVisibleColumns();
435
+ const rowIndexLookup = new Map(visibleRows.map((row, index) => [String(row.id), index]));
436
+ const columnIndexLookup = new Map(visibleColumns.map((column, index) => [column.field, index]));
437
+ const orderedRowIds = [...new Set(selectedCells.map(cell => cell.id))].sort((a, b) => (rowIndexLookup.get(String(a)) ?? 0) - (rowIndexLookup.get(String(b)) ?? 0));
438
+ const orderedFields = [...new Set(selectedCells.map(cell => cell.field))].sort((a, b) => (columnIndexLookup.get(a) ?? 0) - (columnIndexLookup.get(b) ?? 0));
439
+ const valueLookup = new Map();
440
+ selectedCells.forEach(cell => {
441
+ const rowKey = String(cell.id);
442
+ let rowValues = valueLookup.get(rowKey);
443
+ if (!rowValues) {
444
+ rowValues = new Map();
445
+ valueLookup.set(rowKey, rowValues);
446
+ }
447
+ rowValues.set(cell.field, serializeCellForClipboard(cell.id, cell.field));
448
+ });
449
+ return orderedRowIds.map(rowId => {
450
+ const rowValues = valueLookup.get(String(rowId));
451
+ return orderedFields.map(field => rowValues?.get(field) ?? '');
452
+ });
453
+ }, [apiRef, serializeCellForClipboard]);
454
+ const getFillDownSourceData = React.useCallback(selectedCells => {
455
+ if (selectedCells.length === 0) {
456
+ return [];
457
+ }
458
+ const visibleRows = getVisibleRows(apiRef).rows;
459
+ const visibleColumns = apiRef.current.getVisibleColumns();
460
+ const rowIndexLookup = new Map(visibleRows.map((row, index) => [String(row.id), index]));
461
+ const columnIndexLookup = new Map(visibleColumns.map((column, index) => [column.field, index]));
462
+ const topCellByField = new Map();
463
+ selectedCells.forEach(cell => {
464
+ const rowIndex = rowIndexLookup.get(String(cell.id)) ?? Number.MAX_SAFE_INTEGER;
465
+ const currentTopCell = topCellByField.get(cell.field);
466
+ if (!currentTopCell || rowIndex < currentTopCell.rowIndex) {
467
+ topCellByField.set(cell.field, {
468
+ id: cell.id,
469
+ rowIndex
470
+ });
471
+ }
472
+ });
473
+ const orderedFields = [...topCellByField.keys()].sort((a, b) => (columnIndexLookup.get(a) ?? 0) - (columnIndexLookup.get(b) ?? 0));
474
+ return [orderedFields.map(field => {
475
+ const sourceCell = topCellByField.get(field);
476
+ return serializeCellForClipboard(sourceCell.id, field);
477
+ })];
478
+ }, [apiRef, serializeCellForClipboard]);
479
+
480
+ // Fill handle: apply fill using CellValueUpdater
481
+ const applyFill = React.useCallback(() => {
482
+ const targetRowIds = fillDrag.current.targetRowIds;
483
+ const targetFields = fillDrag.current.targetFields;
484
+ const direction = fillDrag.current.direction;
485
+ if (targetRowIds.length === 0 || targetFields.length === 0 || !direction) {
486
+ return;
487
+ }
488
+ apiRef.current.publishEvent('clipboardPasteStart', {
489
+ data: getFillSourceData()
490
+ });
491
+ const cellUpdater = new CellValueUpdater({
492
+ apiRef,
493
+ processRowUpdate: props.processRowUpdate,
494
+ onProcessRowUpdateError: props.onProcessRowUpdateError,
495
+ getRowId: props.getRowId
496
+ });
497
+ if (direction === 'vertical') {
498
+ // Each source column fills its own target rows independently
499
+ for (const field of targetFields) {
500
+ const sourceValues = getSourceValuesForField(field);
501
+ if (sourceValues.length === 0) {
502
+ continue;
503
+ }
504
+ targetRowIds.forEach((rowId, i) => {
505
+ const pastedCellValue = sourceValues[i % sourceValues.length];
506
+ cellUpdater.updateCell({
507
+ rowId,
508
+ field,
509
+ pastedCellValue
510
+ });
511
+ });
512
+ }
513
+ } else if (direction === 'horizontal') {
514
+ // Map source columns to target columns by position offset
515
+ const sourceFields = fillSource.current?.fields ?? [];
516
+ targetFields.forEach((targetField, colOffset) => {
517
+ const sourceField = sourceFields[colOffset % sourceFields.length];
518
+ if (!sourceField) {
519
+ return;
520
+ }
521
+ const sourceValues = getSourceValuesForField(sourceField);
522
+ if (sourceValues.length === 0) {
523
+ return;
524
+ }
525
+ targetRowIds.forEach((rowId, rowIdx) => {
526
+ const pastedCellValue = sourceValues[rowIdx % sourceValues.length];
527
+ cellUpdater.updateCell({
528
+ rowId,
529
+ field: targetField,
530
+ pastedCellValue
531
+ });
532
+ });
533
+ });
534
+ }
535
+ cellUpdater.applyUpdates();
536
+
537
+ // Extend cell selection to include filled cells
538
+ const currentModel = apiRef.current.getCellSelectionModel();
539
+ const newModel = _extends({}, currentModel);
540
+ targetRowIds.forEach(rowId => {
541
+ if (!newModel[rowId]) {
542
+ newModel[rowId] = {};
543
+ }
544
+ targetFields.forEach(field => {
545
+ newModel[rowId][field] = true;
546
+ });
547
+ });
548
+ apiRef.current.setCellSelectionModel(newModel);
549
+ }, [apiRef, props.processRowUpdate, props.onProcessRowUpdateError, props.getRowId, getFillSourceData, getSourceValuesForField]);
550
+
551
+ // Helper: clear fill preview classes from previously decorated elements
552
+ const clearFillPreviewClasses = React.useCallback(() => {
553
+ const previewClasses = [gridClasses['cell--fillPreview'], gridClasses['cell--fillPreviewTop'], gridClasses['cell--fillPreviewBottom'], gridClasses['cell--fillPreviewLeft'], gridClasses['cell--fillPreviewRight']];
554
+ for (const el of fillDrag.current.decoratedElements) {
555
+ el.classList.remove(...previewClasses);
556
+ }
557
+ fillDrag.current.decoratedElements.clear();
558
+ }, []);
559
+
560
+ // Helper: clean up fill drag state (used on mouseup and unmount)
561
+ const cleanupFillDrag = React.useCallback(() => {
562
+ if (fillDrag.current.moveRAF != null) {
563
+ cancelAnimationFrame(fillDrag.current.moveRAF);
564
+ }
565
+ const doc = fillDrag.current.doc;
566
+ if (doc) {
567
+ if (fillDrag.current.moveHandler) {
568
+ doc.removeEventListener('mousemove', fillDrag.current.moveHandler);
569
+ }
570
+ if (fillDrag.current.upHandler) {
571
+ doc.removeEventListener('mouseup', fillDrag.current.upHandler);
572
+ }
573
+ }
574
+ clearFillPreviewClasses();
575
+
576
+ // If actual dragging occurred, the click guard is not needed — reset it
577
+ // so the next click on a cell works normally.
578
+ if (fillDrag.current.isDragging) {
579
+ skipNextCellClick.current = false;
580
+ }
581
+ fillDrag.current = createInitialFillDragState();
582
+ fillSource.current = null;
583
+ apiRef.current.rootElementRef?.current?.classList.remove(gridClasses['root--disableUserSelection']);
584
+ }, [apiRef, clearFillPreviewClasses]);
585
+
586
+ // Fill handle: mousedown on the fill handle
587
+ const handleFillHandleMouseDown = React.useCallback((params, event) => {
588
+ if (!props.cellSelectionFillHandle || !props.cellSelection) {
589
+ return;
590
+ }
591
+
592
+ // Check if the click is on the fill handle (::after pseudo-element at bottom-right)
593
+ const rootEl = apiRef.current.rootElementRef?.current;
594
+ if (!rootEl) {
595
+ return;
596
+ }
597
+ const cellElement = apiRef.current.getCellElement(params.id, params.field);
598
+ if (!cellElement || !cellElement.classList.contains(gridClasses['cell--withFillHandle'])) {
599
+ return;
600
+ }
601
+ const rect = cellElement.getBoundingClientRect();
602
+ const clickX = event.clientX;
603
+ const clickY = event.clientY;
604
+ const isRtl = apiRef.current.state.isRtl;
605
+
606
+ // Check if click is near the inline-end bottom corner (within hit area)
607
+ 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
608
+
609
+ if (!isNearHandle) {
610
+ return;
611
+ }
612
+
613
+ // Prevent default cell selection behavior
614
+ event.preventDefault();
615
+ event.stopPropagation();
616
+ event.defaultMuiPrevented = true;
617
+
618
+ // Skip the cell click that fires after this mousedown+mouseup so it
619
+ // doesn't replace the multi-cell selection with a single cell.
620
+ skipNextCellClick.current = true;
621
+
622
+ // Store selected cells as source (fall back to focused cell if no selection)
623
+ const selectedCells = getSelectedOrFocusedCells(apiRef);
624
+ if (selectedCells.length === 0) {
625
+ return;
626
+ }
627
+
628
+ // Compute all source fields in visible column order
629
+ const visibleColumns = apiRef.current.getVisibleColumns();
630
+ const columnFieldToIndex = new Map(visibleColumns.map((col, i) => [col.field, i]));
631
+ const sourceFields = [...new Set(selectedCells.map(c => c.field))];
632
+ sourceFields.sort((a, b) => (columnFieldToIndex.get(a) ?? 0) - (columnFieldToIndex.get(b) ?? 0));
633
+
634
+ // Pre-compute source column index range
635
+ const sourceColIndices = sourceFields.map(f => columnFieldToIndex.get(f) ?? 0);
636
+
637
+ // Pre-compute source row range (doesn't change during drag)
638
+ const sourceRowIds = [...new Set(selectedCells.map(c => c.id))];
639
+ const sourceRowIndices = sourceRowIds.map(id => apiRef.current.getRowIndexRelativeToVisibleRows(id));
640
+
641
+ // Build row ID lookup map for O(1) resolution during mousemove
642
+ const visibleRows = getVisibleRows(apiRef);
643
+ const idMap = new Map();
644
+ for (const row of visibleRows.rows) {
645
+ idMap.set(String(row.id), row.id);
646
+ }
647
+ fillSource.current = {
648
+ cells: selectedCells,
649
+ fields: sourceFields,
650
+ columnIndexRange: {
651
+ start: Math.min(...sourceColIndices),
652
+ end: Math.max(...sourceColIndices)
653
+ },
654
+ rowIndexRange: {
655
+ start: Math.min(...sourceRowIndices),
656
+ end: Math.max(...sourceRowIndices)
657
+ },
658
+ rowIdMap: idMap
659
+ };
660
+ fillDrag.current.targetFields = [];
661
+ fillDrag.current.targetRowIds = [];
662
+ fillDrag.current.direction = null;
663
+ rootEl.classList.add(gridClasses['root--disableUserSelection']);
664
+ const doc = ownerDocument(rootEl);
665
+ fillDrag.current.doc = doc;
666
+ const handleFillMouseMove = moveEvent => {
667
+ // Activate dragging on the first mousemove (not on mousedown) so that a
668
+ // click-without-drag never sets isFillDragging — which would cause
669
+ // addClassesToCells to hide the fill handle indicator.
670
+ if (!fillDrag.current.isDragging) {
671
+ fillDrag.current.isDragging = true;
672
+ }
673
+
674
+ // Throttle via rAF to avoid layout thrashing
675
+ if (fillDrag.current.moveRAF != null) {
676
+ return;
677
+ }
678
+ fillDrag.current.moveRAF = requestAnimationFrame(() => {
679
+ fillDrag.current.moveRAF = null;
680
+ if (!fillDrag.current.isDragging || !fillSource.current) {
681
+ return;
682
+ }
683
+ const currentRootEl = apiRef.current.rootElementRef?.current;
684
+ const source = fillSource.current;
685
+
686
+ // Find which row and field the mouse is over
687
+ const elements = doc.elementsFromPoint(moveEvent.clientX, moveEvent.clientY);
688
+ let targetRowId = null;
689
+ let targetField = null;
690
+ for (const el of elements) {
691
+ const cellEl = el.closest('[data-field]');
692
+ if (cellEl) {
693
+ targetField = cellEl.getAttribute('data-field');
694
+ const rowEl = cellEl.closest('[data-id]');
695
+ if (rowEl) {
696
+ const idStr = rowEl.getAttribute('data-id');
697
+ if (idStr != null) {
698
+ // O(1) lookup via pre-built map
699
+ const resolved = source.rowIdMap.get(idStr);
700
+ if (resolved != null) {
701
+ targetRowId = resolved;
702
+ }
703
+ }
704
+ break;
705
+ }
706
+ }
707
+ }
708
+ if (targetRowId == null || targetField == null) {
709
+ return;
710
+ }
711
+ const {
712
+ start: minSourceRowIdx,
713
+ end: maxSourceRowIdx
714
+ } = source.rowIndexRange;
715
+ const {
716
+ start: minSourceColIdx,
717
+ end: maxSourceColIdx
718
+ } = source.columnIndexRange;
719
+ const currentVisibleRows = getVisibleRows(apiRef);
720
+ const currentVisibleColumns = apiRef.current.getVisibleColumns();
721
+ const targetRowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(targetRowId);
722
+ const targetColIndex = apiRef.current.getColumnIndex(targetField);
723
+ const isOutsideRowRange = targetRowIndex > maxSourceRowIdx || targetRowIndex < minSourceRowIdx;
724
+ const isOutsideColRange = targetColIndex > maxSourceColIdx || targetColIndex < minSourceColIdx;
725
+
726
+ // Determine fill direction and target cells
727
+ const newTargetRowIds = [];
728
+ let newTargetFields = [];
729
+ if (isOutsideRowRange) {
730
+ // Vertical fill: extend rows, keep all source columns
731
+ fillDrag.current.direction = 'vertical';
732
+ newTargetFields = source.fields;
733
+ if (targetRowIndex > maxSourceRowIdx) {
734
+ // Filling down
735
+ for (let i = maxSourceRowIdx + 1; i <= targetRowIndex; i += 1) {
736
+ if (i < currentVisibleRows.rows.length) {
737
+ newTargetRowIds.push(currentVisibleRows.rows[i].id);
738
+ }
739
+ }
740
+ } else {
741
+ // Filling up
742
+ for (let i = targetRowIndex; i < minSourceRowIdx; i += 1) {
743
+ if (i >= 0) {
744
+ newTargetRowIds.push(currentVisibleRows.rows[i].id);
745
+ }
746
+ }
747
+ }
748
+ } else if (isOutsideColRange) {
749
+ // Horizontal fill: extend columns, keep source rows
750
+ fillDrag.current.direction = 'horizontal';
751
+ const sourceRowIdSet = new Set(source.cells.map(c => String(c.id)));
752
+ for (let i = minSourceRowIdx; i <= maxSourceRowIdx; i += 1) {
753
+ if (i < currentVisibleRows.rows.length) {
754
+ const rowId = currentVisibleRows.rows[i].id;
755
+ if (sourceRowIdSet.has(String(rowId))) {
756
+ newTargetRowIds.push(rowId);
757
+ }
758
+ }
759
+ }
760
+ if (targetColIndex > maxSourceColIdx) {
761
+ // Filling right
762
+ for (let i = maxSourceColIdx + 1; i <= targetColIndex; i += 1) {
763
+ if (i < currentVisibleColumns.length) {
764
+ newTargetFields.push(currentVisibleColumns[i].field);
765
+ }
766
+ }
767
+ } else {
768
+ // Filling left
769
+ for (let i = targetColIndex; i < minSourceColIdx; i += 1) {
770
+ if (i >= 0) {
771
+ newTargetFields.push(currentVisibleColumns[i].field);
772
+ }
773
+ }
774
+ }
775
+ } else {
776
+ // Mouse is within source range — no fill
777
+ fillDrag.current.direction = null;
778
+ }
779
+ fillDrag.current.targetRowIds = newTargetRowIds;
780
+ fillDrag.current.targetFields = newTargetFields;
781
+
782
+ // Apply fill preview classes directly to DOM for immediate visual feedback
783
+ if (currentRootEl) {
784
+ const nextDecorated = new Set();
785
+ newTargetRowIds.forEach((rowId, rowIdx) => {
786
+ newTargetFields.forEach((field, colIdx) => {
787
+ const cellEl = getGridCellElement(currentRootEl, {
788
+ id: rowId,
789
+ field
790
+ });
791
+ if (cellEl) {
792
+ nextDecorated.add(cellEl);
793
+ cellEl.classList.add(gridClasses['cell--fillPreview']);
794
+ if (rowIdx === 0) {
795
+ cellEl.classList.add(gridClasses['cell--fillPreviewTop']);
796
+ }
797
+ if (rowIdx === newTargetRowIds.length - 1) {
798
+ cellEl.classList.add(gridClasses['cell--fillPreviewBottom']);
799
+ }
800
+ if (colIdx === 0) {
801
+ cellEl.classList.add(gridClasses['cell--fillPreviewLeft']);
802
+ }
803
+ if (colIdx === newTargetFields.length - 1) {
804
+ cellEl.classList.add(gridClasses['cell--fillPreviewRight']);
805
+ }
806
+ }
807
+ });
808
+ });
809
+
810
+ // Remove classes only from elements no longer in the target set
811
+ for (const el of fillDrag.current.decoratedElements) {
812
+ if (!nextDecorated.has(el)) {
813
+ el.classList.remove(gridClasses['cell--fillPreview'], gridClasses['cell--fillPreviewTop'], gridClasses['cell--fillPreviewBottom'], gridClasses['cell--fillPreviewLeft'], gridClasses['cell--fillPreviewRight']);
814
+ }
815
+ }
816
+ fillDrag.current.decoratedElements = nextDecorated;
817
+ }
818
+
819
+ // Auto-scroll: trigger for both vertical and horizontal edges
820
+ const virtualScrollerRect = apiRef.current.virtualScrollerRef?.current?.getBoundingClientRect();
821
+ if (virtualScrollerRect) {
822
+ const dimensions = gridDimensionsSelector(apiRef);
823
+ const mouseX = moveEvent.clientX - virtualScrollerRect.x;
824
+ const mouseY = moveEvent.clientY - virtualScrollerRect.y - totalHeaderHeight;
825
+ const height = dimensions.viewportOuterSize.height - totalHeaderHeight;
826
+ const width = dimensions.viewportOuterSize.width;
827
+ mousePosition.current.x = mouseX;
828
+ mousePosition.current.y = mouseY;
829
+ const isInVerticalSensitivity = mouseY <= AUTO_SCROLL_SENSITIVITY || mouseY >= height - AUTO_SCROLL_SENSITIVITY;
830
+ const isInHorizontalSensitivity = mouseX <= AUTO_SCROLL_SENSITIVITY || mouseX >= width - AUTO_SCROLL_SENSITIVITY;
831
+ if (isInVerticalSensitivity || isInHorizontalSensitivity) {
832
+ startAutoScroll();
833
+ } else {
834
+ stopAutoScroll();
835
+ }
836
+ }
837
+ });
838
+ };
839
+ const handleFillMouseUp = () => {
840
+ stopAutoScroll();
841
+ if (fillDrag.current.isDragging) {
842
+ applyFill();
843
+ }
844
+ cleanupFillDrag();
845
+ };
846
+
847
+ // Store refs for cleanup on unmount
848
+ fillDrag.current.moveHandler = handleFillMouseMove;
849
+ fillDrag.current.upHandler = handleFillMouseUp;
850
+ doc.addEventListener('mousemove', handleFillMouseMove);
851
+ doc.addEventListener('mouseup', handleFillMouseUp);
852
+ }, [apiRef, props.cellSelectionFillHandle, props.cellSelection, applyFill, cleanupFillDrag, startAutoScroll, stopAutoScroll, totalHeaderHeight]);
853
+
854
+ // Fill handle: Ctrl+D to fill down
855
+ const handleFillKeyDown = useEventCallback((_params, event) => {
856
+ if (!isFillDownShortcut(event)) {
857
+ return;
858
+ }
859
+ const selectedCells = getSelectedOrFocusedCells(apiRef);
860
+ if (selectedCells.length === 0) {
861
+ return;
862
+ }
863
+ event.preventDefault();
864
+ event.defaultMuiPrevented = true;
865
+
866
+ // Group selected cells by field (column)
867
+ const cellsByField = new Map();
868
+ for (const cell of selectedCells) {
869
+ const list = cellsByField.get(cell.field) ?? [];
870
+ list.push(cell);
871
+ cellsByField.set(cell.field, list);
872
+ }
873
+ const visibleRows = getVisibleRows(apiRef);
874
+ const fillDownSourceData = getFillDownSourceData(selectedCells);
875
+ if (selectedCells.length === 1) {
876
+ // Single cell selected: extend selection down by one row and fill
877
+ const cell = selectedCells[0];
878
+ const colDef = apiRef.current.getColumn(cell.field);
879
+ if (!colDef?.editable) {
880
+ return;
881
+ }
882
+ const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(cell.id);
883
+ const nextRowIndex = rowIndex + 1;
884
+ if (nextRowIndex >= visibleRows.rows.length) {
885
+ return;
886
+ }
887
+ const nextRowId = visibleRows.rows[nextRowIndex].id;
888
+ const sourceValue = serializeCellForClipboard(cell.id, cell.field);
889
+ apiRef.current.publishEvent('clipboardPasteStart', {
890
+ data: fillDownSourceData
891
+ });
892
+ const cellUpdater = new CellValueUpdater({
893
+ apiRef,
894
+ processRowUpdate: props.processRowUpdate,
895
+ onProcessRowUpdateError: props.onProcessRowUpdateError,
896
+ getRowId: props.getRowId
897
+ });
898
+ cellUpdater.updateCell({
899
+ rowId: nextRowId,
900
+ field: cell.field,
901
+ pastedCellValue: sourceValue
902
+ });
903
+ cellUpdater.applyUpdates();
904
+
905
+ // Move selection and focus to the filled cell
906
+ apiRef.current.setCellSelectionModel({
907
+ [nextRowId]: {
908
+ [cell.field]: true
909
+ }
910
+ });
911
+ const colIndex = apiRef.current.getColumnIndex(cell.field);
912
+ apiRef.current.scrollToIndexes({
913
+ rowIndex: nextRowIndex,
914
+ colIndex
915
+ });
916
+ apiRef.current.setCellFocus(nextRowId, cell.field);
917
+ cellWithVirtualFocus.current = {
918
+ id: nextRowId,
919
+ field: cell.field
920
+ };
921
+ return;
922
+ }
923
+
924
+ // Check if this is a single-row multi-column selection
925
+ const isSingleRowMultiColumn = selectedCells.length > 1 && [...cellsByField.values()].every(cells => cells.length === 1);
926
+ if (isSingleRowMultiColumn) {
927
+ // All cells are in the same row — extend down by one row
928
+ const firstCell = selectedCells[0];
929
+ const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(firstCell.id);
930
+ const nextRowIndex = rowIndex + 1;
931
+ if (nextRowIndex >= visibleRows.rows.length) {
932
+ return;
933
+ }
934
+ const nextRowId = visibleRows.rows[nextRowIndex].id;
935
+ apiRef.current.publishEvent('clipboardPasteStart', {
936
+ data: fillDownSourceData
937
+ });
938
+ const cellUpdater = new CellValueUpdater({
939
+ apiRef,
940
+ processRowUpdate: props.processRowUpdate,
941
+ onProcessRowUpdateError: props.onProcessRowUpdateError,
942
+ getRowId: props.getRowId
943
+ });
944
+ const newSelectionModel = {};
945
+ for (const [field, cells] of cellsByField) {
946
+ const colDef = apiRef.current.getColumn(field);
947
+ if (!colDef?.editable) {
948
+ continue;
949
+ }
950
+ const sourceValue = serializeCellForClipboard(cells[0].id, field);
951
+ cellUpdater.updateCell({
952
+ rowId: nextRowId,
953
+ field,
954
+ pastedCellValue: sourceValue
955
+ });
956
+ if (!newSelectionModel[nextRowId]) {
957
+ newSelectionModel[nextRowId] = {};
958
+ }
959
+ newSelectionModel[nextRowId][field] = true;
960
+ }
961
+ cellUpdater.applyUpdates();
962
+ apiRef.current.setCellSelectionModel(newSelectionModel);
963
+
964
+ // Focus first editable cell in the filled row
965
+ const firstEditableField = [...cellsByField.keys()].find(f => apiRef.current.getColumn(f)?.editable);
966
+ if (firstEditableField) {
967
+ const colIndex = apiRef.current.getColumnIndex(firstEditableField);
968
+ apiRef.current.scrollToIndexes({
969
+ rowIndex: nextRowIndex,
970
+ colIndex
971
+ });
972
+ apiRef.current.setCellFocus(nextRowId, firstEditableField);
973
+ cellWithVirtualFocus.current = {
974
+ id: nextRowId,
975
+ field: firstEditableField
976
+ };
977
+ }
978
+ return;
979
+ }
980
+ let cellUpdater = null;
981
+
982
+ // Multiple cells selected: for each column, top row = source, remaining = targets
983
+ for (const [field, cells] of cellsByField) {
984
+ const colDef = apiRef.current.getColumn(field);
985
+ if (!colDef?.editable) {
986
+ continue;
987
+ }
988
+
989
+ // Sort cells by row index
990
+ const sortedCells = cells.map(cell => _extends({}, cell, {
991
+ rowIndex: apiRef.current.getRowIndexRelativeToVisibleRows(cell.id)
992
+ })).sort((a, b) => a.rowIndex - b.rowIndex);
993
+ if (sortedCells.length < 2) {
994
+ continue;
995
+ }
996
+
997
+ // Top row is the source
998
+ const sourceCell = sortedCells[0];
999
+ const sourceValue = serializeCellForClipboard(sourceCell.id, sourceCell.field);
1000
+ if (!cellUpdater) {
1001
+ apiRef.current.publishEvent('clipboardPasteStart', {
1002
+ data: fillDownSourceData
1003
+ });
1004
+ cellUpdater = new CellValueUpdater({
1005
+ apiRef,
1006
+ processRowUpdate: props.processRowUpdate,
1007
+ onProcessRowUpdateError: props.onProcessRowUpdateError,
1008
+ getRowId: props.getRowId
1009
+ });
1010
+ }
1011
+
1012
+ // Fill all cells below the source
1013
+ for (let i = 1; i < sortedCells.length; i += 1) {
1014
+ cellUpdater.updateCell({
1015
+ rowId: sortedCells[i].id,
1016
+ field,
1017
+ pastedCellValue: sourceValue
1018
+ });
1019
+ }
1020
+ }
1021
+ cellUpdater?.applyUpdates();
1022
+ });
1023
+
1024
+ // Fill handle: Ctrl+R to fill right
1025
+ const handleFillRightKeyDown = useEventCallback((_params, event) => {
1026
+ if (!isFillRightShortcut(event)) {
1027
+ return;
1028
+ }
1029
+ const selectedCells = getSelectedOrFocusedCells(apiRef);
1030
+ if (selectedCells.length === 0) {
1031
+ return;
1032
+ }
1033
+ event.preventDefault();
1034
+ event.defaultMuiPrevented = true;
1035
+ const visibleColumns = apiRef.current.getVisibleColumns();
1036
+ const columnFieldToIndex = new Map(visibleColumns.map((col, i) => [col.field, i]));
1037
+
1038
+ // Group selected cells by row
1039
+ const cellsByRow = new Map();
1040
+ for (const cell of selectedCells) {
1041
+ const list = cellsByRow.get(cell.id) ?? [];
1042
+ list.push(cell);
1043
+ cellsByRow.set(cell.id, list);
1044
+ }
1045
+ if (selectedCells.length === 1) {
1046
+ // Single cell: extend selection right by one column and fill
1047
+ const cell = selectedCells[0];
1048
+ const colIndex = columnFieldToIndex.get(cell.field) ?? -1;
1049
+ const nextColIndex = colIndex + 1;
1050
+ if (nextColIndex >= visibleColumns.length) {
1051
+ return;
1052
+ }
1053
+ const nextField = visibleColumns[nextColIndex].field;
1054
+ const nextColDef = apiRef.current.getColumn(nextField);
1055
+ if (!nextColDef?.editable) {
1056
+ return;
1057
+ }
1058
+ const sourceValue = serializeCellForClipboard(cell.id, cell.field);
1059
+ apiRef.current.publishEvent('clipboardPasteStart', {
1060
+ data: [[sourceValue]]
1061
+ });
1062
+ const cellUpdater = new CellValueUpdater({
1063
+ apiRef,
1064
+ processRowUpdate: props.processRowUpdate,
1065
+ onProcessRowUpdateError: props.onProcessRowUpdateError,
1066
+ getRowId: props.getRowId
1067
+ });
1068
+ cellUpdater.updateCell({
1069
+ rowId: cell.id,
1070
+ field: nextField,
1071
+ pastedCellValue: sourceValue
1072
+ });
1073
+ cellUpdater.applyUpdates();
1074
+
1075
+ // Move selection and focus to the filled cell
1076
+ apiRef.current.setCellSelectionModel({
1077
+ [cell.id]: {
1078
+ [nextField]: true
1079
+ }
1080
+ });
1081
+ const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(cell.id);
1082
+ apiRef.current.scrollToIndexes({
1083
+ rowIndex,
1084
+ colIndex: nextColIndex
1085
+ });
1086
+ apiRef.current.setCellFocus(cell.id, nextField);
1087
+ cellWithVirtualFocus.current = {
1088
+ id: cell.id,
1089
+ field: nextField
1090
+ };
1091
+ return;
1092
+ }
1093
+
1094
+ // Check if single-column multi-row selection (extend right by one column)
1095
+ const isSingleColumnMultiRow = [...cellsByRow.values()].every(cells => cells.length === 1);
1096
+ if (isSingleColumnMultiRow) {
1097
+ const firstCell = selectedCells[0];
1098
+ const colIndex = columnFieldToIndex.get(firstCell.field) ?? -1;
1099
+ const nextColIndex = colIndex + 1;
1100
+ if (nextColIndex >= visibleColumns.length) {
1101
+ return;
1102
+ }
1103
+ const nextField = visibleColumns[nextColIndex].field;
1104
+ const nextColDef = apiRef.current.getColumn(nextField);
1105
+ if (!nextColDef?.editable) {
1106
+ return;
1107
+ }
1108
+ apiRef.current.publishEvent('clipboardPasteStart', {
1109
+ data: [...cellsByRow.entries()].map(([, cells]) => [serializeCellForClipboard(cells[0].id, cells[0].field)])
1110
+ });
1111
+ const cellUpdater = new CellValueUpdater({
1112
+ apiRef,
1113
+ processRowUpdate: props.processRowUpdate,
1114
+ onProcessRowUpdateError: props.onProcessRowUpdateError,
1115
+ getRowId: props.getRowId
1116
+ });
1117
+ const newSelectionModel = {};
1118
+ for (const [rowId, cells] of cellsByRow) {
1119
+ const sourceValue = serializeCellForClipboard(cells[0].id, cells[0].field);
1120
+ cellUpdater.updateCell({
1121
+ rowId,
1122
+ field: nextField,
1123
+ pastedCellValue: sourceValue
1124
+ });
1125
+ if (!newSelectionModel[rowId]) {
1126
+ newSelectionModel[rowId] = {};
1127
+ }
1128
+ newSelectionModel[rowId][nextField] = true;
1129
+ }
1130
+ cellUpdater.applyUpdates();
1131
+ apiRef.current.setCellSelectionModel(newSelectionModel);
1132
+ return;
1133
+ }
1134
+
1135
+ // Multiple cells per row: for each row, leftmost = source, rest = targets
1136
+ let cellUpdater = null;
1137
+ for (const [rowId, cells] of cellsByRow) {
1138
+ // Sort cells by column index
1139
+ const sortedCells = cells.map(cell => _extends({}, cell, {
1140
+ colIndex: columnFieldToIndex.get(cell.field) ?? 0
1141
+ })).sort((a, b) => a.colIndex - b.colIndex);
1142
+ if (sortedCells.length < 2) {
1143
+ continue;
1144
+ }
1145
+ const sourceCell = sortedCells[0];
1146
+ const sourceValue = serializeCellForClipboard(sourceCell.id, sourceCell.field);
1147
+ if (!cellUpdater) {
1148
+ apiRef.current.publishEvent('clipboardPasteStart', {
1149
+ data: [...cellsByRow.entries()].map(([, rowCells]) => {
1150
+ const sorted = rowCells.map(c => _extends({}, c, {
1151
+ colIndex: columnFieldToIndex.get(c.field) ?? 0
1152
+ })).sort((a, b) => a.colIndex - b.colIndex);
1153
+ return [serializeCellForClipboard(sorted[0].id, sorted[0].field)];
1154
+ })
1155
+ });
1156
+ cellUpdater = new CellValueUpdater({
1157
+ apiRef,
1158
+ processRowUpdate: props.processRowUpdate,
1159
+ onProcessRowUpdateError: props.onProcessRowUpdateError,
1160
+ getRowId: props.getRowId
1161
+ });
1162
+ }
1163
+
1164
+ // Fill all cells to the right of the source
1165
+ for (let i = 1; i < sortedCells.length; i += 1) {
1166
+ const colDef = apiRef.current.getColumn(sortedCells[i].field);
1167
+ if (!colDef?.editable) {
1168
+ continue;
1169
+ }
1170
+ cellUpdater.updateCell({
1171
+ rowId,
1172
+ field: sortedCells[i].field,
1173
+ pastedCellValue: sourceValue
1174
+ });
1175
+ }
1176
+ }
1177
+ cellUpdater?.applyUpdates();
1178
+ });
1179
+ useGridEvent(apiRef, 'cellMouseDown', runIfCellSelectionIsEnabled(handleFillHandleMouseDown));
362
1180
  useGridEvent(apiRef, 'cellClick', runIfCellSelectionIsEnabled(handleCellClick));
363
1181
  useGridEvent(apiRef, 'cellFocusIn', runIfCellSelectionIsEnabled(handleCellFocusIn));
364
1182
  useGridEvent(apiRef, 'cellKeyDown', runIfCellSelectionIsEnabled(handleCellKeyDown));
1183
+ useGridEvent(apiRef, 'cellKeyDown', runIfCellSelectionIsEnabled(handleFillKeyDown));
1184
+ useGridEvent(apiRef, 'cellKeyDown', runIfCellSelectionIsEnabled(handleFillRightKeyDown));
365
1185
  useGridEvent(apiRef, 'cellMouseDown', runIfCellSelectionIsEnabled(handleCellMouseDown));
366
1186
  useGridEvent(apiRef, 'cellMouseOver', runIfCellSelectionIsEnabled(handleCellMouseOver));
367
1187
  React.useEffect(() => {
@@ -373,10 +1193,11 @@ export const useGridCellSelection = (apiRef, props) => {
373
1193
  const rootRef = apiRef.current.rootElementRef?.current;
374
1194
  return () => {
375
1195
  stopAutoScroll();
1196
+ cleanupFillDrag();
376
1197
  const document = ownerDocument(rootRef);
377
1198
  document.removeEventListener('mouseup', handleMouseUp);
378
1199
  };
379
- }, [apiRef, hasRootReference, handleMouseUp, stopAutoScroll]);
1200
+ }, [apiRef, hasRootReference, handleMouseUp, stopAutoScroll, cleanupFillDrag]);
380
1201
  const checkIfCellIsSelected = React.useCallback((isSelected, {
381
1202
  id,
382
1203
  field
@@ -388,13 +1209,34 @@ export const useGridCellSelection = (apiRef, props) => {
388
1209
  field
389
1210
  }) => {
390
1211
  const visibleRows = getVisibleRows(apiRef);
1212
+
1213
+ // Note: Fill preview classes during drag are applied via direct DOM manipulation
1214
+ // in handleFillMouseMove for performance. The pipe processor only handles
1215
+ // the fill handle indicator (cell--withFillHandle) on the selection's bottom-right cell.
1216
+
391
1217
  if (!visibleRows.range || !apiRef.current.isCellSelected(id, field)) {
1218
+ // Show fill handle on the focused cell when no cell selection exists
1219
+ if (props.cellSelectionFillHandle && !fillDrag.current.isDragging) {
1220
+ const focusedCell = gridFocusCellSelector(apiRef);
1221
+ if (focusedCell && focusedCell.id === id && focusedCell.field === field) {
1222
+ const selectionModel = apiRef.current.getCellSelectionModel();
1223
+ const hasSelection = Object.keys(selectionModel).some(rowId => Object.values(selectionModel[rowId]).some(Boolean));
1224
+ if (!hasSelection) {
1225
+ const col = apiRef.current.getColumn(field);
1226
+ if (col?.editable) {
1227
+ return [...classes, gridClasses['cell--withFillHandle']];
1228
+ }
1229
+ }
1230
+ }
1231
+ }
392
1232
  return classes;
393
1233
  }
394
1234
  const newClasses = [...classes];
395
1235
  const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(id);
396
1236
  const columnIndex = apiRef.current.getColumnIndex(field);
397
1237
  const visibleColumns = apiRef.current.getVisibleColumns();
1238
+ let isBottom = false;
1239
+ let isRight = false;
398
1240
  if (rowIndex > 0) {
399
1241
  const {
400
1242
  id: previousRowId
@@ -411,9 +1253,11 @@ export const useGridCellSelection = (apiRef, props) => {
411
1253
  } = visibleRows.rows[rowIndex + 1];
412
1254
  if (!apiRef.current.isCellSelected(nextRowId, field)) {
413
1255
  newClasses.push(gridClasses['cell--rangeBottom']);
1256
+ isBottom = true;
414
1257
  }
415
1258
  } else {
416
1259
  newClasses.push(gridClasses['cell--rangeBottom']);
1260
+ isBottom = true;
417
1261
  }
418
1262
  if (columnIndex > 0) {
419
1263
  const {
@@ -431,12 +1275,28 @@ export const useGridCellSelection = (apiRef, props) => {
431
1275
  } = visibleColumns[columnIndex + 1];
432
1276
  if (!apiRef.current.isCellSelected(id, nextColumnField)) {
433
1277
  newClasses.push(gridClasses['cell--rangeRight']);
1278
+ isRight = true;
434
1279
  }
435
1280
  } else {
436
1281
  newClasses.push(gridClasses['cell--rangeRight']);
1282
+ isRight = true;
1283
+ }
1284
+
1285
+ // Add fill handle to the bottom-right cell of the selection
1286
+ // Show if any selected column is editable (not just the bottom-right column)
1287
+ if (props.cellSelectionFillHandle && isBottom && isRight && !fillDrag.current.isDragging) {
1288
+ const selectionModel = apiRef.current.getCellSelectionModel();
1289
+ const selectedFieldsInRow = selectionModel[id];
1290
+ const hasEditableColumn = selectedFieldsInRow && Object.keys(selectedFieldsInRow).some(f => {
1291
+ const col = apiRef.current.getColumn(f);
1292
+ return col?.editable && selectedFieldsInRow[f];
1293
+ });
1294
+ if (hasEditableColumn) {
1295
+ newClasses.push(gridClasses['cell--withFillHandle']);
1296
+ }
437
1297
  }
438
1298
  return newClasses;
439
- }, [apiRef]);
1299
+ }, [apiRef, props.cellSelectionFillHandle]);
440
1300
  const canUpdateFocus = React.useCallback((initialValue, {
441
1301
  event,
442
1302
  cell