Skip to content

cloudillo/react-svg-canvas

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

react-svg-canvas

A React library for building interactive SVG canvas applications with pan, zoom, selection, drag-and-drop, resize, and Figma-style snapping.

Features

  • Pan & Zoom - Middle mouse/touch panning, mouse wheel/pinch-to-zoom
  • Touch Support - Full touch event handling for mobile devices
  • Selection System - Multi-select, rectangle selection, selection bounds
  • Drag & Drop - Smooth dragging with window-level event handling
  • Resize Handles - 8-point resize with min/max constraints
  • Rotation - Object and group rotation with snap angles and pivot point manipulation
  • Snapping - Figma-style snapping to edges, centers, grid, and matching sizes
  • Geometry Utilities - Bounds operations, transforms, coordinate conversion
  • Spatial Queries - Hit testing, rectangle selection, culling
  • TypeScript - Full type definitions included

Installation

npm install react-svg-canvas
# or
pnpm add react-svg-canvas
# or
yarn add react-svg-canvas

Peer Dependencies: React 18+

Quick Start

import { SvgCanvas, useSvgCanvas } from 'react-svg-canvas'

function MyCanvas() {
  return (
    <SvgCanvas
      className="my-canvas"
      style={{ width: '100%', height: '100%' }}
    >
      <rect x={100} y={100} width={200} height={150} fill="#3b82f6" />
      <circle cx={400} cy={200} r={50} fill="#ef4444" />
    </SvgCanvas>
  )
}

API Reference

SvgCanvas

The main canvas component that provides pan, zoom, and coordinate transformation.

import { SvgCanvas, SvgCanvasHandle } from 'react-svg-canvas'

function App() {
  const canvasRef = useRef<SvgCanvasHandle>(null)

  return (
    <SvgCanvas
      ref={canvasRef}
      className="canvas"
      style={{ width: '100vw', height: '100vh' }}
      fixed={<MyToolbar />}  // Renders in screen space (not transformed)
      onToolStart={(e) => console.log('Tool start:', e.x, e.y)}
      onToolMove={(e) => console.log('Tool move:', e.x, e.y)}
      onToolEnd={() => console.log('Tool end')}
      onContextReady={(ctx) => console.log('Scale:', ctx.scale)}
    >
      {/* Children render in canvas space (transformed) */}
      <MyShapes />
    </SvgCanvas>
  )
}

Props

Prop Type Description
className string CSS class for the SVG element
style CSSProperties Inline styles for the SVG element
children ReactNode Content rendered in canvas space (pan/zoom applied)
fixed ReactNode Content rendered in screen space (UI overlays)
onToolStart (e: ToolEvent) => void Called on left mouse/touch start
onToolMove (e: ToolEvent) => void Called during drag
onToolEnd () => void Called on mouse/touch end
onContextReady (ctx: SvgCanvasContext) => void Called when context changes (zoom, pan)

Imperative Handle

const canvasRef = useRef<SvgCanvasHandle>(null)

// Center viewport on a point
canvasRef.current?.centerOn(100, 100, 1.5)  // x, y, optional zoom

// Fit a rectangle in view
canvasRef.current?.centerOnRect(0, 0, 500, 400, 50)  // x, y, w, h, padding

// Get/set transform matrix
const matrix = canvasRef.current?.getMatrix()
canvasRef.current?.setMatrix([1, 0, 0, 1, 0, 0])

Interaction Controls

Input Action
Left mouse Tool events (onToolStart/Move/End)
Middle mouse Pan canvas
Mouse wheel Zoom in/out
Single touch Tool events or pan
Two-finger pinch Zoom

useSvgCanvas

Hook to access canvas context from child components.

function MyShape() {
  const { svg, matrix, scale, translateTo, translateFrom } = useSvgCanvas()

  // Convert screen coords to canvas coords
  const [canvasX, canvasY] = translateTo(screenX, screenY)

  // Convert canvas coords to screen coords
  const [screenX, screenY] = translateFrom(canvasX, canvasY)

  return <rect x={100} y={100} width={100 / scale} height={100 / scale} />
}

Selection System

useSelection

Manages selection state for canvas objects.

import { useSelection, SpatialObject } from 'react-svg-canvas'

interface MyObject extends SpatialObject {
  id: string
  bounds: Bounds
  color: string
}

function Canvas({ objects }: { objects: MyObject[] }) {
  const {
    selectedIds,
    selectedObjects,
    selectionCount,
    selectionBounds,
    hasSelection,
    select,
    selectMultiple,
    deselect,
    toggle,
    clear,
    selectAll,
    selectInRect,
    setSelection,
    isSelected
  } = useSelection({ objects, onChange: (ids) => console.log('Selection:', ids) })

  return (
    <SvgCanvas>
      {objects.map(obj => (
        <rect
          key={obj.id}
          {...obj.bounds}
          fill={isSelected(obj.id) ? 'blue' : obj.color}
          onClick={(e) => select(obj.id, e.shiftKey)}
        />
      ))}
      {selectionBounds && (
        <SelectionBox bounds={selectionBounds} onResizeStart={handleResize} />
      )}
    </SvgCanvas>
  )
}

SelectionBox

Renders a selection rectangle with resize handles.

import { SelectionBox } from 'react-svg-canvas'

<SelectionBox
  bounds={{ x: 100, y: 100, width: 200, height: 150 }}
  rotation={45}
  stroke="#0066ff"
  strokeDasharray="4,4"
  showHandles={true}
  handleSize={8}
  onResizeStart={(handle, e) => console.log('Resize:', handle)}
/>

Interaction Hooks

useDraggable

Provides smooth drag interaction with window-level events.

import { useDraggable, svgTransformCoordinates } from 'react-svg-canvas'

function DraggableRect({ x, y, onMove }) {
  const { isDragging, dragProps } = useDraggable({
    onDragStart: (e) => console.log('Start:', e.x, e.y),
    onDragMove: (e) => onMove(e.deltaX, e.deltaY),
    onDragEnd: (e) => console.log('End'),
    transformCoordinates: svgTransformCoordinates  // For SVG coordinate space
  })

  return (
    <rect
      x={x} y={y}
      width={100} height={80}
      fill={isDragging ? 'orange' : 'blue'}
      style={{ cursor: 'move' }}
      {...dragProps}
    />
  )
}

useResizable

Provides resize interaction for selected objects.

import { useResizable } from 'react-svg-canvas'

function ResizableRect({ bounds, onResize }) {
  const { isResizing, activeHandle, handleResizeStart } = useResizable({
    bounds,
    minWidth: 50,
    minHeight: 50,
    onResize: (e) => onResize(e.bounds),
    onResizeEnd: (e) => console.log('Final bounds:', e.bounds)
  })

  return (
    <SelectionBox
      bounds={bounds}
      onResizeStart={handleResizeStart}
    />
  )
}

Snapping System

Figma-style snapping with visual guide lines.

useSnapping

import { useSnapping, SnapGuides, DEFAULT_SNAP_CONFIG } from 'react-svg-canvas'

function Canvas({ objects }) {
  const { svg, translateFrom } = useSvgCanvas()
  const viewBounds = { x: 0, y: 0, width: 1000, height: 800 }

  const { snapDrag, snapResize, activeSnaps, allCandidates, clearSnaps } = useSnapping({
    objects,
    config: DEFAULT_SNAP_CONFIG,
    viewBounds
  })

  function handleDrag(objectId, bounds, delta, grabPoint) {
    const result = snapDrag({
      bounds: { ...bounds, rotation: 0 },
      objectId,
      delta,
      grabPoint
    })
    // result.position contains snapped coordinates
    // result.activeSnaps contains active snap info
  }

  return (
    <SvgCanvas
      fixed={
        <SnapGuides
          activeSnaps={activeSnaps}
          config={DEFAULT_SNAP_CONFIG.guides}
          viewBounds={viewBounds}
          transformPoint={translateFrom}
        />
      }
    >
      {/* Your objects */}
    </SvgCanvas>
  )
}

useGrabPoint

Helper hook for calculating the normalized grab point when dragging objects.

import { useGrabPoint } from 'react-svg-canvas'

function MyDraggable({ bounds }) {
  const { setGrabPoint, getGrabPoint } = useGrabPoint()

  function handleDragStart(mousePos) {
    setGrabPoint(mousePos, bounds)
  }

  function handleDrag(delta) {
    const grabPoint = getGrabPoint()  // Returns { x: 0-1, y: 0-1 }
    // Use with snapDrag...
  }
}

Snap Configuration

const config: SnapConfiguration = {
  enabled: true,
  snapToGrid: true,
  snapToObjects: true,
  snapToSizes: true,         // Snap to matching widths/heights
  gridSize: 10,
  snapThreshold: 8,          // Pixels within which snapping activates
  weights: {
    distance: 10,            // How much distance affects snap priority
    direction: 3,            // Movement direction influence
    velocity: 2,             // Faster movement = less sticky
    grabProximity: 5,        // Snaps near grab point prioritized
    hierarchy: 4,            // Parent/sibling preference
    edgePriority: 1.2,
    centerPriority: 1.0,
    gridPriority: 0.8,
    sizePriority: 0.9
  },
  guides: {
    color: '#ff3366',
    strokeWidth: 1,
    showDistanceIndicators: true
  },
  debug: {
    enabled: false,
    showTopN: 5,
    showScores: true,
    showScoreBreakdown: false
  }
}

Rotation System

Object and group rotation with visual snap zones and pivot point manipulation.

useRotatable

Provides rotation interaction with visual snap zones. When the pointer is within the inner portion of the rotation arc (default 75%), angles snap to predefined values.

import { useRotatable, DEFAULT_SNAP_ANGLES } from 'react-svg-canvas'

function RotatableObject({ bounds, rotation, onRotate }) {
  const { translateTo, translateFrom } = useSvgCanvas()

  const {
    rotationState,
    handleRotateStart,
    rotateProps,
    checkSnapZone,
    arcRadius,
    pivotPosition
  } = useRotatable({
    bounds,
    rotation,
    pivotX: 0.5,
    pivotY: 0.5,
    snapAngles: DEFAULT_SNAP_ANGLES,  // 15° intervals: [0, 15, 30, ...]
    snapZoneRatio: 0.75,              // Inner 75% of arc triggers snapping
    translateTo,
    translateFrom,
    screenSpaceSnapZone: true,        // Consistent UX at all zoom levels
    onRotate: (angle, isSnapped) => onRotate(angle),
    onRotateEnd: (angle) => console.log('Final:', angle)
  })

  return (
    <g>
      <rect {...bounds} />
      <RotationHandle
        position={pivotPosition}
        arcRadius={arcRadius}
        isInSnapZone={rotationState.isInSnapZone}
        onPointerDown={handleRotateStart}
        {...rotateProps}
      />
    </g>
  )
}
CRDT-Friendly State

For collaborative editing with external state (e.g., Yjs), use getter functions to avoid stale closures:

const { handleRotateStart } = useRotatable({
  bounds,
  rotation,
  // Getters are called at drag start for fresh values
  getBounds: () => yObject.get('bounds'),
  getRotation: () => yObject.get('rotation'),
  getPivot: () => ({ x: yObject.get('pivotX'), y: yObject.get('pivotY') }),
  onRotate: (angle) => yObject.set('rotation', angle)
})

usePivotDrag

Drag interaction for manipulating an object's rotation pivot point.

import { usePivotDrag, DEFAULT_PIVOT_SNAP_POINTS } from 'react-svg-canvas'

function PivotHandle({ bounds, rotation, pivotX, pivotY, onPivotChange }) {
  const { translateTo } = useSvgCanvas()

  const {
    pivotState,
    handlePivotDragStart,
    pivotDragProps,
    getPositionCompensation
  } = usePivotDrag({
    bounds,
    rotation,
    pivotX,
    pivotY,
    snapPoints: DEFAULT_PIVOT_SNAP_POINTS,  // 9 points: corners, edges, center
    snapThreshold: 0.08,
    translateTo,
    onDrag: (pivot, snappedPoint, positionCompensation) => {
      // positionCompensation adjusts object position to keep it visually in place
      onPivotChange(pivot, positionCompensation)
    },
    onDragEnd: (pivot, positionCompensation) => {
      console.log('Final pivot:', pivot)
    }
  })

  return (
    <circle
      cx={bounds.x + bounds.width * pivotX}
      cy={bounds.y + bounds.height * pivotY}
      r={6}
      fill={pivotState.snappedPoint ? 'blue' : 'gray'}
      onPointerDown={handlePivotDragStart}
      {...pivotDragProps}
    />
  )
}

useGroupPivot

Manages a shared pivot point for rotating multiple selected objects together.

import { useGroupPivot } from 'react-svg-canvas'

function GroupRotationUI({ selectedObjects, selectionBounds }) {
  const {
    groupPivotState,
    groupPivot,
    handleGroupPivotDragStart,
    groupPivotDragProps,
    rotateObjectsAroundPivot,
    resetPivotToCenter,
    setGroupPivot
  } = useGroupPivot({
    objects: selectedObjects,  // Array of { id, bounds, rotation, pivotX?, pivotY? }
    selectionBounds,
    onRotate: (angle, transformedObjects) => {
      // transformedObjects: { id, x, y, rotation }[]
      updateObjects(transformedObjects)
    }
  })

  return (
    <>
      {/* Pivot handle */}
      <circle
        cx={groupPivot.x}
        cy={groupPivot.y}
        r={8}
        fill={groupPivotState.isPivotCustom ? 'orange' : 'white'}
        onPointerDown={handleGroupPivotDragStart}
        {...groupPivotDragProps}
      />

      {/* Reset button */}
      <button onClick={resetPivotToCenter}>Reset Pivot</button>
    </>
  )
}

Rotation Utilities

import {
  // Constants
  DEFAULT_SNAP_ANGLES,           // [0, 15, 30, 45, ..., 345]
  DEFAULT_SNAP_ZONE_RATIO,       // 0.75
  DEFAULT_PIVOT_SNAP_THRESHOLD,  // 0.08

  // Angle utilities
  getAngleFromCenter,            // (center, point) => degrees
  snapAngle,                     // (angle, snapAngles, isInSnapZone) => angle
  findClosestSnapAngle,          // (angle, snapAngles) => snapAngle

  // Pivot utilities
  getPivotPosition,              // (bounds, pivotX, pivotY) => Point
  calculatePivotCompensation,    // Position adjustment when pivot moves
  canvasToPivot,                 // Convert canvas coords to normalized pivot
  snapPivot,                     // Snap to nearest snap point

  // Rotation transforms
  rotatePointAroundCenter,       // (point, center, angleDeg) => Point
  rotateObjectAroundPivot        // Transform object position during rotation
} from 'react-svg-canvas'

Geometry Utilities

Bounds Operations

import {
  getBoundsCenter,
  expandBounds,
  unionBounds,
  unionAllBounds,
  boundsIntersect,
  boundsContains,
  pointInBounds,
  boundsFromPoints,
  getHandlePositions,
  resizeBounds
} from 'react-svg-canvas'

// Get center point
const center = getBoundsCenter({ x: 0, y: 0, width: 100, height: 100 })
// { x: 50, y: 50 }

// Expand bounds by margin
const expanded = expandBounds(bounds, 10)

// Union of two bounds
const combined = unionBounds(boundsA, boundsB)

// Check intersection
if (boundsIntersect(selection, object.bounds)) {
  // Object is selected
}

// Create bounds from drag rectangle
const selectionRect = boundsFromPoints(startPoint, endPoint)

// Resize bounds from handle drag
const newBounds = resizeBounds(originalBounds, 'se', deltaX, deltaY, minW, minH)

Transforms

import {
  transformPoint,
  invertTransform,
  composeTransforms,
  matrixToTransform,
  transformToMatrix,
  getAbsolutePosition
} from 'react-svg-canvas'

// Apply transform to point
const worldPoint = transformPoint({ x: 10, y: 10 }, transform)

// Convert SVG matrix to transform object
const transform = matrixToTransform([1, 0, 0, 1, 100, 50])

// Get absolute position walking up hierarchy
const absPos = getAbsolutePosition(item, (item) => itemsById[item.parentId])

Math Utilities

import {
  rotatePoint,
  scalePoint,
  distance,
  snapToGrid,
  snapPointToGrid,
  lerp,
  clamp,
  normalizeAngle,
  degToRad,
  radToDeg
} from 'react-svg-canvas'

// Rotate point around center
const rotated = rotatePoint({ x: 100, y: 0 }, { x: 0, y: 0 }, 90)

// Snap to grid
const snapped = snapPointToGrid({ x: 123, y: 456 }, 10)
// { x: 120, y: 460 }

Spatial Queries

import {
  getObjectsAtPoint,
  getTopmostAtPoint,
  getObjectsIntersectingRect,
  getObjectsContainedInRect,
  getSelectionBounds,
  getObjectsInView,
  findNearestObject,
  getObjectsInRadius
} from 'react-svg-canvas'

// Hit testing
const clicked = getTopmostAtPoint(objects, { x: mouseX, y: mouseY })

// Rectangle selection
const selected = getObjectsIntersectingRect(objects, selectionRect)

// Viewport culling (render only visible objects)
const visible = getObjectsInView(objects, viewBounds)

// Find nearest object
const nearest = findNearestObject(objects, cursorPos, maxDistance)

Types

interface Point {
  x: number
  y: number
}

interface Bounds {
  x: number
  y: number
  width: number
  height: number
}

interface Transform {
  x: number
  y: number
  rotation: number
  scaleX: number
  scaleY: number
}

interface SpatialObject {
  id: string
  bounds: Bounds
}

interface ToolEvent {
  startX: number
  startY: number
  x: number
  y: number
}

type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'

// Rotation types
interface RotationState {
  isRotating: boolean
  startAngle: number
  currentAngle: number
  centerX: number
  centerY: number
  isInSnapZone: boolean
}

interface PivotState {
  isDragging: boolean
  pivotX: number           // 0-1 normalized
  pivotY: number           // 0-1 normalized
  snappedPoint: Point | null
}

interface GroupPivotState {
  isDragging: boolean
  pivotX: number           // Canvas coordinates
  pivotY: number           // Canvas coordinates
  isPivotCustom: boolean   // User moved pivot from default center
}

Example: Complete Editor

import {
  SvgCanvas,
  SvgCanvasHandle,
  useSelection,
  useDraggable,
  useSnapping,
  SelectionBox,
  SnapGuides,
  DEFAULT_SNAP_CONFIG
} from 'react-svg-canvas'

function Editor() {
  const canvasRef = useRef<SvgCanvasHandle>(null)
  const [objects, setObjects] = useState<MyObject[]>(initialObjects)

  const selection = useSelection({
    objects,
    onChange: (ids) => console.log('Selected:', ids)
  })

  const snapping = useSnapping({
    objects,
    config: DEFAULT_SNAP_CONFIG,
    viewBounds: { x: 0, y: 0, width: 1920, height: 1080 }
  })

  return (
    <SvgCanvas
      ref={canvasRef}
      style={{ width: '100%', height: '100vh' }}
      onToolStart={(e) => {
        const hit = getTopmostAtPoint(objects, e)
        if (hit) selection.select(hit.id, false)
        else selection.clear()
      }}
      fixed={
        <SnapGuides
          activeSnaps={snapping.activeSnaps}
          config={DEFAULT_SNAP_CONFIG.guides}
          viewBounds={viewBounds}
        />
      }
    >
      {objects.map(obj => (
        <DraggableShape
          key={obj.id}
          object={obj}
          isSelected={selection.isSelected(obj.id)}
          onMove={(delta) => {
            const result = snapping.snapDrag({
              bounds: obj.bounds,
              objectId: obj.id,
              delta,
              grabPoint: { x: 0.5, y: 0.5 }
            })
            updateObject(obj.id, result.position)
          }}
        />
      ))}

      {selection.selectionBounds && (
        <SelectionBox
          bounds={selection.selectionBounds}
          onResizeStart={handleResize}
        />
      )}
    </SvgCanvas>
  )
}

Browser Support

  • Modern browsers with ES2021 support
  • Touch devices (iOS Safari, Android Chrome)

License

MIT License - see LICENSE for details.

Author

Szilard Hajba szilard@cloudillo.org

About

React SVG Canvas

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors