Claude
Skills
Sign in
Back

image-editor

Included with Lifetime
$97 forever

Canvas image editor with Konva.js — crop, resize, rotate, filters, brush masking, and layer compositing. Runs entirely client-side. Use this skill when the user says "add image editor", "setup canvas editor", "add konva", "image editing", or "photo editor".

Image & Video

What this skill does


# Image Editor (Konva.js)

Client-side image editor built with [Konva.js](https://konvajs.org) via `react-konva`. Non-destructive editing with crop, resize, rotate, flip, brightness/contrast/saturation filters, and brush-based masking for AI inpainting workflows. Exports flattened PNG/JPEG for downstream processing.

## Prerequisites

- Next.js app with App Router (no `src/` directory)
- `add-shadcn` skill applied (for UI components)

## Installation

```bash
bun add konva react-konva
bunx shadcn@latest add dialog slider label button separator scroll-area
```

## What Gets Created

```
lib/
└── image-editor/
    ├── types.ts                    # EditorState, Layer, Transform, FilterValues
    ├── filters.ts                  # Filter application helpers
    └── use-editor.ts               # React hook: load image, apply transforms, export
components/
└── image-editor/
    ├── editor-canvas.tsx           # Konva Stage with image rendering and transform handles
    ├── toolbar.tsx                 # Crop, resize, rotate, flip, filter controls
    ├── filter-panel.tsx            # Brightness, contrast, saturation, blur sliders
    ├── mask-tool.tsx               # Brush-based masking for inpainting regions
    └── editor-dialog.tsx           # Dialog wrapper that opens the editor
```

## Setup Steps

### Step 1: Create `lib/image-editor/types.ts`

```typescript
export type FilterValues = {
  brightness: number;
  contrast: number;
  saturation: number;
  blur: number;
};

export const DEFAULT_FILTERS: FilterValues = {
  brightness: 0,
  contrast: 0,
  saturation: 0,
  blur: 0,
};

export type Transform = {
  rotation: number;
  flipX: boolean;
  flipY: boolean;
  crop: {
    x: number;
    y: number;
    width: number;
    height: number;
  } | null;
};

export const DEFAULT_TRANSFORM: Transform = {
  rotation: 0,
  flipX: false,
  flipY: false,
  crop: null,
};

export type EditorState = {
  imageUrl: string;
  naturalWidth: number;
  naturalHeight: number;
  filters: FilterValues;
  transform: Transform;
  maskLines: MaskLine[];
  isDirty: boolean;
};

export type MaskLine = {
  points: number[];
  strokeWidth: number;
};

export type ExportOptions = {
  format: "png" | "jpeg";
  quality: number;
  maxWidth?: number;
  maxHeight?: number;
};
```

### Step 2: Create `lib/image-editor/filters.ts`

```typescript
import Konva from "konva";
import type { Filter } from "konva/lib/Node";
import type { FilterValues } from "./types";

export function applyFilters(
  node: Konva.Image,
  filters: FilterValues
): void {
  const activeFilters: Filter[] = [];

  if (filters.brightness !== 0) {
    activeFilters.push(Konva.Filters.Brighten);
    node.brightness(filters.brightness);
  }

  if (filters.contrast !== 0) {
    activeFilters.push(Konva.Filters.Contrast);
    node.contrast(filters.contrast * 100);
  }

  if (filters.blur > 0) {
    activeFilters.push(Konva.Filters.Blur);
    node.blurRadius(filters.blur * 10);
  }

  if (filters.saturation !== 0) {
    activeFilters.push(Konva.Filters.HSL);
    node.saturation(filters.saturation);
  }

  node.filters(activeFilters);
  node.cache();
}
```

### Step 3: Create `lib/image-editor/use-editor.ts`

```typescript
"use client";

import { useState, useCallback, useRef } from "react";
import type Konva from "konva";
import type { EditorState, FilterValues, MaskLine, ExportOptions } from "./types";
import { DEFAULT_FILTERS, DEFAULT_TRANSFORM } from "./types";

export function useEditor(imageUrl: string) {
  const stageRef = useRef<Konva.Stage>(null);
  const [state, setState] = useState<EditorState>({
    imageUrl,
    naturalWidth: 0,
    naturalHeight: 0,
    filters: DEFAULT_FILTERS,
    transform: DEFAULT_TRANSFORM,
    maskLines: [],
    isDirty: false,
  });

  const setNaturalSize = useCallback((width: number, height: number) => {
    setState((prev) => ({ ...prev, naturalWidth: width, naturalHeight: height }));
  }, []);

  const updateFilters = useCallback((filters: Partial<FilterValues>) => {
    setState((prev) => ({
      ...prev,
      filters: { ...prev.filters, ...filters },
      isDirty: true,
    }));
  }, []);

  const rotate = useCallback((degrees: number) => {
    setState((prev) => ({
      ...prev,
      transform: {
        ...prev.transform,
        rotation: (prev.transform.rotation + degrees) % 360,
      },
      isDirty: true,
    }));
  }, []);

  const flipHorizontal = useCallback(() => {
    setState((prev) => ({
      ...prev,
      transform: { ...prev.transform, flipX: !prev.transform.flipX },
      isDirty: true,
    }));
  }, []);

  const flipVertical = useCallback(() => {
    setState((prev) => ({
      ...prev,
      transform: { ...prev.transform, flipY: !prev.transform.flipY },
      isDirty: true,
    }));
  }, []);

  const addMaskLine = useCallback((line: MaskLine) => {
    setState((prev) => ({
      ...prev,
      maskLines: [...prev.maskLines, line],
      isDirty: true,
    }));
  }, []);

  const clearMask = useCallback(() => {
    setState((prev) => ({ ...prev, maskLines: [], isDirty: true }));
  }, []);

  const reset = useCallback(() => {
    setState((prev) => ({
      ...prev,
      filters: DEFAULT_FILTERS,
      transform: DEFAULT_TRANSFORM,
      maskLines: [],
      isDirty: false,
    }));
  }, []);

  const exportImage = useCallback(
    (options: ExportOptions = { format: "png", quality: 0.92 }): string | null => {
      const stage = stageRef.current;
      if (!stage) return null;

      return stage.toDataURL({
        mimeType: options.format === "jpeg" ? "image/jpeg" : "image/png",
        quality: options.quality,
        pixelRatio: 1,
      });
    },
    []
  );

  const exportMask = useCallback((): string | null => {
    const stage = stageRef.current;
    if (!stage) return null;

    // Find the mask layer and export just that
    const maskLayer = stage.findOne(".mask-layer");
    if (!maskLayer) return null;

    return maskLayer.toDataURL({
      mimeType: "image/png",
      pixelRatio: 1,
    });
  }, []);

  return {
    state,
    stageRef,
    setNaturalSize,
    updateFilters,
    rotate,
    flipHorizontal,
    flipVertical,
    addMaskLine,
    clearMask,
    reset,
    exportImage,
    exportMask,
  };
}
```

### Step 4: Create `components/image-editor/editor-canvas.tsx`

```typescript
"use client";

import { useEffect, useState, useRef, useCallback, useId, memo } from "react";
import { Stage, Layer, Image as KonvaImage, Line } from "react-konva";
import type Konva from "konva";
import { applyFilters } from "@/lib/image-editor/filters";
import type { EditorState, MaskLine } from "@/lib/image-editor/types";

type EditorCanvasProps = {
  state: EditorState;
  stageRef: React.RefObject<Konva.Stage | null>;
  onNaturalSize: (width: number, height: number) => void;
  onAddMaskLine: (line: MaskLine) => void;
  maskMode: boolean;
  brushSize: number;
  containerWidth: number;
  containerHeight: number;
};

export const EditorCanvas = memo(function EditorCanvas({
  state,
  stageRef,
  onNaturalSize,
  onAddMaskLine,
  maskMode,
  brushSize,
  containerWidth,
  containerHeight,
}: EditorCanvasProps) {
  const maskLineListId = useId();
  const [image, setImage] = useState<HTMLImageElement | null>(null);
  const imageRef = useRef<Konva.Image>(null);
  const isDrawing = useRef(false);
  const currentLine = useRef<number[]>([]);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    const img = new window.Image();
    img.crossOrigin = "anonymous";
    img.onload = () => {
      setImage(img);
      onNaturalSize(img.naturalWidth, img.naturalHeight);
    };
    img.src = state.imageUrl;
  }, [state.imageUrl, onNaturalSize]);

  useEffect(() => {
    if (imageRef.current && image) {
      applyFilters(imageRef.current, state.filters);
    }
  }, [state.filters, image]);

  // Calculate display dimensions to fit container
  const scale = image
    ? Math.min(containerWidth / image.naturalWidth, containerHeight / image.naturalHeight
Files: 1
Size: 22.0 KB
Complexity: 31/100
Category: Image & Video

Related in Image & Video