React Konva User Guide

Introduction

Konva is a powerful 2D Canvas library that makes handling vector graphics and images easy.

React-konva is a wrapper library that allows you to use Konva within React applications using familiar Components instead of having to manipulate the Canvas API directly. This combination brings declarative state management for graphical objects in the style of React.

Advantages include:

  • The ability to process thousands of shapes while still ensuring high performance
  • Built-in support for interactive features like drag & drop, transforming, and flexible mouse/touch event handling.
  • Support for layering, image filters, and the ability to quickly export canvas content to image formats or JSON.

Detail

In this article, I will guide you on using Konva to create a whiteboard with functions similar to a simple paint application, allowing you to upload images, draw rectangles, circles, and pencil lines, to help you initially understand Konva's functions


First, please install the following packages

yarn add konva react-konva use-image


Create the ImageElement.tsx file as follows

import {Image as KonvaImage} from 'react-konva'
import useImage from 'use-image'
import Konva from 'konva'

export interface ImageElementProps {
src?: string
commonProps: Konva.NodeConfig & {id: string}
}

export const ImageElement = ({src, commonProps}: ImageElementProps) => {
const [img] = useImage(src || '')
return <KonvaImage {...commonProps} image={img} />
}

This code snippet creates a specialized component to display images on the Canvas. It uses the use-image hook to load images asynchronously from a URL and returns Konva's Image object after the image has finished loading, helping to avoid display errors when the image hasn't loaded in time.


Create the RenderElement.tsx file

import type {KonvaEventObject} from 'konva/lib/Node'
import {memo} from 'react'
import {Circle, Line, Rect} from 'react-konva'
import {ImageElement} from './ImageElement'
import {useWhiteboardStore} from './store'

export const RenderElement = memo(({id}: {id: string}) => {
const element = useWhiteboardStore(state => state.elements[id])
const updateElement = useWhiteboardStore(state => state.updateElement)
const setSelectedId = useWhiteboardStore(state => state.setSelectedId)

if (!element) return null

const commonProps: any = {
...element,
id: element.id,
draggable: true,
onClick: () => setSelectedId(id),
onTap: () => setSelectedId(id),
onDragEnd: (e: KonvaEventObject<DragEvent>) => {
updateElement(id, {x: e.target.x(), y: e.target.y()})
},
onTransformEnd: (e: KonvaEventObject<Event>) => {
const node = e.target
updateElement(id, {
x: node.x(),
y: node.y(),
scaleX: node.scaleX(),
scaleY: node.scaleY(),
})
},
}

switch (element.type) {
case 'rect':
return <Rect {...commonProps} />
case 'circle':
return (
<Circle
{...commonProps}
radius={(element.width! * (element.scaleX || 1)) / 2}
/>
)
case 'pen':
return (
<Line
{...element}
strokeWidth={5}
tension={0.5}
lineCap="round"
points={element.points}
draggable={false}
/>
)
case 'image':
return <ImageElement src={element.src} commonProps={commonProps} />
default:
return null
}
})

This component serves as a display navigation filter. Based on the type attribute of each object in the store, it will decide to draw a rectangle (Rect), circle (Circle), hand-drawn line (Line), or image (ImageElement), while also handling coordinate and size update events when the user interacts.


Create the store.ts file using Zustand to manage the store

import {create} from 'zustand'
import {devtools} from 'zustand/middleware'
import {immer} from 'zustand/middleware/immer'

export type ElementType = 'rect' | 'circle' | 'pen' | 'image'

export interface WhiteboardElement {
id: string
type: ElementType
x?: number
y?: number
width?: number
height?: number
fill?: string
stroke?: string
points?: number[]
src?: string
scaleX?: number
scaleY?: number
}

interface WhiteboardState {
elements: Record<string, WhiteboardElement>
elementIds: string[]
selectedId: string | null
tool: 'select' | 'pen'

setTool: (tool: 'select' | 'pen') => void
setSelectedId: (id: string | null) => void
addElement: (element: WhiteboardElement) => void
updateElement: (id: string, attrs: Partial<WhiteboardElement>) => void
updateLine: (id: string, point: {x: number; y: number}) => void
clearBoard: () => void
}

export const useWhiteboardStore = create<WhiteboardState>()(
devtools(
immer(set => ({
elements: {},
elementIds: [],
selectedId: null,
tool: 'select',

setTool: tool =>
set(state => {
state.tool = tool
}),

setSelectedId: id =>
set(state => {
state.selectedId = id
}),

addElement: element =>
set(state => {
state.elements[element.id] = element
state.elementIds.push(element.id)
}),

updateElement: (id, attrs) =>
set(state => {
if (state.elements[id]) {
state.elements[id] = {...state.elements[id], ...attrs}
}
}),

updateLine: (id, point) =>
set(state => {
const line = state.elements[id]
if (line && line.points) {
line.points.push(point.x, point.y)
}
}),

clearBoard: () =>
set(state => {
state.elements = {}
state.elementIds = []
state.selectedId = null
}),
})),
{name: 'WhiteboardStore'}
)
)

This is where all the drawing board data is managed using the Zustand library. It stores the list of elements, the selection state, and the current tool. Using immer helps to update complex states (like the coordinate array of a drawing stroke) simply and safely.


Create the WhiteBoard.tsx file, which is the main component we will use

import {
BorderOutlined,
DeleteOutlined,
EditOutlined,
PictureOutlined,
SelectOutlined,
StopOutlined,
} from '@ant-design/icons'
import {Button, Card, Divider, Space, Tag, Typography} from 'antd'
import Konva from 'konva'
import {KonvaEventObject} from 'konva/lib/Node'
import {ChangeEvent, useEffect, useRef} from 'react'
import {Layer, Stage, Transformer} from 'react-konva'
import {RenderElement} from './RenderElement'
import {useWhiteboardStore} from './store'

const {Text} = Typography

const Whiteboard = () => {
const {
elementIds,
tool,
selectedId,
setTool,
addElement,
updateLine,
setSelectedId,
clearBoard,
} = useWhiteboardStore()

const stageRef = useRef<Konva.Stage>(null)
const trRef = useRef<Konva.Transformer>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const isDrawing = useRef(false)
const currentLineId = useRef<string | null>(null)

useEffect(() => {
if (trRef.current && stageRef.current) {
const nodes = selectedId
? [stageRef.current.findOne('#' + selectedId)]
: []
trRef.current.nodes(nodes.filter((n): n is Konva.Node => !!n))
trRef.current.getLayer()?.batchDraw()
}
}, [selectedId])

const handleMouseDown = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (tool !== 'pen') return
const stage = e.target.getStage()
const pos = stage?.getPointerPosition()
if (!pos) return

isDrawing.current = true
const id = `line-${Date.now()}`
currentLineId.current = id
addElement({id, type: 'pen', points: [pos.x, pos.y], stroke: '#df4b26'})
}

const handleMouseMove = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (!isDrawing.current || tool !== 'pen' || !currentLineId.current) return
const stage = e.target.getStage()
const pos = stage?.getPointerPosition()
if (pos) updateLine(currentLineId.current, pos)
}

const handleMouseUp = () => {
isDrawing.current = false
currentLineId.current = null
}

const addShape = (type: 'rect' | 'circle') => {
const id = `${type}-${Date.now()}`
addElement({
id,
type,
x: 100 + Math.random() * 50,
y: 100 + Math.random() * 50,
width: 100,
height: 100,
fill: '#3b82f6',
scaleX: 1,
scaleY: 1,
})
setSelectedId(id)
}

const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return

Array.from(files).forEach((file, index) => {
const reader = new FileReader()
reader.onload = event => {
const id = `image-${Date.now()}-${index}`
addElement({
id,
type: 'image',
src: event.target?.result as string,
x: 50 + index * 30,
y: 50 + index * 30,
scaleX: 0.5,
scaleY: 0.5,
})
}
reader.readAsDataURL(file)
})
e.target.value = ''
}

return (
<div className="flex flex-col items-center min-h-screen bg-slate-100 p-8">
<input
type="file"
ref={fileInputRef}
multiple
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>

<Card className="shadow-xl rounded-2xl w-full max-w-[850px] bg-white border-none overflow-hidden">
<div className="flex flex-wrap justify-between items-center mb-6 gap-4">
<h2 className="text-2xl font-bold text-slate-800 !m-0">
Interactive Board
</h2>

<Space className="bg-slate-50 p-1 rounded-lg border border-slate-200">
<Button
type={tool === 'select' ? 'primary' : 'text'}
icon={<SelectOutlined />}
onClick={() => setTool('select')}
>
Select
</Button>
<Button
type={tool === 'pen' ? 'primary' : 'text'}
icon={<EditOutlined />}
onClick={() => setTool('pen')}
>
Pen
</Button>
<Divider orientation="vertical" className="h-6" />
<Button icon={<BorderOutlined />} onClick={() => addShape('rect')}>
Rect
</Button>
<Button icon={<StopOutlined />} onClick={() => addShape('circle')}>
Circle
</Button>
<Button
icon={<PictureOutlined />}
onClick={() => fileInputRef.current?.click()}
>
Images
</Button>
<Button danger icon={<DeleteOutlined />} onClick={clearBoard}>
Clear
</Button>
</Space>
</div>

<div className="border border-slate-200 rounded-xl overflow-hidden shadow-inner bg-white">
<Stage
width={800}
height={500}
ref={stageRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={e =>
e.target === e.target.getStage() && setSelectedId(null)
}
>
<Layer>
{elementIds.map(id => (
<RenderElement key={id} id={id} />
))}
<Transformer
ref={trRef}
anchorSize={8}
keepRatio={true}
enabledAnchors={[
'top-left',
'top-right',
'bottom-left',
'bottom-right',
]}
borderStroke="#3b82f6"
/>
</Layer>
</Stage>
</div>

<div className="mt-4 text-center">
<Text type="secondary" className="text-sm">
Mode:{' '}
<Tag color={tool === 'pen' ? 'orange' : 'blue'}>
{tool.toUpperCase()}
</Tag>
| Elements: {elementIds.length}
</Text>
</div>
</Card>
</div>
)
}

export default Whiteboard

This is the control center of the application. This component integrates the user interface (Ant Design) with Konva's Stage. It handles capturing mouse events for freehand drawing, managing the selection and resizing of objects through the Transformer, and processing the upload of multiple image files to the drawing board at the same time.


You can see that using Konva makes it easy for us to interact with components similar to how we do in React, but in reality, it is still using the canvas element under the hood.


The result will be as follows

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Using Kafka with Docker and NodeJS

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Kubernetes Practice Series

NodeJS Practice Series

Sitemap