npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-annotation-konvas

v0.0.6

Published

Reusable annotation library built on React Konva

Readme

react-annotation-konvas

A reusable React canvas annotation component built on top of React Konva. Supports drawing rectangles, ellipses, and polygons on an image, with drag-to-move, resize, zoom, pan, and hide/show controls.


Installation

npm install react-annotation-konvas konva react-konva use-image

konva, react-konva, and use-image are peer dependencies and must be installed alongside this package.


Quick Start

import { useRef } from "react";
import { LabelKonvas } from "react-annotation-konvas";
import type {
  LabelKonvasHandle,
  AnnotationData,
} from "react-annotation-konvas";

function App() {
  const konvasRef = useRef<LabelKonvasHandle>(null);

  const handleDataChange = (data: AnnotationData) => {
    console.log("Annotations:", data.annotations);
  };

  return (
    <LabelKonvas
      ref={konvasRef}
      imagePath="/images/photo.jpg"
      width={900}
      height={600}
      rectMode={true}
      selectedLabel="Cat"
      onDataChange={handleDataChange}
    />
  );
}

Props

| Prop | Type | Default | Description | | -------------------- | -------------------------------- | --------- | ----------------------------------------------------------------- | | imagePath | string | — | URL or path string to the image (e.g. "/images/photo.jpg") | | width | string \| number | "100%" | Width of the canvas container | | height | string \| number | "100%" | Height of the canvas container | | rectMode | boolean | false | Enable rectangle drawing mode | | ellipseMode | boolean | false | Enable ellipse drawing mode | | polygonMode | boolean | false | Enable polygon drawing mode | | panMode | boolean | false | Enable pan/drag mode | | selectedLabel | string | — | Label name assigned to newly drawn annotations | | zoom | number | 1 | Controlled zoom level (1 = 100%) | | initialAnnotations | Annotation[] | [] | Pre-load existing annotations onto the canvas | | initialHiddenIds | number[] | [] | IDs of annotations to hide on initial render | | selectedStyle | ShapeStyle | see below | Style applied to the currently selected shape | | unselectedStyle | ShapeStyle | see below | Style applied to unselected shapes | | drawingStyle | ShapeStyle | see below | Style applied to the shape being actively drawn | | onDataChange | (data: AnnotationData) => void | — | Fires whenever annotations change | | onModeChange | (key, active) => void | — | Fires when a drawing mode auto-deactivates after shape completion | | onSelectedIdChange | (id: number \| null) => void | — | Fires when the selected annotation changes | | onHiddenIdsChange | (ids: number[]) => void | — | Fires when the hidden annotation list changes | | onZoomChange | (zoom: number) => void | — | Fires when zoom changes internally (e.g. scroll) |

ShapeStyle

type ShapeStyle = {
  stroke?: string; // border color, e.g. "#FF4D4F"
  fill?: string; // fill color, e.g. "rgba(255,77,79,0.25)"
  strokeWidth?: number; // border thickness in px
  dash?: number[]; // dash pattern, e.g. [4, 4]
  pointsColor?: string; // polygon vertex dot color
};

Default styles:

// Selected shape
selectedStyle = {
  stroke: "#1677ff",
  fill: "rgba(22,119,255,0.2)",
  strokeWidth: 2,
};

// Unselected shapes
unselectedStyle = {
  stroke: "#FF4D4F",
  fill: "rgba(255,77,79,0.25)",
  strokeWidth: 2,
};

// Shape being drawn
drawingStyle = {
  stroke: "#FF4D4F",
  fill: "rgba(255,77,79,0.25)",
  strokeWidth: 2,
  dash: [4, 4],
};

Imperative API (ref)

Use a ref to call methods on the component imperatively:

const konvasRef = useRef<LabelKonvasHandle>(null);

// Delete the currently selected annotation
konvasRef.current?.deleteSelected();

// Delete a specific annotation by id
konvasRef.current?.deleteAnnotation(42);

// Delete all annotations
konvasRef.current?.deleteAll();

// Show/hide a specific annotation
konvasRef.current?.toggleHide(42);

// Programmatically select an annotation
konvasRef.current?.setSelectedId(42);

// Set the label for the next drawn annotation
konvasRef.current?.setSelectedLabel("Dog");

// Zoom controls
konvasRef.current?.zoomIn();
konvasRef.current?.zoomOut();
konvasRef.current?.zoomToFit();

// Get/Set annotations (useful for multi-image workflows)
const annotations = konvasRef.current?.getAnnotations();
konvasRef.current?.setAnnotations(savedAnnotations);

| Method | Description | | ----------------------------- | ----------------------------------------------------------- | | deleteSelected() | Deletes the currently selected annotation | | deleteAll() | Deletes all annotations | | deleteAnnotation(id) | Deletes a specific annotation by ID | | toggleHide(id) | Shows/hides a specific annotation by ID | | setSelectedId(id) | Programmatically selects an annotation (null to deselect) | | setSelectedLabel(label) | Sets the label for the next drawn annotation | | getAnnotations() | Returns all current annotations as Annotation[] | | setAnnotations(annotations) | Loads an array of annotations onto the canvas | | zoomIn() | Zooms in by 20% | | zoomOut() | Zooms out by 20% | | zoomToFit() | Resets zoom to 1 and centers the canvas |


Data Types

type AnnotationData = {
  image: string; // the imagePath value
  annotations: Annotation[];
};

type Annotation = RectAnnotation | EllipseAnnotation | PolygonAnnotation;

type RectAnnotation = {
  id: number;
  label: string;
  type: "rect";
  color?: string; // auto-detected dominant color from image region
  x: number; // left edge in image pixels
  y: number; // top edge in image pixels
  width: number;
  height: number;
};

type EllipseAnnotation = {
  id: number;
  label: string;
  type: "ellipse";
  color?: string;
  x: number; // center x in image pixels
  y: number; // center y in image pixels
  radiusX: number;
  radiusY: number;
};

type PolygonAnnotation = {
  id: number;
  label: string;
  type: "polygon";
  color?: string;
  points: number[]; // flat array [x1, y1, x2, y2, ...] in image pixels
};

LabelSidebar Component (Example)

Below is an example sidebar component that displays label options and annotation regions with hide/show/delete controls. This component is not exported from the package — copy and customize it for your own project.

// Example usage with your own LabelSidebar component
import LabelSidebar from "./components/LabelSidebar";

<LabelSidebar
  labels={["Cat", "Dog", "Car"]}
  selectedLabel={selectedLabel}
  onSelect={(label) => setSelectedLabel(label)}
  annotations={annotationData?.annotations ?? []}
  selectedId={selectedId}
  hiddenIds={hiddenIds}
  onSelectAnnotation={(id) => konvasRef.current?.setSelectedId(id)}
  onDeleteAnnotation={(id) => konvasRef.current?.deleteAnnotation(id)}
  onToggleHide={(id) => konvasRef.current?.toggleHide(id)}
/>;

LabelSidebar Props

| Prop | Type | Default | Description | | -------------------- | ------------------------- | ------- | ----------------------------------------------------- | | labels | string[] | — | List of label names to display in the sidebar | | selectedLabel | string | — | Currently selected label name | | onSelect | (label: string) => void | — | Callback when a label is clicked | | annotations | Annotation[] | [] | List of annotations to display in the regions section | | selectedId | number \| null | — | ID of the currently selected annotation | | hiddenIds | number[] | [] | IDs of annotations that are hidden | | onSelectAnnotation | (id: number) => void | — | Callback when an annotation row is clicked | | onDeleteAnnotation | (id: number) => void | — | Callback when the delete button is clicked | | onToggleHide | (id: number) => void | — | Callback when the visibility toggle button is clicked |

The sidebar has two sections:

  1. Labels section (top) — Shows clickable label options for annotating
  2. Regions section (bottom) — Shows drawn annotations with shape icon, color swatch, label name, hide/show toggle, and delete button

The divider between sections is draggable to resize.

import React, { useRef, useState, useCallback } from "react";
import CropSquareIcon from "@mui/icons-material/CropSquare";
import CircleOutlinedIcon from "@mui/icons-material/CircleOutlined";
import HexagonOutlinedIcon from "@mui/icons-material/HexagonOutlined";
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import type { Annotation } from "react-annotation-konvas";

type LabelSidebarProps = {
  labels: string[];
  selectedLabel?: string;
  onSelect?: (label: string) => void;
  annotations?: Annotation[];
  selectedId?: number | null;
  hiddenIds?: number[];
  onSelectAnnotation?: (id: number) => void;
  onDeleteAnnotation?: (id: number) => void;
  onToggleHide?: (id: number) => void;
};

const ShapeIcon = ({ type }: { type: Annotation["type"] }) => {
  const style = { fontSize: "15px" };
  if (type === "rect") return <CropSquareIcon style={style} />;
  if (type === "ellipse") return <CircleOutlinedIcon style={style} />;
  return <HexagonOutlinedIcon style={style} />;
};

const iconBtn: React.CSSProperties = {
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  width: "22px",
  height: "22px",
  borderRadius: "4px",
  border: "none",
  background: "transparent",
  cursor: "pointer",
  padding: 0,
  color: "#666",
  flexShrink: 0,
};

const LabelSidebar = ({
  labels,
  selectedLabel,
  onSelect,
  annotations = [],
  selectedId,
  hiddenIds = [],
  onSelectAnnotation,
  onDeleteAnnotation,
  onToggleHide,
}: LabelSidebarProps) => {
  const [topHeight, setTopHeight] = useState(50); // percent
  const dragging = useRef(false);
  const containerRef = useRef<HTMLDivElement>(null);

  const onDividerMouseDown = useCallback((e: React.MouseEvent) => {
    e.preventDefault();
    dragging.current = true;

    const onMove = (ev: MouseEvent) => {
      if (!dragging.current || !containerRef.current) return;
      const rect = containerRef.current.getBoundingClientRect();
      const pct = ((ev.clientY - rect.top) / rect.height) * 100;
      setTopHeight(Math.min(85, Math.max(15, pct)));
    };
    const onUp = () => {
      dragging.current = false;
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
    };
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
  }, []);

  return (
    <div
      ref={containerRef}
      style={{
        width: "220px",
        flexShrink: 0,
        borderRight: "1px solid #e0e0e0",
        display: "flex",
        flexDirection: "column",
        boxSizing: "border-box",
        background: "#fafafa",
        userSelect: "none",
        overflow: "hidden",
      }}
    >
      {/* ── TOP: Label list ── */}
      <div
        style={{
          height: `${topHeight}%`,
          overflow: "hidden",
          display: "flex",
          flexDirection: "column",
        }}
      >
        <p
          style={{
            margin: "0",
            padding: "14px 16px 10px",
            fontSize: "11px",
            fontWeight: 600,
            letterSpacing: "0.08em",
            textTransform: "uppercase",
            color: "#888",
            flexShrink: 0,
          }}
        >
          Labels
        </p>
        <div style={{ overflowY: "auto", flex: 1, padding: "0 8px 8px" }}>
          {labels.map((label) => (
            <div
              key={label}
              onClick={() => onSelect?.(label)}
              style={{
                padding: "7px 10px",
                marginBottom: "3px",
                cursor: "pointer",
                borderRadius: "6px",
                background: selectedLabel === label ? "#1677ff" : "transparent",
                color: selectedLabel === label ? "#fff" : "#1a1a1a",
                fontSize: "13px",
                fontWeight: selectedLabel === label ? 500 : 400,
                transition: "background 0.15s, color 0.15s",
              }}
              onMouseEnter={(e) => {
                if (selectedLabel !== label)
                  (e.currentTarget as HTMLDivElement).style.background =
                    "#efefef";
              }}
              onMouseLeave={(e) => {
                if (selectedLabel !== label)
                  (e.currentTarget as HTMLDivElement).style.background =
                    "transparent";
              }}
            >
              {label}
            </div>
          ))}
        </div>
      </div>

      {/* ── DIVIDER ── */}
      <div
        onMouseDown={onDividerMouseDown}
        style={{
          height: "6px",
          background: "#e0e0e0",
          cursor: "row-resize",
          flexShrink: 0,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        <div
          style={{
            width: "32px",
            height: "2px",
            borderRadius: "2px",
            background: "#bbb",
          }}
        />
      </div>

      {/* ── BOTTOM: Drawn regions ── */}
      <div
        style={{
          flex: 1,
          overflow: "hidden",
          display: "flex",
          flexDirection: "column",
        }}
      >
        <p
          style={{
            margin: "0",
            padding: "10px 16px 8px",
            fontSize: "11px",
            fontWeight: 600,
            letterSpacing: "0.08em",
            textTransform: "uppercase",
            color: "#888",
            flexShrink: 0,
          }}
        >
          Regions ({annotations.length})
        </p>
        <div style={{ overflowY: "auto", flex: 1, padding: "0 8px 8px" }}>
          {annotations.length === 0 && (
            <p
              style={{
                fontSize: "12px",
                color: "#aaa",
                padding: "4px 6px",
                margin: 0,
              }}
            >
              No regions drawn yet.
            </p>
          )}
          {annotations.map((ann) => {
            const isSelected = selectedId === ann.id;
            const isHidden = hiddenIds.includes(ann.id);
            return (
              <div
                key={ann.id}
                onClick={() => onSelectAnnotation?.(ann.id)}
                style={{
                  display: "flex",
                  alignItems: "center",
                  gap: "6px",
                  padding: "5px 8px",
                  marginBottom: "3px",
                  borderRadius: "6px",
                  cursor: "pointer",
                  background: isSelected ? "#e6f0ff" : "transparent",
                  border: isSelected
                    ? "1px solid #1677ff"
                    : "1px solid transparent",
                  opacity: isHidden ? 0.4 : 1,
                  transition: "background 0.15s",
                }}
                onMouseEnter={(e) => {
                  if (!isSelected)
                    (e.currentTarget as HTMLDivElement).style.background =
                      "#efefef";
                }}
                onMouseLeave={(e) => {
                  if (!isSelected)
                    (e.currentTarget as HTMLDivElement).style.background =
                      "transparent";
                }}
              >
                {/* Shape icon button */}
                <div
                  style={{
                    ...iconBtn,
                    color: isSelected ? "#1677ff" : "#555",
                    flexShrink: 0,
                  }}
                >
                  <ShapeIcon type={ann.type} />
                </div>

                {/* Summarized color swatch */}
                <div
                  title={ann.color ?? "no color"}
                  style={{
                    width: "14px",
                    height: "14px",
                    borderRadius: "3px",
                    background: ann.color ?? "#ccc",
                    border: "1px solid rgba(0,0,0,0.15)",
                    flexShrink: 0,
                  }}
                />

                {/* Label name */}
                <span
                  style={{
                    flex: 1,
                    fontSize: "12px",
                    fontWeight: 500,
                    color: "#1a1a1a",
                    overflow: "hidden",
                    textOverflow: "ellipsis",
                    whiteSpace: "nowrap",
                  }}
                >
                  {ann.label || (
                    <span style={{ color: "#aaa", fontStyle: "italic" }}>
                      unlabeled
                    </span>
                  )}
                </span>

                {/* Action icons */}
                <button
                  style={iconBtn}
                  title={isHidden ? "Show" : "Hide"}
                  onClick={(e) => {
                    e.stopPropagation();
                    onToggleHide?.(ann.id);
                  }}
                >
                  {isHidden ? (
                    <VisibilityOffOutlinedIcon style={{ fontSize: "14px" }} />
                  ) : (
                    <VisibilityOutlinedIcon style={{ fontSize: "14px" }} />
                  )}
                </button>
                <button
                  style={{ ...iconBtn, color: "#d32f2f" }}
                  title="Delete"
                  onClick={(e) => {
                    e.stopPropagation();
                    onDeleteAnnotation?.(ann.id);
                  }}
                >
                  <DeleteOutlineOutlinedIcon style={{ fontSize: "14px" }} />
                </button>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

export default LabelSidebar;

ToolButton Component (Example)

Below is an example toolbar button component with active state. This component is not exported from the package — copy and customize it for your own project.

// Example usage with your own ToolButton component
import ToolButton from "./components/ToolButton";

<ToolButton
  onClick={() => setIsRectMode((p) => !p)}
  active={isRectMode}
  title="Rectangle Tool"
>
  <RectIcon />
</ToolButton>;

ToolButton Props

| Prop | Type | Default | Description | | ----------- | --------------------- | ------- | --------------------------------------------- | | onClick | () => void | — | Click handler | | active | boolean | false | Whether the button is in active/pressed state | | title | string | — | Tooltip text | | children | React.ReactNode | — | Button content (typically an icon) | | className | string | — | Additional CSS class | | style | React.CSSProperties | — | Inline style overrides |

import React from "react";

type ToolButtonProps = {
  onClick: () => void;
  active?: boolean;
  title?: string;
  children: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
};

const ToolButton = ({
  onClick,
  active = false,
  title,
  children,
  className,
  style,
}: ToolButtonProps) => {
  return (
    <button
      onClick={onClick}
      title={title}
      className={className}
      style={{
        background: active ? "#fd8789" : "#ffffff",
        border: "1px solid #ccc",
        height: "30px",
        width: "30px",
        cursor: "pointer",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        color: active ? "#fff" : "#555",
        transition: "all 0.2s ease",
        ...style,
      }}
    >
      {children}
    </button>
  );
};

export default ToolButton;

Full Example with Toolbar, Sidebar, and Multi-Image Navigation

import { useState, useRef, useEffect } from "react";
import { LabelKonvas } from "react-annotation-konvas";
import type {
  LabelKonvasHandle,
  AnnotationData,
  Annotation,
} from "react-annotation-konvas";
// Import your own custom components (see example implementations above)
import LabelSidebar from "./components/LabelSidebar";
import ToolButton from "./components/ToolButton";

const labels = ["Cat", "Dog", "Car", "Person"];
const images = [
  "/images/photo1.jpg",
  "/images/photo2.jpg",
  "/images/photo3.jpg",
];

function AnnotationTool() {
  const konvasRef = useRef<LabelKonvasHandle>(null);
  const [currentImageIndex, setCurrentImageIndex] = useState(0);
  const [annotationDataList, setAnnotationDataList] = useState<
    (AnnotationData | null)[]
  >(images.map(() => null));
  const [annotationData, setAnnotationData] = useState<AnnotationData | null>(
    null,
  );
  const [isRectMode, setIsRectMode] = useState(false);
  const [isEllipseMode, setIsEllipseMode] = useState(false);
  const [isPolygonMode, setIsPolygonMode] = useState(false);
  const [isPanMode, setIsPanMode] = useState(false);
  const [selectedLabel, setSelectedLabel] = useState<string | undefined>();
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const [hiddenIds, setHiddenIds] = useState<number[]>([]);
  const [zoom, setZoom] = useState(1);

  // Save current annotations before switching images
  const saveCurrentAnnotations = () => {
    const annotations = konvasRef.current?.getAnnotations() ?? [];
    setAnnotationDataList((prev) => {
      const updated = [...prev];
      updated[currentImageIndex] = {
        id: currentImageIndex,
        image: images[currentImageIndex],
        annotations,
      };
      return updated;
    });
  };

  const handlePrevImage = () => {
    saveCurrentAnnotations();
    setCurrentImageIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
  };

  const handleNextImage = () => {
    saveCurrentAnnotations();
    setCurrentImageIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
  };

  // Restore annotations when switching images
  useEffect(() => {
    const savedData = annotationDataList[currentImageIndex];
    if (savedData?.annotations && savedData.annotations.length > 0) {
      konvasRef.current?.setAnnotations(savedData.annotations);
    } else {
      konvasRef.current?.deleteAll();
    }
  }, [currentImageIndex]);

  return (
    <div style={{ display: "flex", height: "100vh" }}>
      {/* Sidebar */}
      <LabelSidebar
        labels={labels}
        selectedLabel={selectedLabel}
        onSelect={(label) => {
          setSelectedLabel(label);
          konvasRef.current?.setSelectedLabel(label);
        }}
        annotations={annotationData?.annotations ?? []}
        selectedId={selectedId}
        hiddenIds={hiddenIds}
        onSelectAnnotation={(id) => konvasRef.current?.setSelectedId(id)}
        onDeleteAnnotation={(id) => konvasRef.current?.deleteAnnotation(id)}
        onToggleHide={(id) => konvasRef.current?.toggleHide(id)}
      />

      {/* Main area */}
      <div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
        {/* Toolbar */}
        <div
          style={{
            display: "flex",
            gap: 8,
            padding: 8,
            borderBottom: "1px solid #ccc",
            alignItems: "center",
          }}
        >
          <ToolButton
            onClick={() => {
              setIsRectMode((p) => !p);
              setIsEllipseMode(false);
              setIsPolygonMode(false);
              setIsPanMode(false);
            }}
            active={isRectMode}
            title="Rectangle"
          >
            ▢
          </ToolButton>
          <ToolButton
            onClick={() => {
              setIsEllipseMode((p) => !p);
              setIsRectMode(false);
              setIsPolygonMode(false);
              setIsPanMode(false);
            }}
            active={isEllipseMode}
            title="Ellipse"
          >
            ○
          </ToolButton>
          <ToolButton
            onClick={() => {
              setIsPolygonMode((p) => !p);
              setIsRectMode(false);
              setIsEllipseMode(false);
              setIsPanMode(false);
            }}
            active={isPolygonMode}
            title="Polygon"
          >
            ⬡
          </ToolButton>
          <ToolButton
            onClick={() => {
              setIsPanMode((p) => !p);
              setIsRectMode(false);
              setIsEllipseMode(false);
              setIsPolygonMode(false);
            }}
            active={isPanMode}
            title="Pan"
          >
            ✋
          </ToolButton>
          <ToolButton
            onClick={() => konvasRef.current?.zoomIn()}
            title="Zoom In"
          >
            +
          </ToolButton>
          <ToolButton
            onClick={() => konvasRef.current?.zoomOut()}
            title="Zoom Out"
          >
            -
          </ToolButton>
          <ToolButton
            onClick={() => konvasRef.current?.zoomToFit()}
            title="Fit"
          >
            ⊡
          </ToolButton>
          <ToolButton
            onClick={() => konvasRef.current?.deleteSelected()}
            title="Delete"
          >
            🗑
          </ToolButton>
          <ToolButton
            onClick={() => konvasRef.current?.deleteAll()}
            title="Clear All"
          >
            🗑️
          </ToolButton>

          {/* Image Navigation */}
          <div
            style={{
              marginLeft: "auto",
              display: "flex",
              alignItems: "center",
              gap: 8,
            }}
          >
            <ToolButton onClick={handlePrevImage} title="Previous Image">
              ←
            </ToolButton>
            <span style={{ fontSize: 14, fontWeight: 500 }}>
              {currentImageIndex + 1} / {images.length}
            </span>
            <ToolButton onClick={handleNextImage} title="Next Image">
              →
            </ToolButton>
          </div>
        </div>

        {/* Canvas */}
        <LabelKonvas
          ref={konvasRef}
          imagePath={images[currentImageIndex]}
          width="100%"
          height="100%"
          rectMode={isRectMode}
          ellipseMode={isEllipseMode}
          polygonMode={isPolygonMode}
          panMode={isPanMode}
          selectedLabel={selectedLabel}
          zoom={zoom}
          onZoomChange={setZoom}
          onDataChange={setAnnotationData}
          onSelectedIdChange={setSelectedId}
          onHiddenIdsChange={setHiddenIds}
          onModeChange={(key, active) => {
            if (key === "rectangle") setIsRectMode(active);
            if (key === "ellipse") setIsEllipseMode(active);
            if (key === "polygon") setIsPolygonMode(active);
            if (key === "pan") setIsPanMode(active);
          }}
          selectedStyle={{
            stroke: "#1677ff",
            fill: "rgba(22,119,255,0.2)",
            strokeWidth: 2,
          }}
          unselectedStyle={{
            stroke: "#FF4D4F",
            fill: "rgba(255,77,79,0.15)",
            strokeWidth: 2,
          }}
          drawingStyle={{
            stroke: "#FF4D4F",
            fill: "rgba(255,77,79,0.25)",
            strokeWidth: 2,
            dash: [5, 4],
          }}
        />
      </div>
    </div>
  );
}

export default AnnotationTool;

Key Features for Multi-Image Support

  • getAnnotations() — Retrieve current annotations before switching images
  • setAnnotations(annotations) — Restore saved annotations when returning to an image
  • deleteAll() — Clear canvas when no saved annotations exist for an image
  • Annotations are stored per-image in annotationDataList and persist while navigating

Drawing Instructions

| Shape | How to draw | | ------------- | --------------------------------------------------------------------------- | | Rectangle | Click once to set the first corner, move mouse, click again to finish | | Ellipse | Click once to set the center, move mouse, click again to finish | | Polygon | Click to add each vertex; click near the first point to close the shape | | Pan | Click and drag to pan around the canvas |

  • Press Escape to cancel an in-progress rectangle or ellipse drawing.
  • Click any drawn shape (while not in a drawing mode) to select it.
  • Drag a selected shape to move it.
  • Use the Transformer handles on selected rectangles/ellipses to resize them.
  • Drag individual vertex dots on a selected polygon to reshape it.

Loading Existing Annotations

const saved: Annotation[] = [
  { id: 1, label: "Cat", type: "rect", x: 50, y: 80, width: 200, height: 150 },
  {
    id: 2,
    label: "Dog",
    type: "ellipse",
    x: 300,
    y: 200,
    radiusX: 80,
    radiusY: 50,
  },
  {
    id: 3,
    label: "Car",
    type: "polygon",
    points: [10, 20, 100, 20, 100, 80, 10, 80],
  },
];

<LabelKonvas imagePath="/photo.jpg" initialAnnotations={saved} />;

Coordinates are in image pixels (not screen pixels), so they remain accurate regardless of zoom or canvas size.


Peer Dependencies

| Package | Version | | ------------- | -------- | | react | ≥ 17.0.0 | | react-dom | ≥ 17.0.0 | | konva | ≥ 8.0.0 | | react-konva | ≥ 18.0.0 | | use-image | ≥ 1.0.0 |

Note: If you use the example LabelSidebar component, you'll also need @mui/icons-material, @mui/material, @emotion/react, and @emotion/styled.


License

ISC