Simple shape editor component with React and SVG

React Shape Editor

Simple shape editor component.

Installation

npm i react-shape-editor

Components

ShapeEditor

The wrapper for the entire editor component. Contains the <svg> element.

Prop Type Default
Description
children renderable elements null Will include components such as wrapShape-wrapped shapes, other library components (SelectionLayer/ImageLayer/DrawLayer) or arbitrary SVG elements
focusOnAdd bool true If true, focus on newly created elements.
focusOnDelete bool true If true, focus on the next-closest element after a shape is deleted.
scale number 1 Scale factor of the svg contents. For example, given a vectorWidth of 100 and a scale of 0.5, the rendered DOM element will be 50 px wide.
style object {} Style to apply to the <svg> element.
vectorHeight number 0 Height of the <svg> element viewBox.
vectorWidth number 0 Width of the <svg> element viewBox.

wrapShape (HOC)

When used to wrap an SVG element, enables resize and movement functionality.

Usage

const WrappedRect = wrapShape(({ height, width /* ... "wrapShape Props Received" */ }) => (
  <rect fill="blue" height={height} width={width} />
))

// later, in render()

<WrappedRect
  shapeId={myId}
  x={12}
  y={56}
  width={20}
  /* ... "WrappedShape Props" */
/>

wrapShape Props Received

Prop Type
Description
height number Height of the shape.
width number Width of the shape.
scale number Scale of the parent <svg> element, provided so you can render strokes or other components that maintain a constant size at every zoom level.
shapeId string Unique identifier for the shape.
x number x-axis offset of the shape. NOTE: You should not use this to set the position of your shape, because the <g> that wraps the shape already includes this offset.
y number y-axis offset of the shape. NOTE: You should not use this to set the position of your shape, because the <g> that wraps the shape already includes this offset.
disabled bool If true, the shape cannot be moved or resized, and shows no resize handles.
isBeingChanged bool If true, the shape is currently being moved or scaled.
active bool If true, the shape has HTML-native focus or is selected via a SelectionLayer.
nativeActive bool If true, the shape has HTML-native focus (keyboard events will get applied to it).
isInSelectionGroup bool If you assigned it via a prop on the WrappedShape, this will be available to tell whether or not the shape is in a group of two or more selected shapes (when using the SelectionLayer component). Useful for hiding the resize handles when selected in a group, or adding an outline.
other props any Any extra props passed to the WrappedShape are passed down as-is to this component.

WrappedShape Props

Prop Type
Default
Description
height
(required)
number Height of the shape.
shapeId
(required)
string Unique identifier for the shape, to aid in data handling.
width
(required)
number Width of the shape.
x
(required)
number x-axis offset of the shape.
y
(required)
number y-axis offset of the shape.
onChange func ()=>{} Listener for transformation of this shape triggered by interactions with resize handles, panning, or keyboard shortcuts. Required for user-triggered shape transformations to work. Signature: (newRect: { x: number, y: number, height: number, width: number }, WrappedShapeProps: object) => void
onDelete func ()=>{} Listener for the deletion of this shape via backspace or delete keys. Required for user-triggered shape deletion to work. Signature: (event: Event, WrappedShapeProps: object) => void
active bool false If true, the shape is rendered as focused (particularly important when using a SelectionLayer). When not using a selection layer, this prop can be left unset, as native HTML focus will handle focus state.
constrainMove func non-constraining function A callback for restricting movement during shape transformations (e.g., to lock movement to one axis, keeping the shape inside a predefined boundary or snapping it to a grid). Signature: ({ originalX: number, originalY: number, x: number, y: number, width: number, height: number }) => ({ x: number, y: number })
constrainResize func non-constraining function A callback for restricting resizing during shape transformations (e.g., to lock resizing to one axis, keeping the shape inside a predefined boundary or snapping it to a grid). Signature: ({ originalMovingCorner: { x: number, y: number }, startCorner: { x: number, y: number }, movingCorner: { x: number, y: number }, lockedDimension: one of "x" or "y" }) => ({ x: number, y: number })
disabled bool false If true, the shape cannot be moved or resized, and shows no resize handles.
isInSelectionGroup bool false Whether or not the shape is in a group of two or more selected shapes (when using the SelectionLayer component). Prop is merely forwarded to the wrapped component to be used in customized rendering, e.g., hiding the resize handles when selected in a group, or adding an outline.
keyboardTransformMultiplier number 1 Multiplier for keyboard-triggered transforms, such as ↑↓←→ keys to move or shift+↑↓←→ keys to resize. For example, with the default setting of 1, pressing would move the shape 1 px to the right. With a setting of 5, it would move 5px.
onKeyDown func ()=>{} Listener for shape keydown event. If event.preventDefault() is called inside, it will override the default keyboard shortcut behavior. Signature: (event: Event, WrappedShapeProps: object) => void
onBlur func ()=>{} Listener for shape blur event. Signature: (event: Event, WrappedShapeProps: object) => void
onFocus func ()=>{} Listener for shape focus event. Signature: (event: Event, WrappedShapeProps: object) => void
ResizeHandleComponent Component DefaultResizeHandleComponent The component to use for shape handles.
wrapperProps object {} Extra props to add to the SVG <g> element wrapping the shape.
other props any Any extra props passed to the WrappedShape are passed down as-is to the component being wrapped.

ImageLayer

Renders an svg image element.

Prop Type Default
Description
src
(required)
string URL for the image to display.
onLoad func ()=>{} Callback for the image load. Signature: ({ naturalWidth: number, naturalHeight: number }) => void

DrawLayer

Creates an invisible layer of the SVG that allows users to draw shapes via mouse click-and-drag.

Prop Type
Default
Description
onAddShape
(required)
func Callback for when a shape has finished being drawn. Use it to add the new shape to your data. Signature: (newRect: { x: number, y: number, height: number, width: number }) => void
constrainMove func non-constraining function A callback for restricting the initial starting location for the drawing (e.g., to lock movement to one axis, keeping the shape inside a predefined boundary or snapping it to a grid). Signature: ({ originalX: number, originalY: number, x: number, y: number, width: number, height: number }) => ({ x: number, y: number })
constrainResize func non-constraining function A callback for restricting the dragged corner when drawing a shape (e.g., to lock resizing to one axis, keeping the shape inside a predefined boundary or snapping it to a grid). Signature: ({ originalMovingCorner: { x: number, y: number }, startCorner: { x: number, y: number }, movingCorner: { x: number, y: number }, lockedDimension: one of "x" or "y" }) => ({ x: number, y: number })
DrawPreviewComponent wrapShape(Component) DefaultDrawPreviewComponent The component to preview the shape while dragging. Must be wrapped with wrapShape.

SelectionLayer

Creates an invisible layer of the SVG that allows users to select shapes via mouse click-and-drag.

Prop Type
Default
Description
selectedShapeIds
(required)
string[] shapeIds that belong to the currently selected group. Should be populated with shapeIds passed back from onSelectionChange.
onSelectionChange func ()=>{} Listener for the addition or removal of shapes from the selection group (e.g., by shift+clicking extra shape or drawing new selection). Required for user-triggered selection to work. Signature: (selectedShapeIds: string[]) => void
onChange func ()=>{} Listener for transformation of shapes in the selection triggered by interactions with resize handles, panning, or keyboard shortcuts. Required for user-triggered shape transformations on selections to work. Signature: (newRects: { x: number, y: number, height: number, width: number }[], selectedShapesProps: object[]) => void
onDelete func ()=>{} Listener for the deletion of all shapes in this selection via backspace or delete keys. Required for user-triggered shape deletion to work. Signature: (event: Event, selectedShapesProps: object[]) => void
SelectionDrawComponent wrapShape(Component) DefaultSelectionDrawComponent The component to preview the selection while dragging. Must be wrapped with wrapShape.
SelectionComponent wrapShape(Component) DefaultSelectionComponent The component that visually wraps around the selected shapes (i.e., the selection outline). Must be wrapped with wrapShape.
minimumDistanceForSelection number 15 Minimum height or width that the drawn selection must be in order to perform the selection.
keyboardTransformMultiplier number 1 Multiplier for keyboard-triggered transforms, such as ↑↓←→ keys to move or shift+↑↓←→ keys to resize. For example, with the default setting of 1, pressing would move shapes in the selection 1 px to the right. With a setting of 5, they would move 5px.
selectionComponentProps object {} Extra props to pass to the SelectionComponent.
children renderable elements null wrapShape-wrapped shapes that are targets for selection by this selection group. Can also include other library components (SelectionLayer/ImageLayer/DrawLayer) or arbitrary SVG elements.

Usage

import React from 'react';
import {
  ShapeEditor,
  ImageLayer,
  DrawLayer,
  wrapShape,
} from 'react-shape-editor';

function arrayReplace(arr, index, item) {
  return [
    ...arr.slice(0, index),
    ...(Array.isArray(item) ? item : [item]),
    ...arr.slice(index + 1),
  ];
}

const RectShape = wrapShape(({ width, height }) => (
  <rect width={width} height={height} fill="rgba(0,0,255,0.5)" />
));

let idIterator = 1;
export default class Editor extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      items: [
        { id: '1', x: 20, y: 50, width: 50, height: 25 },
        { id: '2', x: 120, y: 0, width: 20, height: 75 },
      ],
      vectorWidth: 0,
      vectorHeight: 0,
    };
  }

  render() {
    const { items, vectorWidth, vectorHeight } = this.state;

    return (
      <div style={{ height: 400 }}>
        <ShapeEditor vectorWidth={vectorWidth} vectorHeight={vectorHeight}>
          <ImageLayer
            src="https://raw.githubusercontent.com/fritz-c/react-shape-editor/d8661b46d07d832e316aacc906a0d603a3bb13a2/website/blank.png"
            onLoad={({ naturalWidth, naturalHeight }) => {
              this.setState({
                vectorWidth: naturalWidth,
                vectorHeight: naturalHeight,
              });
            }}
          />
          <DrawLayer
            onAddShape={({ x, y, width, height }) => {
              this.setState(state => ({
                items: [
                  ...state.items,
                  { id: `id${idIterator}`, x, y, width, height },
                ],
              }));
              idIterator += 1;
            }}
          />
          {items.map((item, index) => {
            const { id, height, width, x, y } = item;
            return (
              <RectShape
                key={id}
                shapeId={id}
                height={height}
                width={width}
                x={x}
                y={y}
                onChange={newRect => {
                  this.setState(state => ({
                    items: arrayReplace(state.items, index, {
                      ...item,
                      ...newRect,
                    }),
                  }));
                }}
                onDelete={() => {
                  this.setState(state => ({
                    items: arrayReplace(state.items, index, []),
                  }));
                }}
              />
            );
          })}
        </ShapeEditor>
      </div>
    );
  }
}

Contributing

After cloning the repository and running npm install inside, you can use the following commands to develop and build the project.

# Starts a dev server that hosts a demo page with the component.
npm start

# Runs the library tests
npm test

# Lints the code with eslint
npm run lint

# Lints and builds the code, placing the result in the dist directory.
# This build is necessary to reflect changes if you're
#  `npm link`-ed to this repository from another local project.
npm run build

Pull requests are welcome!

GitHub