Реализация рисования/аннотирования (Canvas) на сайте

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация рисования/аннотирования (Canvas) на сайте
Сложная
~1-2 недели
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Реализация рисования и аннотирования (Canvas) на сайте

Canvas-инструменты рисования нужны в системах проверки документов (аннотирование PDF), обучающих платформах (доска преподавателя), инструментах дизайна и системах управления проектами с визуальными схемами. Технически это работа с 2D Context или WebGL поверх <canvas>, обработка pointer-событий и сериализация состояния.

Архитектура: immediate mode vs retained mode

Immediate mode (чистый Canvas 2D) — рисуем пиксели напрямую. Быстро, нет накладных расходов. Сложно: нет объектной модели, hit-testing приходится писать вручную.

Retained mode (Fabric.js, Konva.js) — объектная модель поверх canvas. Каждая фигура — объект, можно выделить, переместить, изменить. Дороже по памяти, но намного удобнее для редакторов.

Для аннотирования с выделением и редактированием объектов — retained mode (Konva или Fabric). Для freehand-рисования кистями — immediate mode с pressure sensitivity.

Freehand рисование (Canvas 2D)

function DrawingCanvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const isDrawingRef = useRef(false)
  const lastPointRef = useRef<{ x: number; y: number } | null>(null)

  const [tool, setTool] = useState<'pen' | 'eraser'>('pen')
  const [color, setColor] = useState('#2563eb')
  const [lineWidth, setLineWidth] = useState(3)

  function getPoint(e: PointerEvent): { x: number; y: number } {
    const rect = canvasRef.current!.getBoundingClientRect()
    const scaleX = canvasRef.current!.width / rect.width
    const scaleY = canvasRef.current!.height / rect.height
    return {
      x: (e.clientX - rect.left) * scaleX,
      y: (e.clientY - rect.top) * scaleY,
    }
  }

  useEffect(() => {
    const canvas = canvasRef.current!
    const ctx = canvas.getContext('2d')!

    function onPointerDown(e: PointerEvent) {
      canvas.setPointerCapture(e.pointerId)
      isDrawingRef.current = true
      lastPointRef.current = getPoint(e)

      // Точка при клике без движения
      ctx.beginPath()
      ctx.arc(lastPointRef.current.x, lastPointRef.current.y, lineWidth / 2, 0, Math.PI * 2)
      ctx.fillStyle = tool === 'eraser' ? '#ffffff' : color
      ctx.fill()
    }

    function onPointerMove(e: PointerEvent) {
      if (!isDrawingRef.current || !lastPointRef.current) return

      const point = getPoint(e)

      ctx.globalCompositeOperation = tool === 'eraser' ? 'destination-out' : 'source-over'
      ctx.strokeStyle = color
      ctx.lineWidth = tool === 'eraser' ? lineWidth * 4 : lineWidth
      ctx.lineCap = 'round'
      ctx.lineJoin = 'round'

      // Quadratic bezier для плавных линий
      ctx.beginPath()
      ctx.moveTo(lastPointRef.current.x, lastPointRef.current.y)
      ctx.quadraticCurveTo(
        lastPointRef.current.x,
        lastPointRef.current.y,
        (lastPointRef.current.x + point.x) / 2,
        (lastPointRef.current.y + point.y) / 2
      )
      ctx.stroke()

      lastPointRef.current = point
    }

    function onPointerUp() {
      isDrawingRef.current = false
      lastPointRef.current = null
    }

    canvas.addEventListener('pointerdown', onPointerDown)
    canvas.addEventListener('pointermove', onPointerMove)
    canvas.addEventListener('pointerup', onPointerUp)
    canvas.addEventListener('pointercancel', onPointerUp)

    return () => {
      canvas.removeEventListener('pointerdown', onPointerDown)
      canvas.removeEventListener('pointermove', onPointerMove)
      canvas.removeEventListener('pointerup', onPointerUp)
      canvas.removeEventListener('pointercancel', onPointerUp)
    }
  }, [tool, color, lineWidth])

  return (
    <div>
      <canvas
        ref={canvasRef}
        width={1200}
        height={800}
        style={{ width: '100%', touchAction: 'none', cursor: 'crosshair' }}
        className="border rounded bg-white"
      />
    </div>
  )
}

touchAction: none — обязательно, иначе браузер перехватывает тач для скролла.

Аннотирование с Konva.js

npm install konva react-konva
import { Stage, Layer, Line, Rect, Circle, Text, Transformer } from 'react-konva'
import Konva from 'konva'

type AnnotationType = 'line' | 'rect' | 'circle' | 'arrow' | 'text'

interface Annotation {
  id: string
  type: AnnotationType
  points?: number[]
  x?: number
  y?: number
  width?: number
  height?: number
  text?: string
  color: string
}

function AnnotationTool({ backgroundImage }: { backgroundImage: string }) {
  const [annotations, setAnnotations] = useState<Annotation[]>([])
  const [selectedId, setSelectedId] = useState<string | null>(null)
  const [activeTool, setActiveTool] = useState<AnnotationType>('rect')
  const [isDrawing, setIsDrawing] = useState(false)
  const stageRef = useRef<Konva.Stage>(null)

  function getRelativePosition() {
    const stage = stageRef.current!
    const pos = stage.getPointerPosition()!
    return { x: pos.x, y: pos.y }
  }

  function handleMouseDown() {
    setSelectedId(null)
    const pos = getRelativePosition()
    const newAnnotation: Annotation = {
      id: crypto.randomUUID(),
      type: activeTool,
      color: '#ef4444',
      x: pos.x,
      y: pos.y,
      width: 0,
      height: 0,
    }

    if (activeTool === 'line') {
      newAnnotation.points = [pos.x, pos.y, pos.x, pos.y]
    }

    setAnnotations((prev) => [...prev, newAnnotation])
    setIsDrawing(true)
  }

  function handleMouseMove() {
    if (!isDrawing) return
    const pos = getRelativePosition()
    const lastIndex = annotations.length - 1
    const last = annotations[lastIndex]

    const updated = { ...last }
    if (activeTool === 'line') {
      updated.points = [last.points![0], last.points![1], pos.x, pos.y]
    } else {
      updated.width = pos.x - last.x!
      updated.height = pos.y - last.y!
    }

    setAnnotations((prev) => [...prev.slice(0, lastIndex), updated])
  }

  function handleMouseUp() {
    setIsDrawing(false)
  }

  return (
    <Stage
      ref={stageRef}
      width={800}
      height={600}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
    >
      <Layer>
        {annotations.map((ann) => {
          if (ann.type === 'rect') {
            return (
              <Rect
                key={ann.id}
                x={ann.x}
                y={ann.y}
                width={ann.width}
                height={ann.height}
                stroke={ann.color}
                strokeWidth={2}
                fill="transparent"
                onClick={() => setSelectedId(ann.id)}
                draggable={selectedId === ann.id}
              />
            )
          }
          if (ann.type === 'line') {
            return (
              <Line
                key={ann.id}
                points={ann.points}
                stroke={ann.color}
                strokeWidth={2}
                lineCap="round"
              />
            )
          }
          return null
        })}
      </Layer>
    </Stage>
  )
}

Undo/Redo через стек состояний

function useUndoRedo<T>(initialState: T) {
  const [history, setHistory] = useState<T[]>([initialState])
  const [cursor, setCursor] = useState(0)

  const current = history[cursor]

  function push(newState: T) {
    // Обрезаем историю после текущей позиции (ветвление)
    const newHistory = [...history.slice(0, cursor + 1), newState]
    setHistory(newHistory)
    setCursor(newHistory.length - 1)
  }

  function undo() {
    if (cursor > 0) setCursor((c) => c - 1)
  }

  function redo() {
    if (cursor < history.length - 1) setCursor((c) => c + 1)
  }

  return { current, push, undo, redo, canUndo: cursor > 0, canRedo: cursor < history.length - 1 }
}

Экспорт аннотированного изображения

function exportAnnotated(stage: Konva.Stage, bgImage: HTMLImageElement) {
  const canvas = document.createElement('canvas')
  canvas.width = stage.width()
  canvas.height = stage.height()
  const ctx = canvas.getContext('2d')!

  // Сначала фон
  ctx.drawImage(bgImage, 0, 0)

  // Поверх — аннотации из Konva
  const stageCanvas = stage.toCanvas()
  ctx.drawImage(stageCanvas, 0, 0)

  canvas.toBlob((blob) => {
    const url = URL.createObjectURL(blob!)
    const a = document.createElement('a')
    a.href = url
    a.download = 'annotated.png'
    a.click()
    URL.revokeObjectURL(url)
  })
}

Что делаем

Анализируем сценарий: свободное рисование, аннотирование документов, схематические диаграммы. Выбираем подход (immediate/retained), реализуем инструменты (кисть, фигуры, текст, стрелки), undo/redo, экспорт. При необходимости — синхронизацию через WebSocket для совместного рисования.

Срок: базовый canvas с кистью и фигурами — 2–3 дня. Аннотирование PDF с сохранением — 5–7 дней.