Skip to content

feat: customizable fill handle config #1068

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions packages/core/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const portalRef = useRef(null);
<>
{
createPortal(
<div ref={portalRef} style="position: fixed; left: 0; top: 0; z-index: 9999;" />,
<div ref={portalRef} style="position: fixed; left: 0; top: 0; z-index: 9999;" />,
document.body
)
}
Expand Down Expand Up @@ -1019,10 +1019,73 @@ Highlight regions are regions on the grid which get drawn with a background colo
## fillHandle

```ts
fillHandle?: boolean;
fillHandle?: boolean | Partial<FillHandleConfig>;
```

Controls the visibility of the fill handle used for filling cells with the mouse.
Controls the presence of the fill handle used for filling cells with the mouse.

**Configuration for the fill-handle (the small drag handle that appears in the
bottom-right of the current selection).**

```ts
interface FillHandleConfig {
/** Shape of the handle. Defaults to "square". */
shape?: "square" | "circle";
/** Width/height (or diameter for circles) in CSS pixels. Defaults to `4`. */
size?: number;
/** Horizontal offset from the bottom-right corner of the cell (px). */
offsetX?: number;
/** Vertical offset from the bottom-right corner of the cell (px). Default is `-2`. */
offsetY?: number;
/** Stroke width (px) of the outline that surrounds the handle. Defaults to `0`. */
outline?: number;
}
```

---

## allowedFillDirections

```ts
allowedFillDirections?: "horizontal" | "vertical" | "orthogonal" | "any"; // default "orthogonal"
```

Controls which directions the fill-handle may extend when the user drags it.

- "horizontal": Only left/right expansion is allowed.
- "vertical": Only up/down expansion is allowed.
- "orthogonal": Expands to the closest orthogonal edge (up/down/left/right). This is the default.
- "any": Expands freely in both axes (forms a rectangle to the pointer).

---

## onFillPattern

```ts
onFillPattern?: (event: FillPatternEventArgs) => void;

interface FillPatternEventArgs extends PreventableEvent {
patternSource: Rectangle;
fillDestination: Rectangle;
}
```

Emitted whenever the user initiates a pattern fill using the fill handle. The event provides both the source
pattern region and the destination region about to be filled. Call `event.preventDefault()` to cancel the fill.

Example: prevent filling into protected regions

```ts
<DataEditor
onFillPattern={e => {
const { fillDestination } = e;
if (/* your condition */ false) {
e.preventDefault();
}
}}
{...props}
/>
```

---

Expand Down
38 changes: 35 additions & 3 deletions packages/core/src/docs/examples/fill-handle.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
defaultProps,
clearCell,
} from "../../data-editor/stories/utils.js";
import { GridCellKind } from "../../internal/data-grid/data-grid-types.js";
import { GridCellKind, type FillHandleDirection } from "../../internal/data-grid/data-grid-types.js";
import type { FillPatternEventArgs } from "../../index.js";
import { SimpleThemeWrapper } from "../../stories/story-utils.js";

export default {
Expand All @@ -34,9 +35,39 @@ export default {
</SimpleThemeWrapper>
),
],
argTypes: {
fillHandleEnabled: { control: "boolean", name: "fillHandle enabled" },
shape: { control: { type: "inline-radio" }, options: ["square", "circle"], name: "shape" },
size: { control: { type: "number" }, name: "size" },
offsetX: { control: { type: "number" }, name: "offsetX" },
offsetY: { control: { type: "number" }, name: "offsetY" },
outline: { control: { type: "number" }, name: "outline" },
allowedFillDirections: {
control: { type: "inline-radio" },
options: ["horizontal", "vertical", "orthogonal", "any"],
name: "allowedFillDirections",
},
},
args: {
fillHandleEnabled: true,
shape: "square",
size: 4,
offsetX: -2,
offsetY: -2,
outline: 0,
allowedFillDirections: "orthogonal",
},
};

export const FillHandle: React.VFC = () => {
export const FillHandle: React.VFC<{
fillHandleEnabled: boolean;
shape: "square" | "circle";
size: number;
offsetX: number;
offsetY: number;
outline: number;
allowedFillDirections: FillHandleDirection;
}> = ({ fillHandleEnabled, shape, size, offsetX, offsetY, outline, allowedFillDirections, onFillPattern }) => {
const { cols, getCellContent, setCellValueRaw, setCellValue } = useMockDataGenerator(60, false);

const [numRows, setNumRows] = React.useState(50);
Expand Down Expand Up @@ -72,7 +103,8 @@ export const FillHandle: React.VFC = () => {
columns={cols}
rowMarkers={"both"}
onPaste={true}
fillHandle={true}
fillHandle={fillHandleEnabled ? { shape, size, offsetX, offsetY, outline } : false}
allowedFillDirections={allowedFillDirections}
keybindings={{ downFill: true, rightFill: true }}
onCellEdited={setCellValue}
trailingRowOptions={{
Expand Down
32 changes: 31 additions & 1 deletion packages/core/src/internal/data-grid/data-grid-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,36 @@ export type CompactSelectionRanges = readonly Slice[];

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

/**
* Configuration options for the fill-handle (the little drag square in the bottom-right of a selection).
*
* `shape` – Either a square or a circle. Default is `square`.
* `size` – Width/height (or diameter) in CSS pixels. Default is `4`.
* `offsetX` – Horizontal offset from the bottom-right corner of the cell (positive is →). Default is `-2`.
* `offsetY` – Vertical offset from the bottom-right corner of the cell (positive is ↓). Default is `-2`.
* `outline` – Width of the outline stroke in CSS pixels. Default is `0`.
*/
export type FillHandleConfig = {
readonly shape: "square" | "circle";
readonly size: number;
readonly offsetX: number;
readonly offsetY: number;
readonly outline: number;
};

export type FillHandle = boolean | Partial<FillHandleConfig>;

/**
* Default configuration used when `fillHandle` is simply `true`.
*/
export const DEFAULT_FILL_HANDLE: Readonly<FillHandleConfig> = {
shape: "square",
size: 4,
offsetX: -2,
offsetY: -2,
outline: 0,
} as const;

function mergeRanges(input: CompactSelectionRanges) {
if (input.length === 0) {
return [];
Expand Down Expand Up @@ -561,7 +591,7 @@ export class CompactSelection {

static create = (items: CompactSelectionRanges) => {
return new CompactSelection(mergeRanges(items));
}
};

static empty = (): CompactSelection => {
return emptyCompactSelection ?? (emptyCompactSelection = new CompactSelection([]));
Expand Down
48 changes: 33 additions & 15 deletions packages/core/src/internal/data-grid/data-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
booleanCellIsEditable,
type InnerGridColumn,
type DrawCellCallback,
type FillHandle,
DEFAULT_FILL_HANDLE,
} from "./data-grid-types.js";
import { CellSet } from "./cell-set.js";
import { SpriteManager, type SpriteMap } from "./data-grid-sprites.js";
Expand Down Expand Up @@ -145,7 +147,7 @@ export interface DataGridProps {
* @defaultValue false
* @group Editing
*/
readonly fillHandle: boolean | undefined;
readonly fillHandle: FillHandle | undefined;

readonly disabledRows: CompactSelection | undefined;
/**
Expand Down Expand Up @@ -314,13 +316,15 @@ type DamageUpdateList = readonly {
// newValue: GridCell,
}[];

const fillHandleClickSize = 6;

export interface DataGridRef {
focus: () => void;
getBounds: (col?: number, row?: number) => Rectangle | undefined;
damage: (cells: DamageUpdateList) => void;
getMouseArgsForPosition: (posX: number, posY: number, ev?: MouseEvent | TouchEvent) => GridMouseEventArgs | undefined;
getMouseArgsForPosition: (
posX: number,
posY: number,
ev?: MouseEvent | TouchEvent
) => GridMouseEventArgs | undefined;
}

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

const mappedColumns = useMappedColumns(columns, freezeColumns);
const stickyX = React.useMemo(() => fixedShadowX ? getStickyWidth(mappedColumns, dragAndDropState) : 0,[mappedColumns, dragAndDropState, fixedShadowX]);
const stickyX = React.useMemo(
() => (fixedShadowX ? getStickyWidth(mappedColumns, dragAndDropState) : 0),
[mappedColumns, dragAndDropState, fixedShadowX]
);

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

let isFillHandle = false;
if (fillHandle && selection.current !== undefined) {
const drawFill = fillHandle !== false && fillHandle !== undefined;
if (drawFill && selection.current !== undefined) {
const fill =
typeof fillHandle === "object"
? { ...DEFAULT_FILL_HANDLE, ...fillHandle }
: DEFAULT_FILL_HANDLE;

const fillHandleClickSize = fill.size;
const half = fillHandleClickSize / 2;

const fillHandleLocation = rectBottomRight(selection.current.range);
const fillHandleCellBounds = getBoundsForItem(canvas, fillHandleLocation[0], fillHandleLocation[1]);
const fillBounds = getBoundsForItem(canvas, fillHandleLocation[0], fillHandleLocation[1]);

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

//check if posX and posY are within fillHandleClickSize from handleLogicalCenter
// Check if posX and posY are within fillHandleClickSize from handleLogicalCenter
isFillHandle =
Math.abs(handleLogicalCenterX - posX) < fillHandleClickSize &&
Math.abs(handleLogicalCenterY - posY) < fillHandleClickSize;
Math.abs(centerX - posX) < fillHandleClickSize &&
Math.abs(centerY - posY) < fillHandleClickSize;
}
}

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

return getMouseArgsForPosition(canvasRef.current, posX, posY, ev);
}
},
}),
[canvasRef, damage, getBoundsForItem]
[canvasRef, damage, getBoundsForItem, getMouseArgsForPosition]
);

const lastFocusedSubdomNode = React.useRef<Item>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ export function drawGrid(arg: DrawGridArg, lastArg: DrawGridArg | undefined) {
const selectionCurrent = selection.current;

if (
fillHandle &&
(fillHandle !== false && fillHandle !== undefined) &&
drawFocus &&
selectionCurrent !== undefined &&
damage.has(rectBottomRight(selectionCurrent.range))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable unicorn/no-for-loop */
import { type GridSelection, type InnerGridCell, type Item } from "../data-grid-types.js";
import { type GridSelection, type InnerGridCell, type Item, type FillHandle, DEFAULT_FILL_HANDLE } from "../data-grid-types.js";
import { getStickyWidth, type MappedGridColumn, computeBounds, getFreezeTrailingHeight } from "./data-grid-lib.js";
import { type FullTheme } from "../../../common/styles.js";
import { blend, withAlpha } from "../color-parser.js";
Expand Down Expand Up @@ -188,11 +188,16 @@ export function drawFillHandle(
getCellContent: (cell: Item) => InnerGridCell,
freezeTrailingRows: number,
hasAppendRow: boolean,
fillHandle: boolean,
fillHandle: FillHandle,
rows: number
): (() => void) | undefined {
if (selectedCell.current === undefined) return undefined;

const drawFill = fillHandle !== false && fillHandle !== undefined;
if (!drawFill) return undefined;

const fill = typeof fillHandle === "object" ? { ...DEFAULT_FILL_HANDLE, ...fillHandle } : DEFAULT_FILL_HANDLE;

const range = selectedCell.current.range;
const currentItem = selectedCell.current.cell;
const fillHandleTarget = [range.x + range.width - 1, range.y + range.height - 1];
Expand Down Expand Up @@ -261,7 +266,7 @@ export function drawFillHandle(
}
}

const doHandle = row === fillHandleRow && isFillHandleCol && fillHandle;
const doHandle = row === fillHandleRow && isFillHandleCol && drawFill;

if (doHandle) {
drawHandleCb = () => {
Expand All @@ -270,10 +275,50 @@ export function drawFillHandle(
ctx.rect(clipX, 0, width - clipX, height);
ctx.clip();
}
// Draw a larger, outlined fill handle similar to Excel / Google Sheets.
const size = fill.size;
const half = size / 2;

// Place the handle so its center sits on the bottom-right corner of the cell,
// plus any configured offsets (fill.offsetX, fill.offsetY).
// Offset by half pixel to align with grid lines.
const hx = cellX + cellWidth + fill.offsetX - half + 0.5;
const hy = drawY + rh + fill.offsetY - half + 0.5;

ctx.beginPath();
ctx.rect(cellX + cellWidth - 4, drawY + rh - 4, 4, 4);
if (fill.shape === "circle") {
ctx.arc(hx + half, hy + half, half, 0, Math.PI * 2);
} else {
ctx.rect(hx, hy, size, size);
}

// Fill
ctx.fillStyle = col.themeOverride?.accentColor ?? theme.accentColor;
ctx.fill();

// Outline (drawn so it doesn't eat into the filled area)
if (fill.outline > 0) {
ctx.lineWidth = fill.outline;
ctx.strokeStyle = theme.bgCell;
if (fill.shape === "circle") {
ctx.beginPath();
ctx.arc(
hx + half,
hy + half,
half + fill.outline / 2,
0,
Math.PI * 2
);
ctx.stroke();
} else {
ctx.strokeRect(
hx - fill.outline / 2,
hy - fill.outline / 2,
size + fill.outline,
size + fill.outline
);
}
}
};
}
return drawHandleCb !== undefined;
Expand Down
Loading
Loading