Реалізація малювання/анотування (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>, обробка подій покажчика та серіалізація стану.

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

Immediate mode (чистий Canvas 2D) — рисуємо пікселі безпосередньо. Швидко, без накладних витрат. Складно: немає об'єктної моделі, потрібно писати hit-testing вручну.

Retained mode (Fabric.js, Konva.js) — об'єктна модель над canvas. Кожна фігура — це об'єкт, який можна виділити, перемістити, змінити. Більше витрат пам'яті, але набагато зручніше для редакторів.

Для анотування з виділенням і редагуванням об'єктів — використовуйте retained mode (Konva або Fabric). Для вільного рисування кистками — immediate mode з чутливістю до тиску.

Вільне рисування (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'

      // Квадратична крива Безьє для гладких ліній
      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>
  )
}

Скасування/Повтор через стек стану

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), реалізуємо інструменти (кисть, фігури, текст, стрілки), скасування/повтор, експорт. За необхідності додаємо синхронізацію через WebSocket для спільного рисування.

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