Skip to content

Commit ec392f4

Browse files
feat: customizable fill handle config (#1068)
* feat: customizable fill handle config * fix comments * Some more tweaks * Update defaults * Fix linting --------- Co-authored-by: lukasmasuch <[email protected]>
1 parent 09d3f65 commit ec392f4

File tree

8 files changed

+251
-30
lines changed

8 files changed

+251
-30
lines changed

packages/core/API.md

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const portalRef = useRef(null);
1515
<>
1616
{
1717
createPortal(
18-
<div ref={portalRef} style="position: fixed; left: 0; top: 0; z-index: 9999;" />,
18+
<div ref={portalRef} style="position: fixed; left: 0; top: 0; z-index: 9999;" />,
1919
document.body
2020
)
2121
}
@@ -1019,10 +1019,73 @@ Highlight regions are regions on the grid which get drawn with a background colo
10191019
## fillHandle
10201020

10211021
```ts
1022-
fillHandle?: boolean;
1022+
fillHandle?: boolean | Partial<FillHandleConfig>;
10231023
```
10241024

1025-
Controls the visibility of the fill handle used for filling cells with the mouse.
1025+
Controls the presence of the fill handle used for filling cells with the mouse.
1026+
1027+
**Configuration for the fill-handle (the small drag handle that appears in the
1028+
bottom-right of the current selection).**
1029+
1030+
```ts
1031+
interface FillHandleConfig {
1032+
/** Shape of the handle. Defaults to "square". */
1033+
shape?: "square" | "circle";
1034+
/** Width/height (or diameter for circles) in CSS pixels. Defaults to `4`. */
1035+
size?: number;
1036+
/** Horizontal offset from the bottom-right corner of the cell (px). */
1037+
offsetX?: number;
1038+
/** Vertical offset from the bottom-right corner of the cell (px). Default is `-2`. */
1039+
offsetY?: number;
1040+
/** Stroke width (px) of the outline that surrounds the handle. Defaults to `0`. */
1041+
outline?: number;
1042+
}
1043+
```
1044+
1045+
---
1046+
1047+
## allowedFillDirections
1048+
1049+
```ts
1050+
allowedFillDirections?: "horizontal" | "vertical" | "orthogonal" | "any"; // default "orthogonal"
1051+
```
1052+
1053+
Controls which directions the fill-handle may extend when the user drags it.
1054+
1055+
- "horizontal": Only left/right expansion is allowed.
1056+
- "vertical": Only up/down expansion is allowed.
1057+
- "orthogonal": Expands to the closest orthogonal edge (up/down/left/right). This is the default.
1058+
- "any": Expands freely in both axes (forms a rectangle to the pointer).
1059+
1060+
---
1061+
1062+
## onFillPattern
1063+
1064+
```ts
1065+
onFillPattern?: (event: FillPatternEventArgs) => void;
1066+
1067+
interface FillPatternEventArgs extends PreventableEvent {
1068+
patternSource: Rectangle;
1069+
fillDestination: Rectangle;
1070+
}
1071+
```
1072+
1073+
Emitted whenever the user initiates a pattern fill using the fill handle. The event provides both the source
1074+
pattern region and the destination region about to be filled. Call `event.preventDefault()` to cancel the fill.
1075+
1076+
Example: prevent filling into protected regions
1077+
1078+
```ts
1079+
<DataEditor
1080+
onFillPattern={e => {
1081+
const { fillDestination } = e;
1082+
if (/* your condition */ false) {
1083+
e.preventDefault();
1084+
}
1085+
}}
1086+
{...props}
1087+
/>
1088+
```
10261089

10271090
---
10281091

packages/core/src/docs/examples/fill-handle.stories.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
defaultProps,
1010
clearCell,
1111
} from "../../data-editor/stories/utils.js";
12-
import { GridCellKind } from "../../internal/data-grid/data-grid-types.js";
12+
import { GridCellKind, type FillHandleDirection } from "../../internal/data-grid/data-grid-types.js";
13+
import type { FillPatternEventArgs } from "../../index.js";
1314
import { SimpleThemeWrapper } from "../../stories/story-utils.js";
1415

1516
export default {
@@ -34,9 +35,39 @@ export default {
3435
</SimpleThemeWrapper>
3536
),
3637
],
38+
argTypes: {
39+
fillHandleEnabled: { control: "boolean", name: "fillHandle enabled" },
40+
shape: { control: { type: "inline-radio" }, options: ["square", "circle"], name: "shape" },
41+
size: { control: { type: "number" }, name: "size" },
42+
offsetX: { control: { type: "number" }, name: "offsetX" },
43+
offsetY: { control: { type: "number" }, name: "offsetY" },
44+
outline: { control: { type: "number" }, name: "outline" },
45+
allowedFillDirections: {
46+
control: { type: "inline-radio" },
47+
options: ["horizontal", "vertical", "orthogonal", "any"],
48+
name: "allowedFillDirections",
49+
},
50+
},
51+
args: {
52+
fillHandleEnabled: true,
53+
shape: "square",
54+
size: 4,
55+
offsetX: -2,
56+
offsetY: -2,
57+
outline: 0,
58+
allowedFillDirections: "orthogonal",
59+
},
3760
};
3861

39-
export const FillHandle: React.VFC = () => {
62+
export const FillHandle: React.VFC<{
63+
fillHandleEnabled: boolean;
64+
shape: "square" | "circle";
65+
size: number;
66+
offsetX: number;
67+
offsetY: number;
68+
outline: number;
69+
allowedFillDirections: FillHandleDirection;
70+
}> = ({ fillHandleEnabled, shape, size, offsetX, offsetY, outline, allowedFillDirections }) => {
4071
const { cols, getCellContent, setCellValueRaw, setCellValue } = useMockDataGenerator(60, false);
4172

4273
const [numRows, setNumRows] = React.useState(50);
@@ -72,7 +103,8 @@ export const FillHandle: React.VFC = () => {
72103
columns={cols}
73104
rowMarkers={"both"}
74105
onPaste={true}
75-
fillHandle={true}
106+
fillHandle={fillHandleEnabled ? { shape, size, offsetX, offsetY, outline } : false}
107+
allowedFillDirections={allowedFillDirections}
76108
keybindings={{ downFill: true, rightFill: true }}
77109
onCellEdited={setCellValue}
78110
trailingRowOptions={{

packages/core/src/internal/data-grid/data-grid-types.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,36 @@ export type CompactSelectionRanges = readonly Slice[];
526526

527527
export type FillHandleDirection = "horizontal" | "vertical" | "orthogonal" | "any";
528528

529+
/**
530+
* Configuration options for the fill-handle (the little drag square in the bottom-right of a selection).
531+
*
532+
* `shape` – Either a square or a circle. Default is `square`.
533+
* `size` – Width/height (or diameter) in CSS pixels. Default is `4`.
534+
* `offsetX` – Horizontal offset from the bottom-right corner of the cell (positive is →). Default is `-2`.
535+
* `offsetY` – Vertical offset from the bottom-right corner of the cell (positive is ↓). Default is `-2`.
536+
* `outline` – Width of the outline stroke in CSS pixels. Default is `0`.
537+
*/
538+
export type FillHandleConfig = {
539+
readonly shape: "square" | "circle";
540+
readonly size: number;
541+
readonly offsetX: number;
542+
readonly offsetY: number;
543+
readonly outline: number;
544+
};
545+
546+
export type FillHandle = boolean | Partial<FillHandleConfig>;
547+
548+
/**
549+
* Default configuration used when `fillHandle` is simply `true`.
550+
*/
551+
export const DEFAULT_FILL_HANDLE: Readonly<FillHandleConfig> = {
552+
shape: "square",
553+
size: 4,
554+
offsetX: -2,
555+
offsetY: -2,
556+
outline: 0,
557+
} as const;
558+
529559
function mergeRanges(input: CompactSelectionRanges) {
530560
if (input.length === 0) {
531561
return [];
@@ -561,7 +591,7 @@ export class CompactSelection {
561591

562592
static create = (items: CompactSelectionRanges) => {
563593
return new CompactSelection(mergeRanges(items));
564-
}
594+
};
565595

566596
static empty = (): CompactSelection => {
567597
return emptyCompactSelection ?? (emptyCompactSelection = new CompactSelection([]));

packages/core/src/internal/data-grid/data-grid.tsx

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
booleanCellIsEditable,
2424
type InnerGridColumn,
2525
type DrawCellCallback,
26+
type FillHandle,
27+
DEFAULT_FILL_HANDLE,
2628
} from "./data-grid-types.js";
2729
import { CellSet } from "./cell-set.js";
2830
import { SpriteManager, type SpriteMap } from "./data-grid-sprites.js";
@@ -145,7 +147,7 @@ export interface DataGridProps {
145147
* @defaultValue false
146148
* @group Editing
147149
*/
148-
readonly fillHandle: boolean | undefined;
150+
readonly fillHandle: FillHandle | undefined;
149151

150152
readonly disabledRows: CompactSelection | undefined;
151153
/**
@@ -314,13 +316,15 @@ type DamageUpdateList = readonly {
314316
// newValue: GridCell,
315317
}[];
316318

317-
const fillHandleClickSize = 6;
318-
319319
export interface DataGridRef {
320320
focus: () => void;
321321
getBounds: (col?: number, row?: number) => Rectangle | undefined;
322322
damage: (cells: DamageUpdateList) => void;
323-
getMouseArgsForPosition: (posX: number, posY: number, ev?: MouseEvent | TouchEvent) => GridMouseEventArgs | undefined;
323+
getMouseArgsForPosition: (
324+
posX: number,
325+
posY: number,
326+
ev?: MouseEvent | TouchEvent
327+
) => GridMouseEventArgs | undefined;
324328
}
325329

326330
const getRowData = (cell: InnerGridCell, getCellRenderer?: GetCellRendererCallback) => {
@@ -446,7 +450,10 @@ const DataGrid: React.ForwardRefRenderFunction<DataGridRef, DataGridProps> = (p,
446450
}, [cellYOffset, cellXOffset, translateX, translateY, enableFirefoxRescaling, enableSafariRescaling]);
447451

448452
const mappedColumns = useMappedColumns(columns, freezeColumns);
449-
const stickyX = React.useMemo(() => fixedShadowX ? getStickyWidth(mappedColumns, dragAndDropState) : 0,[mappedColumns, dragAndDropState, fixedShadowX]);
453+
const stickyX = React.useMemo(
454+
() => (fixedShadowX ? getStickyWidth(mappedColumns, dragAndDropState) : 0),
455+
[mappedColumns, dragAndDropState, fixedShadowX]
456+
);
450457

451458
// row: -1 === columnHeader, -2 === groupHeader
452459
const getBoundsForItem = React.useCallback(
@@ -653,18 +660,29 @@ const DataGrid: React.ForwardRefRenderFunction<DataGridRef, DataGridProps> = (p,
653660
const isEdge = bounds !== undefined && bounds.x + bounds.width - posX < edgeDetectionBuffer;
654661

655662
let isFillHandle = false;
656-
if (fillHandle && selection.current !== undefined) {
663+
const drawFill = fillHandle !== false && fillHandle !== undefined;
664+
if (drawFill && selection.current !== undefined) {
665+
const fill =
666+
typeof fillHandle === "object"
667+
? { ...DEFAULT_FILL_HANDLE, ...fillHandle }
668+
: DEFAULT_FILL_HANDLE;
669+
670+
const fillHandleClickSize = fill.size;
671+
const half = fillHandleClickSize / 2;
672+
657673
const fillHandleLocation = rectBottomRight(selection.current.range);
658-
const fillHandleCellBounds = getBoundsForItem(canvas, fillHandleLocation[0], fillHandleLocation[1]);
674+
const fillBounds = getBoundsForItem(canvas, fillHandleLocation[0], fillHandleLocation[1]);
659675

660-
if (fillHandleCellBounds !== undefined) {
661-
const handleLogicalCenterX = fillHandleCellBounds.x + fillHandleCellBounds.width - 2;
662-
const handleLogicalCenterY = fillHandleCellBounds.y + fillHandleCellBounds.height - 2;
676+
if (fillBounds !== undefined) {
677+
// Handle center sits exactly on the bottom-right corner of the cell.
678+
// Offset by half pixel to align with grid lines.
679+
const centerX = fillBounds.x + fillBounds.width + fill.offsetX - half + 0.5;
680+
const centerY = fillBounds.y + fillBounds.height + fill.offsetY - half + 0.5;
663681

664-
//check if posX and posY are within fillHandleClickSize from handleLogicalCenter
682+
// Check if posX and posY are within fillHandleClickSize from handleLogicalCenter
665683
isFillHandle =
666-
Math.abs(handleLogicalCenterX - posX) < fillHandleClickSize &&
667-
Math.abs(handleLogicalCenterY - posY) < fillHandleClickSize;
684+
Math.abs(centerX - posX) < fillHandleClickSize &&
685+
Math.abs(centerY - posY) < fillHandleClickSize;
668686
}
669687
}
670688

@@ -1710,9 +1728,9 @@ const DataGrid: React.ForwardRefRenderFunction<DataGridRef, DataGridProps> = (p,
17101728
}
17111729

17121730
return getMouseArgsForPosition(canvasRef.current, posX, posY, ev);
1713-
}
1731+
},
17141732
}),
1715-
[canvasRef, damage, getBoundsForItem]
1733+
[canvasRef, damage, getBoundsForItem, getMouseArgsForPosition]
17161734
);
17171735

17181736
const lastFocusedSubdomNode = React.useRef<Item>();

packages/core/src/internal/data-grid/render/data-grid-render.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) {
443443
const selectionCurrent = selection.current;
444444

445445
if (
446-
fillHandle &&
446+
(fillHandle !== false && fillHandle !== undefined) &&
447447
drawFocus &&
448448
selectionCurrent !== undefined &&
449449
damage.has(rectBottomRight(selectionCurrent.range))

packages/core/src/internal/data-grid/render/data-grid.render.rings.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
22
/* eslint-disable unicorn/no-for-loop */
3-
import { type GridSelection, type InnerGridCell, type Item } from "../data-grid-types.js";
3+
import { type GridSelection, type InnerGridCell, type Item, type FillHandle, DEFAULT_FILL_HANDLE } from "../data-grid-types.js";
44
import { getStickyWidth, type MappedGridColumn, computeBounds, getFreezeTrailingHeight } from "./data-grid-lib.js";
55
import { type FullTheme } from "../../../common/styles.js";
66
import { blend, withAlpha } from "../color-parser.js";
@@ -188,11 +188,16 @@ export function drawFillHandle(
188188
getCellContent: (cell: Item) => InnerGridCell,
189189
freezeTrailingRows: number,
190190
hasAppendRow: boolean,
191-
fillHandle: boolean,
191+
fillHandle: FillHandle,
192192
rows: number
193193
): (() => void) | undefined {
194194
if (selectedCell.current === undefined) return undefined;
195195

196+
const drawFill = fillHandle !== false && fillHandle !== undefined;
197+
if (!drawFill) return undefined;
198+
199+
const fill = typeof fillHandle === "object" ? { ...DEFAULT_FILL_HANDLE, ...fillHandle } : DEFAULT_FILL_HANDLE;
200+
196201
const range = selectedCell.current.range;
197202
const currentItem = selectedCell.current.cell;
198203
const fillHandleTarget = [range.x + range.width - 1, range.y + range.height - 1];
@@ -261,7 +266,7 @@ export function drawFillHandle(
261266
}
262267
}
263268

264-
const doHandle = row === fillHandleRow && isFillHandleCol && fillHandle;
269+
const doHandle = row === fillHandleRow && isFillHandleCol && drawFill;
265270

266271
if (doHandle) {
267272
drawHandleCb = () => {
@@ -270,10 +275,50 @@ export function drawFillHandle(
270275
ctx.rect(clipX, 0, width - clipX, height);
271276
ctx.clip();
272277
}
278+
// Draw a larger, outlined fill handle similar to Excel / Google Sheets.
279+
const size = fill.size;
280+
const half = size / 2;
281+
282+
// Place the handle so its center sits on the bottom-right corner of the cell,
283+
// plus any configured offsets (fill.offsetX, fill.offsetY).
284+
// Offset by half pixel to align with grid lines.
285+
const hx = cellX + cellWidth + fill.offsetX - half + 0.5;
286+
const hy = drawY + rh + fill.offsetY - half + 0.5;
287+
273288
ctx.beginPath();
274-
ctx.rect(cellX + cellWidth - 4, drawY + rh - 4, 4, 4);
289+
if (fill.shape === "circle") {
290+
ctx.arc(hx + half, hy + half, half, 0, Math.PI * 2);
291+
} else {
292+
ctx.rect(hx, hy, size, size);
293+
}
294+
295+
// Fill
275296
ctx.fillStyle = col.themeOverride?.accentColor ?? theme.accentColor;
276297
ctx.fill();
298+
299+
// Outline (drawn so it doesn't eat into the filled area)
300+
if (fill.outline > 0) {
301+
ctx.lineWidth = fill.outline;
302+
ctx.strokeStyle = theme.bgCell;
303+
if (fill.shape === "circle") {
304+
ctx.beginPath();
305+
ctx.arc(
306+
hx + half,
307+
hy + half,
308+
half + fill.outline / 2,
309+
0,
310+
Math.PI * 2
311+
);
312+
ctx.stroke();
313+
} else {
314+
ctx.strokeRect(
315+
hx - fill.outline / 2,
316+
hy - fill.outline / 2,
317+
size + fill.outline,
318+
size + fill.outline
319+
);
320+
}
321+
}
277322
};
278323
}
279324
return drawHandleCb !== undefined;

0 commit comments

Comments
 (0)