diff --git a/packages/core/API.md b/packages/core/API.md index 00e18d738..60cb16c5f 100644 --- a/packages/core/API.md +++ b/packages/core/API.md @@ -15,7 +15,7 @@ const portalRef = useRef(null); <> { createPortal( -
, +
, document.body ) } @@ -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; ``` -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 + { + const { fillDestination } = e; + if (/* your condition */ false) { + e.preventDefault(); + } + }} + {...props} +/> +``` --- diff --git a/packages/core/src/docs/examples/fill-handle.stories.tsx b/packages/core/src/docs/examples/fill-handle.stories.tsx index 658512040..65b65e875 100644 --- a/packages/core/src/docs/examples/fill-handle.stories.tsx +++ b/packages/core/src/docs/examples/fill-handle.stories.tsx @@ -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 { @@ -34,9 +35,39 @@ export default { ), ], + 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 }) => { const { cols, getCellContent, setCellValueRaw, setCellValue } = useMockDataGenerator(60, false); const [numRows, setNumRows] = React.useState(50); @@ -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={{ diff --git a/packages/core/src/internal/data-grid/data-grid-types.ts b/packages/core/src/internal/data-grid/data-grid-types.ts index cdaa1bf9a..af64d3aef 100644 --- a/packages/core/src/internal/data-grid/data-grid-types.ts +++ b/packages/core/src/internal/data-grid/data-grid-types.ts @@ -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; + +/** + * Default configuration used when `fillHandle` is simply `true`. + */ +export const DEFAULT_FILL_HANDLE: Readonly = { + shape: "square", + size: 4, + offsetX: -2, + offsetY: -2, + outline: 0, +} as const; + function mergeRanges(input: CompactSelectionRanges) { if (input.length === 0) { return []; @@ -561,7 +591,7 @@ export class CompactSelection { static create = (items: CompactSelectionRanges) => { return new CompactSelection(mergeRanges(items)); - } + }; static empty = (): CompactSelection => { return emptyCompactSelection ?? (emptyCompactSelection = new CompactSelection([])); diff --git a/packages/core/src/internal/data-grid/data-grid.tsx b/packages/core/src/internal/data-grid/data-grid.tsx index 18099b731..883c541eb 100644 --- a/packages/core/src/internal/data-grid/data-grid.tsx +++ b/packages/core/src/internal/data-grid/data-grid.tsx @@ -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"; @@ -145,7 +147,7 @@ export interface DataGridProps { * @defaultValue false * @group Editing */ - readonly fillHandle: boolean | undefined; + readonly fillHandle: FillHandle | undefined; readonly disabledRows: CompactSelection | undefined; /** @@ -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) => { @@ -446,7 +450,10 @@ const DataGrid: React.ForwardRefRenderFunction = (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( @@ -653,18 +660,29 @@ const DataGrid: React.ForwardRefRenderFunction = (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; } } @@ -1710,9 +1728,9 @@ const DataGrid: React.ForwardRefRenderFunction = (p, } return getMouseArgsForPosition(canvasRef.current, posX, posY, ev); - } + }, }), - [canvasRef, damage, getBoundsForItem] + [canvasRef, damage, getBoundsForItem, getMouseArgsForPosition] ); const lastFocusedSubdomNode = React.useRef(); diff --git a/packages/core/src/internal/data-grid/render/data-grid-render.ts b/packages/core/src/internal/data-grid/render/data-grid-render.ts index 4946b1557..310c3511d 100644 --- a/packages/core/src/internal/data-grid/render/data-grid-render.ts +++ b/packages/core/src/internal/data-grid/render/data-grid-render.ts @@ -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)) diff --git a/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts b/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts index 864dd4f4d..e8f07bc1d 100644 --- a/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts +++ b/packages/core/src/internal/data-grid/render/data-grid.render.rings.ts @@ -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"; @@ -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]; @@ -261,7 +266,7 @@ export function drawFillHandle( } } - const doHandle = row === fillHandleRow && isFillHandleCol && fillHandle; + const doHandle = row === fillHandleRow && isFillHandleCol && drawFill; if (doHandle) { drawHandleCb = () => { @@ -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; diff --git a/packages/core/src/internal/data-grid/render/draw-grid-arg.ts b/packages/core/src/internal/data-grid/render/draw-grid-arg.ts index 9221f91f7..7e3539ee1 100644 --- a/packages/core/src/internal/data-grid/render/draw-grid-arg.ts +++ b/packages/core/src/internal/data-grid/render/draw-grid-arg.ts @@ -13,6 +13,7 @@ import type { DrawHeaderCallback, CellList, DrawCellCallback, + FillHandle, } from "../data-grid-types.js"; import type { CellSet } from "../cell-set.js"; import type { EnqueueCallback } from "../use-animation-queue.js"; @@ -52,7 +53,7 @@ export interface DrawGridArg { readonly isFocused: boolean; readonly drawFocus: boolean; readonly selection: GridSelection; - readonly fillHandle: boolean; + readonly fillHandle: FillHandle; readonly freezeTrailingRows: number; readonly hasAppendRow: boolean; readonly hyperWrapping: boolean; diff --git a/packages/core/test/math.test.ts b/packages/core/test/math.test.ts index 1c7e275ce..2130b6b5e 100644 --- a/packages/core/test/math.test.ts +++ b/packages/core/test/math.test.ts @@ -1,6 +1,6 @@ -import { expect, describe, it } from "vitest"; -import type { Rectangle } from "../src/internal/data-grid/data-grid-types.js"; +import { describe, expect, it } from "vitest"; import { getClosestRect, hugRectToTarget, splitRectIntoRegions } from "../src/common/math.js"; +import type { FillHandleDirection, Rectangle } from "../src/internal/data-grid/data-grid-types.js"; describe("getClosestRect", () => { const testRect: Rectangle = { x: 10, y: 10, width: 5, height: 5 }; @@ -48,6 +48,38 @@ describe("getClosestRect", () => { ) ).to.deep.equal({ x: 11, y: 10, width: 10, height: 1 }); }); + + it("respects FillHandleDirection: horizontal", () => { + const dir: FillHandleDirection = "horizontal"; + // Horizontal should ignore vertical displacement and extend left/right only + expect(getClosestRect(testRect, 5, 50, dir)).to.deep.equal({ x: 5, y: 10, width: 5, height: 5 }); + expect(getClosestRect(testRect, 20, 50, dir)).to.deep.equal({ x: 15, y: 10, width: 6, height: 5 }); + // Inside (horizontally) returns undefined after y is forced to rect.y + expect(getClosestRect(testRect, 12, 5, dir)).to.be.undefined; + }); + + it("respects FillHandleDirection: vertical", () => { + const dir: FillHandleDirection = "vertical"; + // Vertical should ignore horizontal displacement and extend up/down only + expect(getClosestRect(testRect, 50, 5, dir)).to.deep.equal({ x: 10, y: 5, width: 5, height: 5 }); + expect(getClosestRect(testRect, 50, 20, dir)).to.deep.equal({ x: 10, y: 15, width: 5, height: 6 }); + // Inside (vertically) returns undefined after x is forced to rect.x + expect(getClosestRect(testRect, 12, 12, dir)).to.be.undefined; + }); + + it("respects FillHandleDirection: orthogonal (default)", () => { + const dir: FillHandleDirection = "orthogonal"; + // Should choose nearest edge in either axis + expect(getClosestRect(testRect, 12, 5, dir)).to.deep.equal({ x: 10, y: 5, width: 5, height: 5 }); + expect(getClosestRect(testRect, 20, 12, dir)).to.deep.equal({ x: 15, y: 10, width: 6, height: 5 }); + }); + + it("respects FillHandleDirection: any", () => { + const dir: FillHandleDirection = "any"; + // Should form a rectangle combining original and the pointer position (free expansion) + expect(getClosestRect(testRect, 20, 20, dir)).to.deep.equal({ x: 10, y: 10, width: 11, height: 11 }); + expect(getClosestRect(testRect, 5, 5, dir)).to.deep.equal({ x: 5, y: 5, width: 10, height: 10 }); + }); }); describe("hugRectToTarget", () => {