diff --git a/README.md b/README.md index 7accde5a..1059c211 100755 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ * [Install](#install) * [Usage](#usage) * [Props](#props) +* [Grid system](#grid-system) * [Instance API](#instance-api) * [updateSize(size: { width: number | string, height: number | string }): void](#updateSize-void) * [updatePosition({ x: number, y: number }): void](#updatePosition-void) @@ -106,7 +107,7 @@ yarn add react-rnd ## Props -#### `default: { x: number; y: number; width?: number | string; height?: number | string; };` +#### `default: { x: number; y: number; width?: number | string; height?: number | string; } | RndDefaultGrid;` The `width` and `height` property is used to set the default size of the component. For example, you can set `300`, `'300px'`, `50%`. @@ -114,17 +115,31 @@ If omitted, set `'auto'`. The `x` and `y` property is used to set the default position of the component. -#### `size?: { width: (number | string), height: (number | string) };` +When using grid units (`positionUnit="grid"` / `sizeUnit="grid"`), use `RndDefaultGrid`: `{ columnStart, rowStart, columnSpan, rowSpan }`. Requires `gridConfig`. + +#### `size?: { width: (number | string), height: (number | string) } | GridSize;` The `size` property is used to set size of the component. For example, you can set 300, '300px', 50%. +When `sizeUnit` is `'grid'`, use `GridSize`: `{ columnSpan: number, rowSpan: number }`. -Use `size` if you need to control size state by yourself. +Use `size` if you need to control size state yourself. -#### `position?: { x: number, y: number };` +#### `position?: { x: number, y: number } | GridPosition;` The `position` property is used to set position of the component. -Use `position` if you need to control size state by yourself. +Use `position` if you need to control position state yourself. +When `positionUnit` is `'%'`, `x` and `y` are in 0–100 (percentage of parent size). +When `positionUnit` is `'grid'`, use `GridPosition`: `{ columnStart: number, rowStart: number }`. + +#### `positionUnit?: 'px' | '%' | 'grid';` + +When `'%'`, positioning uses percentages of the parent size instead of pixels: +- `position` and `default` `x`/`y` are in 0–100. +- `onDrag`, `onDragStop`, `onResize`, and `onResizeStop` receive position as 0–100. +- `updatePosition()` expects `{ x, y }` in 0–100. + +When `'grid'`, position and size use **grid units** (see [Grid system](#grid-system)). You must set `gridConfig`. Position is `{ columnStart, rowStart }`; callbacks receive `gridPlacement` and optionally grid position. Default is `'px'`. see, following example. @@ -173,13 +188,25 @@ For example, you can set `300`, `'300px'`, `50%`. The `maxHeight` property is used to set the maximum height of the component. For example, you can set `300`, `'300px'`, `50%`. +#### `gridConfig?: { columns: number; rowHeight: number };` + +Required when using `positionUnit="grid"` or `sizeUnit="grid"`. Defines the grid: `columns` is the number of columns; `rowHeight` is the height of each row in pixels. Column width is derived from the parent width (`parentWidth / columns`). Drag and resize automatically snap to grid lines when in grid mode. + +#### `sizeUnit?: 'px' | '%' | 'grid';` + +When `'grid'`, `size` is in grid spans: `{ columnSpan, rowSpan }`. Requires `gridConfig`. Default is `'px'`. See [Grid system](#grid-system). + +#### `layoutMode?: 'absolute' | 'grid';` + +When `'grid'`, the Rnd wrapper uses CSS `grid-column` and `grid-row` instead of `position`/`left`/`top`, so the component participates in a CSS Grid parent. The parent must use `display: grid` with matching column/row setup. Default is `'absolute'`. + #### `resizeGrid?: [number, number];` -The `resizeGrid` property is used to specify the increments that resizing should snap to. Defaults to `[1, 1]`. +The `resizeGrid` property is used to specify the increments that resizing should snap to. Defaults to `[1, 1]`. When `sizeUnit="grid"` (or `positionUnit="grid"`), this is set automatically from `gridConfig`. #### `dragGrid?: [number, number];` -The `dragGrid` property is used to specify the increments that moving should snap to. Defaults to `[1, 1]`. +The `dragGrid` property is used to specify the increments that moving should snap to. Defaults to `[1, 1]`. When `positionUnit="grid"`, this is set automatically from `gridConfig`. #### `lockAspectRatio?: boolean | number;` @@ -320,16 +347,95 @@ Specifies movement boundaries. Accepted values: #### `enableUserSelectHack?: boolean;` -By default, we add 'user-select:none' attributes to the document body -to prevent ugly text selection during drag. If this is causing problems -for your app, set this to `false`. +By default, we add 'user-select:none' attributes to the document body to prevent ugly text selection during drag. If this is causing problems for your app, set this to `false`. #### `scale?: number;` -Specifies the scale of the canvas your are resizing and dragging this element on. This allows -you to, for example, get the correct resize and drag deltas while you are zoomed in or out via -a transform or matrix in the parent of this element. -If omitted, set `1`. +Specifies the scale of the canvas you are resizing and dragging this element on. This allows you to, for example, get the correct resize and drag deltas while you are zoomed in or out via a transform or matrix in the parent of this element. If omitted, set `1`. + +## Grid system + +Rnd can use **grid units** for position and size so you can persist and control layout by column/row indices (e.g. for page builders or Fluid-style editors). No manual conversion between pixels and grid is needed. + +### Setup + +1. Set **`gridConfig`**: `{ columns: number, rowHeight: number }`. For example, 24 columns and 8px row height. +2. Set **`positionUnit="grid"`** and/or **`sizeUnit="grid"`**. +3. Use **`position`** as `{ columnStart, rowStart }` and **`size`** as `{ columnSpan, rowSpan }` (0-based indices). + +Column width is computed from the parent width (`parentWidth / columns`). Row height is fixed (e.g. `grid-auto-rows: 8px` on the container). Drag and resize snap to grid lines automatically. + +### Types + +```javascript +// Grid configuration (required when using grid units) +type GridConfig = { columns: number; rowHeight: number }; + +// Position in grid: start cell +type GridPosition = { columnStart: number; rowStart: number }; + +// Size in grid: number of cells +type GridSize = { columnSpan: number; rowSpan: number }; + +// Full placement (0-based line indices; columnEnd/rowEnd are exclusive) +type GridPlacement = { + columnStart: number; + rowStart: number; + columnEnd: number; + rowEnd: number; +}; +``` + +### Example: controlled grid + +```javascript + { + if (d.gridPlacement) { + setPosition({ columnStart: d.columnStart, rowStart: d.rowStart }); + } + }} + onResizeStop={(e, dir, ref, delta, position, gridPlacement) => { + if (gridPlacement) { + setPosition({ columnStart: gridPlacement.columnStart, rowStart: gridPlacement.rowStart }); + setSize({ + columnSpan: gridPlacement.columnEnd - gridPlacement.columnStart, + rowSpan: gridPlacement.rowEnd - gridPlacement.rowStart, + }); + } + }} +/> +``` + +### Callbacks in grid mode + +- **`onDragStop`**: The second argument includes **`gridPlacement`** when `positionUnit="grid"`: `{ columnStart, rowStart, columnEnd, rowEnd }`. Position is also provided as `{ columnStart, rowStart }`. +- **`onResizeStop`**: The sixth argument is **`gridPlacement`** when using grid units, so you can persist the new grid placement directly. + +### Acting as a grid child + +Set **`layoutMode="grid"`** so the Rnd wrapper uses `grid-column` and `grid-row` instead of `position`/`left`/`top`. The parent must be `display: grid` with the same grid (e.g. same column count and row height). Useful when the DOM should be pure CSS Grid for layout. + +### Instance API in grid mode + +- **`updatePosition(position)`** accepts **`GridPosition`** when `positionUnit="grid"`: `{ columnStart, rowStart }`. +- **`updateSize(size)`** accepts **`GridSize`** when `sizeUnit="grid"`: `{ columnSpan, rowSpan }`. + +### Exported helpers + +You can use these for custom logic or persistence: + +- `getGridCellDimensions(parentSize, gridConfig)` → `{ columnWidth, rowHeight }` +- `gridPositionToPx(gridPosition, cellDimensions)` +- `gridSizeToPx(gridSize, cellDimensions)` +- `pxToGridPosition(pxPosition, cellDimensions)` +- `pxToGridSize(pxSize, cellDimensions)` +- `pxToGridPlacement(position, size, cellDimensions)` → `GridPlacement` ## Callback @@ -365,7 +471,7 @@ Calls when resizable component resizing. #### `onResizeStop?: RndResizeCallback;` -`RndResizeCallback` type is below. +`RndResizeCallback` type is below. When using [grid units](#grid-system), a sixth argument `gridPlacement?: GridPlacement` is passed. ``` javascript export type RndResizeCallback = ( @@ -373,7 +479,8 @@ export type RndResizeCallback = ( dir: ResizeDirection, refToElement: React.ElementRef<'div'>, delta: ResizableDelta, - position: Position, + position: Position | GridPosition, + gridPlacement?: GridPlacement, ) => void; ``` @@ -417,8 +524,7 @@ type DraggableEventHandler = ( #### `onDragStop: DraggableEventHandler;` -`onDragStop` called on dragging stop. - +`onDragStop` called on dragging stop. When `positionUnit="grid"`, the data object includes **`gridPlacement`**: `{ columnStart, rowStart, columnEnd, rowEnd }` (see [Grid system](#grid-system)). ``` javascript type DraggableData = { @@ -437,10 +543,9 @@ type DraggableEventHandler = ( ## Instance API -#### `updateSize(size: { width: string | number, height: string | number })` +#### `updateSize(size: { width: string | number, height: string | number } | GridSize)` -Update component size. -For example, you can set `300`, `'300px'`, `50%`. +Update component size. For example, you can set `300`, `'300px'`, `50%`. When `sizeUnit="grid"`, pass **`GridSize`**: `{ columnSpan, rowSpan }`. - for example @@ -464,10 +569,9 @@ class YourComponent extends Component { } ``` -#### `updatePosition({ x: number, y: number }): void` +#### `updatePosition({ x: number, y: number } | GridPosition): void` -Update component position. -`grid` `bounds` props is ignored, when this method called. +Update component position. When `positionUnit="grid"`, pass **`GridPosition`**: `{ columnStart, rowStart }`. When using px or %, pass `{ x, y }`. `grid` and `bounds` props are ignored when this method is called. - for example @@ -510,6 +614,10 @@ If you have a bug to report, please reproduce the bug in [CodeSandbox](https://c ## Changelog +#### v10.5.3 + +- Add grid system: `positionUnit="grid"`, `sizeUnit="grid"`, `gridConfig`, and `layoutMode="grid"`. Callbacks report `gridPlacement`; `updatePosition`/`updateSize` accept grid types. + #### v10.5.1 - Upgrade `re-resizable` to `6.11.0` diff --git a/package.json b/package.json index 6e742dc0..9f1d6e2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-rnd", - "version": "10.5.2", + "version": "10.5.3", "description": "A draggable and resizable React Component", "title": "react-rnd", "main": "./lib/index.es5.js", diff --git a/src/index.js.flow b/src/index.js.flow index 8b930c5e..e27bc85a 100755 --- a/src/index.js.flow +++ b/src/index.js.flow @@ -104,6 +104,8 @@ export type HandleComponent = { topLeft?: React.ReactElement; } +export type PositionUnit = 'px' | '%'; + export type Props = { dragGrid?: Grid, default?: { @@ -114,6 +116,7 @@ export type Props = { x: number, y: number, }, + positionUnit?: PositionUnit, size?: Size, resizeGrid?: Grid, bounds?: string, diff --git a/src/index.tsx b/src/index.tsx index 9920e199..04060fe2 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -42,7 +42,8 @@ export type RndResizeCallback = ( dir: ResizeDirection, elementRef: HTMLElement, delta: ResizableDelta, - position: Position, + position: Position | GridPosition, + gridPlacement?: GridPlacement, ) => void; type Size = { @@ -50,6 +51,36 @@ type Size = { height: string | number; }; +export type PositionUnit = "px" | "%" | "grid"; + +export type SizeUnit = "px" | "%" | "grid"; + +/** Grid layout: columns count and row height in px. Column width = containerWidth / columns. */ +export type GridConfig = { + columns: number; + rowHeight: number; +}; + +/** Grid placement in 0-based line indices (columnEnd/rowEnd exclusive). */ +export type GridPlacement = { + columnStart: number; + rowStart: number; + columnEnd: number; + rowEnd: number; +}; + +/** Grid position (start cell). */ +export type GridPosition = { + columnStart: number; + rowStart: number; +}; + +/** Grid size in spans. */ +export type GridSize = { + columnSpan: number; + rowSpan: number; +}; + type State = { resizing: boolean; bounds: { @@ -60,6 +91,7 @@ type State = { }; maxWidth?: number | string; maxHeight?: number | string; + parentSize: { width: number; height: number } | null; }; type MaxSize = { @@ -113,18 +145,26 @@ export type HandleComponent = { topLeft?: React.ReactElement; }; +/** Default / position / size when positionUnit or sizeUnit is "grid" use grid fields. */ +export type RndDefaultGrid = GridPosition & GridSize; + export interface Props { dragGrid?: Grid; - default?: { - x: number; - y: number; - } & Size; - position?: { - x: number; - y: number; - }; - size?: Size; + /** Required when positionUnit or sizeUnit is "grid". */ + gridConfig?: GridConfig; + default?: ( + | ({ x: number; y: number } & Size) + | RndDefaultGrid + ); + position?: Position | GridPosition; + size?: Size | GridSize; resizeGrid?: Grid; + /** When 'grid', position is GridPosition and gridConfig is required; callbacks receive grid placement. */ + positionUnit?: PositionUnit; + /** When 'grid', size is GridSize and gridConfig is required; callbacks receive grid placement. Default 'px'. */ + sizeUnit?: SizeUnit; + /** When 'grid', the wrapper uses grid-column/grid-row instead of position/left/top (parent must be display:grid). */ + layoutMode?: "absolute" | "grid"; bounds?: string | Element; onMouseDown?: (e: MouseEvent) => void; onMouseUp?: (e: MouseEvent) => void; @@ -181,6 +221,94 @@ const getEnableResizingByFlag = (flag: boolean): Enable => ({ topRight: flag, }); +function positionPercentToPx( + percent: Position, + parentSize: { width: number; height: number }, +): Position { + return { + x: (percent.x / 100) * parentSize.width, + y: (percent.y / 100) * parentSize.height, + }; +} + +function positionPxToPercent( + px: Position, + parentSize: { width: number; height: number }, +): Position { + const { width, height } = parentSize; + return { + x: width <= 0 ? 0 : (px.x / width) * 100, + y: height <= 0 ? 0 : (px.y / height) * 100, + }; +} + +export type GridCellDimensions = { columnWidth: number; rowHeight: number }; + +export function getGridCellDimensions( + parentSize: { width: number; height: number }, + gridConfig: GridConfig, +): GridCellDimensions { + return { + columnWidth: parentSize.width / gridConfig.columns, + rowHeight: gridConfig.rowHeight, + }; +} + +export function gridPositionToPx( + gridPos: GridPosition, + cell: GridCellDimensions, +): Position { + return { + x: gridPos.columnStart * cell.columnWidth, + y: gridPos.rowStart * cell.rowHeight, + }; +} + +export function gridSizeToPx( + gridSize: GridSize, + cell: GridCellDimensions, +): { width: number; height: number } { + return { + width: gridSize.columnSpan * cell.columnWidth, + height: gridSize.rowSpan * cell.rowHeight, + }; +} + +export function pxToGridPosition( + px: Position, + cell: GridCellDimensions, +): GridPosition { + return { + columnStart: Math.round(px.x / cell.columnWidth), + rowStart: Math.round(px.y / cell.rowHeight), + }; +} + +export function pxToGridSize( + size: { width: number; height: number }, + cell: GridCellDimensions, +): GridSize { + return { + columnSpan: Math.max(1, Math.round(size.width / cell.columnWidth)), + rowSpan: Math.max(1, Math.round(size.height / cell.rowHeight)), + }; +} + +export function pxToGridPlacement( + position: Position, + size: { width: number; height: number }, + cell: GridCellDimensions, +): GridPlacement { + const start = pxToGridPosition(position, cell); + const span = pxToGridSize(size, cell); + return { + columnStart: start.columnStart, + rowStart: start.rowStart, + columnEnd: start.columnStart + span.columnSpan, + rowEnd: start.rowStart + span.rowSpan, + }; +} + interface DefaultProps { maxWidth: number; maxHeight: number; @@ -224,6 +352,7 @@ export class Rnd extends React.PureComponent { }, maxWidth: props.maxWidth, maxHeight: props.maxHeight, + parentSize: null, }; this.onResizeStart = this.onResizeStart.bind(this); @@ -236,17 +365,68 @@ export class Rnd extends React.PureComponent { } componentDidMount() { + this.updateParentSize(); this.updateOffsetFromParent(); const { left, top } = this.offsetFromParent; - const { x, y } = this.getDraggablePosition(); - this.draggable.setState({ - x: x - left, - y: y - top, - }); + const positionUnit = this.props.positionUnit ?? "px"; + const defaultValue = this.props.default; + let parentSize: { width: number; height: number } | null = null; + if (this.resizable) { + try { + parentSize = this.getParentSize(); + } catch { + // refs may not be ready + } + } + + if (positionUnit === "%" && defaultValue && parentSize && "x" in defaultValue) { + const px = positionPercentToPx( + { x: defaultValue.x, y: defaultValue.y }, + parentSize, + ); + this.draggable.setState({ + x: px.x - left, + y: px.y - top, + }); + } else if (positionUnit === "grid" && defaultValue && parentSize && "columnStart" in defaultValue && this.props.gridConfig) { + const cell = getGridCellDimensions(parentSize, this.props.gridConfig); + const px = gridPositionToPx( + { columnStart: defaultValue.columnStart, rowStart: defaultValue.rowStart }, + cell, + ); + this.draggable.setState({ + x: px.x - left, + y: px.y - top, + }); + } else { + const { x, y } = this.getDraggablePosition(); + this.draggable.setState({ + x: x - left, + y: y - top, + }); + } // HACK: Apply position adjustment this.forceUpdate(); } + componentDidUpdate() { + this.updateParentSize(); + } + + updateParentSize() { + const parent = this.getParent(); + if (!parent || !this.resizable) return; + try { + const { width, height } = this.getParentSize(); + const prev = this.state.parentSize; + if (!prev || prev.width !== width || prev.height !== height) { + this.setState({ parentSize: { width, height } }); + } + } catch { + // getParentSize may throw before refs are ready + } + } + // HACK: To get `react-draggable` state x and y. getDraggablePosition(): { x: number; y: number } { const { x, y } = (this.draggable as any).state; @@ -356,27 +536,86 @@ export class Rnd extends React.PureComponent { }); } + getGridCellDimensions(): GridCellDimensions | null { + const { gridConfig } = this.props; + if (!gridConfig) return null; + try { + const parentSize = this.getParentSize(); + return getGridCellDimensions(parentSize, gridConfig); + } catch { + return null; + } + } + + getPositionForCallback(px: Position): Position | GridPosition { + const positionUnit = this.props.positionUnit ?? "px"; + if (positionUnit === "%") { + try { + const parentSize = this.getParentSize(); + return positionPxToPercent(px, parentSize); + } catch { + return px; + } + } + if (positionUnit === "grid") { + const cell = this.getGridCellDimensions(); + if (cell) return pxToGridPosition(px, cell); + } + return px; + } + + getGridPlacementForCallback(position: Position, width: number, height: number): GridPlacement | null { + const cell = this.getGridCellDimensions(); + if (!cell) return null; + return pxToGridPlacement(position, { width, height }, cell); + } + onDrag(e: RndDragEvent, data: DraggableData) { if (!this.props.onDrag) return; const { left, top } = this.offsetFromParent; + let pos: Position; if (!this.props.dragAxis || this.props.dragAxis === "both") { - return this.props.onDrag(e, { ...data, x: data.x + left, y: data.y + top }); + pos = { x: data.x + left, y: data.y + top }; } else if (this.props.dragAxis === "x") { - return this.props.onDrag(e, { ...data, x: data.x + left, y: this.originalPosition.y + top, deltaY: 0 }); + pos = { x: data.x + left, y: this.originalPosition.y + top }; + } else { + pos = { x: this.originalPosition.x + left, y: data.y + top }; + } + const position = this.getPositionForCallback(pos); + if (!this.props.dragAxis || this.props.dragAxis === "both") { + return this.props.onDrag(e, { ...data, ...position }); + } else if (this.props.dragAxis === "x") { + return this.props.onDrag(e, { ...data, ...position, deltaY: 0 }); } else if (this.props.dragAxis === "y") { - return this.props.onDrag(e, { ...data, x: this.originalPosition.x + left, y: data.y + top, deltaX: 0 }); + return this.props.onDrag(e, { ...data, ...position, deltaX: 0 }); } } onDragStop(e: RndDragEvent, data: DraggableData) { if (!this.props.onDragStop) return; const { left, top } = this.offsetFromParent; + let pos: Position; if (!this.props.dragAxis || this.props.dragAxis === "both") { - return this.props.onDragStop(e, { ...data, x: data.x + left, y: data.y + top }); + pos = { x: data.x + left, y: data.y + top }; } else if (this.props.dragAxis === "x") { - return this.props.onDragStop(e, { ...data, x: data.x + left, y: this.originalPosition.y + top, deltaY: 0 }); + pos = { x: data.x + left, y: this.originalPosition.y + top }; + } else { + pos = { x: this.originalPosition.x + left, y: data.y + top }; + } + const position = this.getPositionForCallback(pos); + const payload = { ...data, ...position }; + if (this.props.positionUnit === "grid" && this.resizable) { + const w = this.resizable.size.width as number; + const h = this.resizable.size.height as number; + const gridPlacement = this.getGridPlacementForCallback(pos, w, h); + if (gridPlacement) (payload as any).gridPlacement = gridPlacement; + } + if (!this.props.dragAxis || this.props.dragAxis === "both") { + return this.props.onDragStop(e, payload); + } else if (this.props.dragAxis === "x") { + return this.props.onDragStop(e, { ...payload, deltaY: 0 }); } else if (this.props.dragAxis === "y") { - return this.props.onDragStop(e, { ...data, x: this.originalPosition.x + left, y: data.y + top, deltaX: 0 }); + return this.props.onDragStop(e, { ...payload, deltaX: 0 }); } } @@ -518,10 +757,8 @@ export class Rnd extends React.PureComponent { this.resizingPosition = { x, y }; if (!this.props.onResize) return; - this.props.onResize(e, direction, elementRef, delta, { - x, - y, - }); + const position = this.getPositionForCallback({ x, y }); + this.props.onResize(e, direction, elementRef, delta, position); } onResizeStop( @@ -536,17 +773,59 @@ export class Rnd extends React.PureComponent { const { maxWidth, maxHeight } = this.getMaxSizesFromProps(); this.setState({ maxWidth, maxHeight }); if (this.props.onResizeStop) { - this.props.onResizeStop(e, direction, elementRef, delta, this.resizingPosition); + const position = this.getPositionForCallback(this.resizingPosition); + const gridPlacement = + this.props.positionUnit === "grid" || this.props.sizeUnit === "grid" + ? this.getGridPlacementForCallback( + this.resizingPosition, + elementRef.offsetWidth, + elementRef.offsetHeight, + ) + : undefined; + this.props.onResizeStop(e, direction, elementRef, delta, position, gridPlacement ?? undefined); } } - updateSize(size: { width: number | string; height: number | string }) { + updateSize(size: { width: number | string; height: number | string } | GridSize) { if (!this.resizable) return; - this.resizable.updateSize({ width: size.width, height: size.height }); + if ("columnSpan" in size && "rowSpan" in size && this.props.sizeUnit === "grid" && this.props.gridConfig) { + try { + const parentSize = this.getParentSize(); + const cell = getGridCellDimensions(parentSize, this.props.gridConfig); + const px = gridSizeToPx(size, cell); + this.resizable.updateSize({ width: px.width, height: px.height }); + } catch { + // fallback no-op if refs not ready + } + } else { + this.resizable.updateSize({ width: (size as any).width, height: (size as any).height }); + } } - updatePosition(position: Position) { - this.draggable.setState(position); + updatePosition(position: Position | GridPosition) { + const positionUnit = this.props.positionUnit ?? "px"; + if (positionUnit === "%" && "x" in position && "y" in position) { + try { + const parentSize = this.getParentSize(); + const px = positionPercentToPx(position, parentSize); + const { left, top } = this.offsetFromParent; + this.draggable.setState({ x: px.x - left, y: px.y - top }); + } catch { + this.draggable.setState(position as Position); + } + } else if (positionUnit === "grid" && "columnStart" in position && this.props.gridConfig) { + try { + const parentSize = this.getParentSize(); + const cell = getGridCellDimensions(parentSize, this.props.gridConfig); + const px = gridPositionToPx(position, cell); + const { left, top } = this.offsetFromParent; + this.draggable.setState({ x: px.x - left, y: px.y - top }); + } catch { + // fallback no-op if refs not ready + } + } else if ("x" in position && "y" in position) { + this.draggable.setState(position as Position); + } } updateOffsetFromParent() { @@ -602,26 +881,113 @@ export class Rnd extends React.PureComponent { scale, allowAnyClick, dragPositionOffset, + positionUnit = "px", + sizeUnit = "px", + gridConfig, + layoutMode = "absolute", + size: sizeProp, ...resizableProps } = this.props; const defaultValue = this.props.default ? { ...this.props.default } : undefined; // Remove unknown props, see also https://reactjs.org/warnings/unknown-prop.html delete resizableProps.default; + const { left, top } = this.offsetFromParent; + const parentSize = this.state.parentSize; + const gridCell: GridCellDimensions | null = + gridConfig && parentSize ? getGridCellDimensions(parentSize, gridConfig) : null; + const cursorStyle = disableDragging || dragHandleClassName ? { cursor: "auto" } : { cursor: "move" }; - const innerStyle = { + const innerStyle: React.CSSProperties = { ...resizableStyle, ...cursorStyle, ...style, }; - const { left, top } = this.offsetFromParent; - let draggablePosition; + if (layoutMode === "grid" && gridCell != null) { + let placement: GridPlacement; + if (position && "columnStart" in position && sizeProp && "columnSpan" in sizeProp) { + placement = { + columnStart: (position as GridPosition).columnStart, + rowStart: (position as GridPosition).rowStart, + columnEnd: (position as GridPosition).columnStart + (sizeProp as GridSize).columnSpan, + rowEnd: (position as GridPosition).rowStart + (sizeProp as GridSize).rowSpan, + }; + } else if (this.resizable && typeof this.resizable.size?.width === "number" && typeof this.resizable.size?.height === "number") { + const pos = this.getDraggablePosition(); + placement = pxToGridPlacement( + { x: pos.x + left, y: pos.y + top }, + { width: this.resizable.size.width as number, height: this.resizable.size.height as number }, + gridCell, + ); + } else { + placement = { columnStart: 0, rowStart: 0, columnEnd: 1, rowEnd: 1 }; + } + Object.assign(innerStyle, { + position: "relative" as const, + gridColumn: `${placement.columnStart + 1} / ${placement.columnEnd + 1}`, + gridRow: `${placement.rowStart + 1} / ${placement.rowEnd + 1}`, + left: undefined, + top: undefined, + }); + } + + let draggablePosition: { x: number; y: number } | undefined; if (position) { + let positionPx: Position; + if (positionUnit === "%" && parentSize && "x" in position) { + positionPx = positionPercentToPx(position as Position, parentSize); + } else if (positionUnit === "grid" && gridCell && "columnStart" in position) { + positionPx = gridPositionToPx(position as GridPosition, gridCell); + } else if ("x" in position && "y" in position) { + positionPx = position as Position; + } else { + positionPx = { x: 0, y: 0 }; + } draggablePosition = { - x: position.x - left, - y: position.y - top, + x: positionPx.x - left, + y: positionPx.y - top, }; } + + // Effective drag/resize grid: when in grid mode, snap to grid cell size + const effectiveDragGrid: Grid | undefined = + gridCell && positionUnit === "grid" ? [gridCell.columnWidth, gridCell.rowHeight] : dragGrid; + const effectiveResizeGrid: Grid | undefined = + gridCell && (positionUnit === "grid" || sizeUnit === "grid") ? [gridCell.columnWidth, gridCell.rowHeight] : resizeGrid; + + // Size for Resizable: convert grid to px when sizeUnit is "grid" + let sizeForResizable: Size | undefined; + if (sizeProp !== undefined) { + if (sizeUnit === "grid" && gridCell && "columnSpan" in sizeProp) { + const px = gridSizeToPx(sizeProp as GridSize, gridCell); + sizeForResizable = { width: px.width, height: px.height }; + } else { + sizeForResizable = sizeProp as Size; + } + } + + // Default size for Resizable: when default is grid shape, convert to px + let defaultSizeForResizable: { x?: number; y?: number; width: number; height: number } | undefined = defaultValue as any; + if (defaultValue && "columnSpan" in defaultValue && gridCell) { + const px = gridSizeToPx( + { columnSpan: defaultValue.columnSpan, rowSpan: defaultValue.rowSpan }, + gridCell, + ); + defaultSizeForResizable = { + width: px.width, + height: px.height, + }; + } + + // In % or grid mode, default position is applied in componentDidMount; pass 0,0 so Draggable gets numeric values + const defaultPositionForDraggable: { x: number; y: number } | undefined = + defaultValue && positionUnit === "%" && "x" in defaultValue && typeof defaultValue.x === "number" && typeof defaultValue.y === "number" + ? { x: 0, y: 0 } + : defaultValue && positionUnit === "grid" && "columnStart" in defaultValue + ? { x: 0, y: 0 } + : defaultValue && "x" in defaultValue && "y" in defaultValue + ? { x: (defaultValue as any).x, y: (defaultValue as any).y } + : undefined; // INFO: Make uncontorolled component when resizing to control position by setPostion. const pos = this.state.resizing ? undefined : draggablePosition; const dragAxisOrUndefined = this.state.resizing ? "both" : dragAxis; @@ -633,16 +999,16 @@ export class Rnd extends React.PureComponent { this.draggable = c; }} handle={dragHandleClassName ? `.${dragHandleClassName}` : undefined} - defaultPosition={defaultValue} + defaultPosition={defaultPositionForDraggable} onMouseDown={onMouseDown} - // @ts-expect-error + // @ts-expect-error react-draggable accepts onMouseUp at runtime onMouseUp={onMouseUp} onStart={this.onDragStart} onDrag={this.onDrag} onStop={this.onDragStop} axis={dragAxisOrUndefined} disabled={disableDragging} - grid={dragGrid} + grid={effectiveDragGrid} bounds={bounds ? this.state.bounds : undefined} position={pos} enableUserSelectHack={enableUserSelectHack} @@ -659,8 +1025,8 @@ export class Rnd extends React.PureComponent { this.resizable = c; this.resizableElement.current = c.resizable; }} - defaultSize={defaultValue} - size={this.props.size} + defaultSize={defaultSizeForResizable} + size={sizeForResizable} enable={typeof enableResizing === "boolean" ? getEnableResizingByFlag(enableResizing) : enableResizing} onResizeStart={this.onResizeStart} onResize={this.onResize} @@ -670,7 +1036,7 @@ export class Rnd extends React.PureComponent { minHeight={this.props.minHeight} maxWidth={this.state.resizing ? this.state.maxWidth : this.props.maxWidth} maxHeight={this.state.resizing ? this.state.maxHeight : this.props.maxHeight} - grid={resizeGrid} + grid={effectiveResizeGrid} handleWrapperClass={resizeHandleWrapperClass} handleWrapperStyle={resizeHandleWrapperStyle} lockAspectRatio={this.props.lockAspectRatio} diff --git a/stories/grid/grid-units-controlled.tsx b/stories/grid/grid-units-controlled.tsx new file mode 100644 index 00000000..d070dc4f --- /dev/null +++ b/stories/grid/grid-units-controlled.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { Rnd, GridConfig, GridPlacement, GridPosition, GridSize } from "../../src"; +import { style } from "../styles"; + +const ROW_HEIGHT = 8; +const COLUMNS = 24; +const CONTAINER_WIDTH = 480; +const CONTAINER_HEIGHT = 320; + +const gridConfig: GridConfig = { + columns: COLUMNS, + rowHeight: ROW_HEIGHT, +}; + +const containerStyle: React.CSSProperties = { + width: CONTAINER_WIDTH, + height: CONTAINER_HEIGHT, + boxSizing: "border-box", + position: "relative", + background: "#fafafa", + outline: "1px solid #ccc", + overflow: "hidden", + backgroundImage: ` + linear-gradient(to right, rgba(0,0,0,0.08) 1px, transparent 1px), + linear-gradient(to bottom, rgba(0,0,0,0.08) 1px, transparent 1px) + `, + backgroundSize: `${CONTAINER_WIDTH / COLUMNS}px ${ROW_HEIGHT}px`, +}; + +type State = { + position: GridPosition; + size: GridSize; +}; + +export default class GridUnitsControlled extends React.Component<{}, State> { + constructor(props: {}) { + super(props); + this.state = { + position: { columnStart: 2, rowStart: 3 }, + size: { columnSpan: 6, rowSpan: 4 }, + }; + } + + render() { + const { position, size } = this.state; + return ( +
+ { + if ("columnStart" in d && "rowStart" in d) { + this.setState({ + position: { columnStart: d.columnStart, rowStart: d.rowStart }, + }); + } + }} + onResizeStop={( + _e, + _dir, + ref, + _delta, + _position, + gridPlacement?: GridPlacement, + ) => { + if (gridPlacement) { + this.setState({ + position: { + columnStart: gridPlacement.columnStart, + rowStart: gridPlacement.rowStart, + }, + size: { + columnSpan: gridPlacement.columnEnd - gridPlacement.columnStart, + rowSpan: gridPlacement.rowEnd - gridPlacement.rowStart, + }, + }); + } + }} + > +
+ positionUnit="grid" · sizeUnit="grid" +
+ columnStart: {position.columnStart} · rowStart: {position.rowStart} +
+ columnSpan: {size.columnSpan} · rowSpan: {size.rowSpan} +
+
+
+ ); + } +} diff --git a/stories/grid/percent-with-visual-grid.tsx b/stories/grid/percent-with-visual-grid.tsx new file mode 100644 index 00000000..6b40e458 --- /dev/null +++ b/stories/grid/percent-with-visual-grid.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { Rnd } from "../../src"; +import { style } from "../styles"; + +const GRID_SIZE = 20; +const CONTAINER_WIDTH = 400; +const CONTAINER_HEIGHT = 300; + +const containerStyle: React.CSSProperties = { + width: CONTAINER_WIDTH, + height: CONTAINER_HEIGHT, + boxSizing: "border-box", + position: "relative", + background: "#fafafa", + outline: "1px solid #ccc", + overflow: "hidden", + // Visual grid: lines every GRID_SIZE px to match dragGrid/resizeGrid + // outline (not border) so the content box stays exact and grid aligns with Rnd position + backgroundImage: ` + linear-gradient(to right, rgba(0,0,0,0.08) 1px, transparent 1px), + linear-gradient(to bottom, rgba(0,0,0,0.08) 1px, transparent 1px) + `, + backgroundSize: `${GRID_SIZE}px ${GRID_SIZE}px`, +}; + +type State = { + x: number; + y: number; + width: number; + height: number; +}; + +export default class Example extends React.Component<{}, State> { + constructor(props: {}) { + super(props); + this.state = { + width: 120, + height: 80, + x: 15, + y: 20, + }; + } + + render() { + return ( +
+ { + this.setState({ x: d.x, y: d.y }); + }} + onResizeStop={(e, direction, ref, delta, position) => { + this.setState({ + width: ref.offsetWidth, + height: ref.offsetHeight, + ...position, + }); + }} + > +
+ positionUnit="%" · grid=[{GRID_SIZE},{GRID_SIZE}]px +
+ x: {this.state.x.toFixed(1)}% · y: {this.state.y.toFixed(1)}% +
+
+
+ ); + } +} diff --git a/stories/index.tsx b/stories/index.tsx index e54836ce..47513b76 100644 --- a/stories/index.tsx +++ b/stories/index.tsx @@ -31,6 +31,9 @@ import BoundsElementUncontrolled from "./bounds/element-uncontrolled"; import SizePercentUncontrolled from "./size/size-percent-uncontrolled"; import SizePercentControlled from "./size/size-percent-controlled"; +import PositionPercentUncontrolled from "./position/position-percent-uncontrolled"; +import PositionPercentControlled from "./position/position-percent-controlled"; + import Callbacks from "./callback/callbacks"; import Cancel from "./cancel/cancel"; @@ -44,6 +47,8 @@ import DragAxisNone from "./dragAxis/dragAxisNone"; import GridResize from "./grid/resize"; import GridDrag from "./grid/drag"; import GridBoth from "./grid/both"; +import GridPercentWithVisualGrid from "./grid/percent-with-visual-grid"; +import GridUnitsControlled from "./grid/grid-units-controlled"; import SandboxBodySizeToMaxWidth from "./sandbox/bodysize-to-maxwidth"; import SandboxLockAspectRatioWithBounds from "./sandbox/lock-aspect-ratio-with-bounds"; @@ -82,6 +87,10 @@ storiesOf("size", module) .add("percent uncontrolled", () => ) .add("percent controlled", () => ); +storiesOf("position", module) + .add("percent uncontrolled", () => ) + .add("percent controlled", () => ); + storiesOf("callbacks", module).add("callback", () => ); storiesOf("cancel", module).add("cancel", () => ); @@ -96,7 +105,9 @@ storiesOf("dragAxis", module) storiesOf("grid", module) .add("resize", () => ) .add("drag", () => ) - .add("both", () => ); + .add("both", () => ) + .add("percent with visual grid", () => ) + .add("grid units controlled", () => ); storiesOf("sandbox", module) .add("body size apply to maxwidth", () => ) diff --git a/stories/position/position-percent-controlled.tsx b/stories/position/position-percent-controlled.tsx new file mode 100644 index 00000000..8a3ea359 --- /dev/null +++ b/stories/position/position-percent-controlled.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { Rnd } from "../../src"; +import { style } from "../styles"; + +type State = { + x: number; + y: number; + width: number; + height: number; +}; + +const containerStyle: React.CSSProperties = { + width: 400, + height: 300, + position: "relative", + background: "#e8e8e8", + border: "1px solid #ccc", +}; + +export default class Example extends React.Component<{}, State> { + constructor(props: {}) { + super(props); + this.state = { + width: 120, + height: 80, + x: 10, + y: 20, + }; + } + + render() { + return ( +
+ { + this.setState({ x: d.x, y: d.y }); + }} + onResizeStop={(e, direction, ref, delta, position) => { + this.setState({ + width: ref.offsetWidth, + height: ref.offsetHeight, + ...position, + }); + }} + > + Position in % (x: {this.state.x.toFixed(1)}, y: {this.state.y.toFixed(1)}) + +
+ ); + } +} diff --git a/stories/position/position-percent-uncontrolled.tsx b/stories/position/position-percent-uncontrolled.tsx new file mode 100644 index 00000000..db3f9a3d --- /dev/null +++ b/stories/position/position-percent-uncontrolled.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Rnd } from "../../src"; +import { style } from "../styles"; + +const containerStyle: React.CSSProperties = { + width: 400, + height: 300, + position: "relative", + background: "#e8e8e8", + border: "1px solid #ccc", +}; + +export default function Example() { + return ( +
+ + Default at 25%, 30% (uncontrolled) + +
+ ); +}