Konva.js: интерактивная графика Canvas — примеры и редактор
Konva.js на практике: бесплатная мастерская без регистрации с семью примерами интерактивной графики. Перемещение и трансформации фигур, схема с соединениями, рисование, размытие, анимация, постер и сохранение PNG/JSON. Исходники проекта для скачивания.
Перемещаемые блоки схемы, редактор постера, заметки поверх изображения или простая доска для рисования — у этих задач общая основа: объекты на холсте должны реагировать на действия человека. Konva.js помогает работать с такими объектами через JavaScript: создавать фигуры, объединять их, выделять, перемещать и сохранять результат.
Ниже — бесплатная мастерская без регистрации с семью примерами. Выберите заготовку, поменяйте подписи и цвета, попробуйте перемещение, трансформации, рисование и размытие. Результат можно скачать в PNG, а проект — сохранить в JSON, чтобы продолжить работу позже.
Попробуйте Konva.js на практике
Выберите пример, перемещайте объекты и меняйте их параметры. Сохраните результат в PNG, заберите код или скачайте учебный проект.
Загрузка мастерской Konva.js…
Canvas-библиотека — Konva.js. Примеры и площадка — ae-nekrasov.ru. Графика обрабатывается в браузере.
Мастерская использует настоящий Konva 10.7.1. Выбор объектов в списке, поля координат, кнопки сдвига, история изменений и сохранение проекта подготовлены нашей оболочкой. Это учебный редактор: он показывает отдельные приёмы, из которых можно собрать собственный инструмент. Настройки сцены обрабатываются в браузере.
Где полезна Konva и что она берёт на себя
В обычном Canvas вы рисуете пиксели; для повторного изменения композиции нужно хранить её данные и перерисовывать холст. Konva предоставляет дерево объектов: Stage содержит сцену, Layer — слой, а внутри располагаются фигуры и группы. У каждого объекта есть свойства и события. Устройство этой структуры описано в официальном обзоре.
Библиотека подходит для редакторов и диаграмм, но готовую предметную логику нужно написать: например, какие блоки разрешено соединять, что означает размер фигуры и какие данные сохраняются. Существует интеграция с React, Vue и другими фреймворками; здесь используется обычный JavaScript. Способы подключения приведены в инструкции для начала работы, а лицензия MIT — в репозитории проекта.
Текст на холсте не заменяет обычную HTML-страницу. Название инструмента, инструкцию, поля и кнопки стоит оставить в DOM. Это помогает пользоваться редактором с клавиатуры и читать его назначение без взаимодействия с графикой. В нашей мастерской объекты можно выбрать в списке и изменить через поля.
Подключение и общая основа примеров
Положите UMD-файл konva.min.js версии 10.7.1 в папку assets. Его можно получить из пакета konva после установки npm install konva@10.7.1. Для простой страницы сборщик не обязателен. Создайте три собственных файла: site.css, common.js и example.js.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Мастерская Canvas</title>
<meta name="description" content="Учебные примеры интерактивной графики.">
<link rel="stylesheet" href="assets/site.css">
<script src="assets/konva.min.js" defer></script>
<script src="assets/common.js" defer></script>
<script src="assets/example.js" defer></script>
</head>
<body>
<h1>Мастерская Canvas</h1>
<div id="demo-controls"></div>
<div id="canvas-host" role="img" aria-label="Учебная графическая сцена"></div>
<p id="demo-status" role="status">Готово к работе.</p>
</body>
</html>
body { margin: 0; padding: 20px; font: 16px/1.5 system-ui, sans-serif; }
#canvas-host { width: 100%; max-width: 800px; background: #eef5f3; }
#demo-controls { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 12px; }
button, input { font: inherit; min-height: 44px; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible { outline: 3px solid #2369a8; }
В common.js создадим сцену с логическими координатами 800 × 480. Её масштаб зависит от ширины контейнера; размеры самих объектов остаются прежними. Такой принцип показан в примере адаптивного Canvas. Это уменьшение всей сцены, а не перестановка элементов, как в адаптивной HTML-вёрстке.
function createScene() {
const host = document.getElementById('canvas-host');
const stage = new Konva.Stage({ container: host, width: 800, height: 480 });
const layer = new Konva.Layer();
stage.add(layer);
const fit = () => {
const scale = Math.max(1, Math.min(host.clientWidth, 800)) / 800;
stage.size({ width: 800 * scale, height: 480 * scale });
stage.scale({ x: scale, y: scale });
};
const observer = new ResizeObserver(fit);
observer.observe(host);
fit();
const dispose = () => { observer.disconnect(); stage.destroy(); };
return { stage, layer, dispose };
}
function addButton(label, action) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.addEventListener('click', action);
document.getElementById('demo-controls').append(button);
return button;
}
Далее выбирайте один пример и вставляйте его в example.js; эти фрагменты не нужно складывать подряд. В одностраничном приложении вызывайте dispose() при удалении редактора. Полный проект мастерской доступен для скачивания выше; короткие фрагменты объясняют отдельные приёмы.
Пример 1. Фигура, которую можно переместить
Свойство draggable включает перетаскивание. Событие dragend удобно для фиксации результата: координаты можно записать в модель или показать рядом с холстом. Это базовый приём из документации по перемещению.
const { stage, layer } = createScene();
const rect = new Konva.Rect({
x: 80, y: 90, width: 220, height: 130,
fill: '#177d78', cornerRadius: 16, draggable: true
});
layer.add(rect);
const status = document.getElementById('demo-status');
function report() {
status.textContent = `Положение: X ${Math.round(rect.x())}, Y ${Math.round(rect.y())}.`;
}
rect.on('dragend', report);
addButton('Вправо на 10', () => { rect.x(rect.x() + 10); report(); });
addButton('Влево на 10', () => { rect.x(rect.x() - 10); report(); });
Кнопки выполняют то же перемещение без мыши. Для рабочего редактора добавьте остальные направления и ограничения области. Если объекты не должны выходить за пределы документа, проверяйте их положение после перемещения или задайте функцию ограничения.
Пример 2. Изменение размера и поворот
Transformer рисует рамку с управляющими точками. Он меняет scaleX и scaleY, а не исходные ширину и высоту: это отдельно отмечено в официальном примере трансформации. Для прямоугольника перенесём масштаб в размеры после завершения действия.
const { stage, layer } = createScene();
const rect = new Konva.Rect({
x: 140, y: 120, width: 220, height: 140, fill: '#2369a8', draggable: true
});
const transformer = new Konva.Transformer({
flipEnabled: false, keepRatio: false,
boundBoxFunc: (oldBox, nextBox) =>
nextBox.width < 40 || nextBox.height < 40 ? oldBox : nextBox
});
layer.add(rect, transformer);
transformer.nodes([rect]);
rect.on('transformend', () => {
rect.size({
width: Math.max(40, rect.width() * rect.scaleX()),
height: Math.max(40, rect.height() * rect.scaleY())
});
rect.scale({ x: 1, y: 1 });
transformer.forceUpdate();
});
Запрет переворота и минимальный размер здесь намеренные. Для текста, круга или группы потребуется другая модель преобразования; не переносите обработчик прямоугольника на все типы объектов. Поворот также сохраните в данных, если композиция должна восстанавливаться.
Пример 3. Схема с соединёнными блоками
Сгруппируем фон и подпись, чтобы они перемещались вместе. Стрелка хранит точки отдельно, поэтому обновим их при изменении положения блока. Это упрощённая схема: соединение проходит между центрами. Для прикладного редактора можно добавить порты и обход препятствий; пример связанных объектов есть в документации Konva.
const { layer } = createScene();
function makeNode(x, y, text) {
const group = new Konva.Group({ x, y, draggable: true });
group.add(new Konva.Rect({ width: 180, height: 80, fill: '#177d78', cornerRadius: 12 }));
group.add(new Konva.Text({
text, width: 180, height: 80, align: 'center', verticalAlign: 'middle',
fill: '#ffffff', fontSize: 22, listening: false
}));
return group;
}
const a = makeNode(70, 120, 'Заявка');
const b = makeNode(480, 260, 'Результат');
const arrow = new Konva.Arrow({
stroke: '#2369a8', fill: '#2369a8', strokeWidth: 4,
pointerLength: 12, pointerWidth: 12, listening: false
});
layer.add(arrow, a, b);
function connect() { arrow.points([a.x() + 90, a.y() + 40, b.x() + 90, b.y() + 40]); }
a.on('dragmove xChange yChange', connect);
b.on('dragmove xChange yChange', connect);
connect();
Подпись не перехватывает события, а стрелка находится позади блоков. В данных приложения храните идентификаторы начала и конца связи; координаты линии лучше вычислять по текущему положению узлов, чтобы они не рассинхронизировались.
Пример 4. Рисование на уменьшенной сцене
На телефоне сцена масштабируется. Координаты указателя нужно получить относительно слоя, иначе линия окажется не под пальцем. Метод getRelativePointerPosition() учитывает преобразования; его применение разобрано в примере относительных координат. Сам штрих — обычная Line с последовательностью точек.
const { stage, layer } = createScene();
let line = null;
stage.container().style.touchAction = 'none';
stage.on('pointerdown', () => {
const p = layer.getRelativePointerPosition();
if (!p) return;
line = new Konva.Line({
points: [p.x, p.y, p.x, p.y], stroke: '#177d78', strokeWidth: 6,
lineCap: 'round', lineJoin: 'round', listening: false
});
layer.add(line);
});
stage.on('pointermove', () => {
if (!line) return;
const p = layer.getRelativePointerPosition();
if (p) line.points([...line.points(), p.x, p.y]);
});
stage.on('pointerup pointercancel pointerleave', () => { line = null; });
addButton('Очистить рисунок', () => { line = null; layer.destroyChildren(); });
Отключайте жест прокрутки только в области рисования. Оставьте остальную страницу доступной для обычного скролла. Длинные штрихи содержат много точек: в большом редакторе полезны ограничение их числа и упрощение траектории. Другие подходы показаны в учебной доске для рисования.
Пример 5. Кэширование и размытие
Для пиксельного фильтра Blur сначала создаётся кэш изображения, затем задаются фильтр и радиус. Такой порядок описан в руководстве по размытию. Подготовьте собственный небольшой файл assets/sample.png и запускайте страницу через локальный сервер.
const { layer } = createScene();
const image = new Image();
image.onload = () => {
const picture = new Konva.Image({
image, x: 100, y: 80, width: 600, height: 320
});
layer.add(picture);
picture.cache();
picture.filters([Konva.Filters.Blur]);
picture.blurRadius(8);
addButton('Без размытия', () => picture.blurRadius(0));
addButton('Размытие 12', () => picture.blurRadius(12));
};
image.onerror = () => {
document.getElementById('demo-status').textContent = 'Не удалось открыть assets/sample.png.';
};
image.src = 'assets/sample.png';
Изображение с чужого домена может сделать холст недоступным для чтения пикселей и экспорта. Одного crossOrigin = 'anonymous' недостаточно: сервер изображения должен разрешить CORS. Эта граница объяснена в разборе tainted canvas. Для мастерской используется наша локальная иллюстрация.
Пример 6. Анимация с ручным запуском
Скорость движения должна зависеть от прошедшего времени, а не количества кадров. timeDiff измеряется в миллисекундах; методы start() и stop() управляют циклом. Эти параметры определены в API Animation. Здесь движение включается только кнопкой.
const { stage, layer, dispose } = createScene();
const ball = new Konva.Circle({ x: 60, y: 240, radius: 32, fill: '#177d78' });
layer.add(ball);
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const animation = new Konva.Animation((frame) => {
if (!frame) return;
const step = 120 * Math.min(frame.timeDiff, 50) / 1000;
ball.x(40 + ((ball.x() - 40 + step) % 720));
}, layer);
const start = addButton('Пуск', () => {
if (!reduced.matches) animation.start();
});
addButton('Пауза', () => animation.stop());
function syncMotion() {
start.disabled = reduced.matches;
if (reduced.matches) animation.stop();
}
syncMotion();
reduced.addEventListener('change', syncMotion);
const visible = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) animation.stop();
});
visible.observe(stage.container());
function syncVisibility() {
if (document.hidden) animation.stop();
}
document.addEventListener('visibilitychange', syncVisibility);
window.addEventListener('pagehide', () => {
animation.stop(); visible.disconnect();
reduced.removeEventListener('change', syncMotion);
document.removeEventListener('visibilitychange', syncVisibility);
dispose();
}, { once: true });
Анимация останавливается вне видимой области и при уходе на другую вкладку. Возврат на страницу сам по себе не запускает движение снова. Для удаления редактора внутри приложения также снимайте собственные обработчики документа, а не только уничтожайте сцену.
Пример 7. Постер: картинка для публикации и данные для продолжения
У этих двух сохранений разные задачи. PNG фиксирует внешний вид. JSON хранит редактируемые данные: например, текст, координаты и цвет. В полном редакторе лучше сохранять собственную модель приложения; stage.toJSON() не переносит обработчики и ресурсы изображения. Это рассмотрено в рекомендациях по сохранению состояния.
const { stage, layer } = createScene();
const background = new Konva.Rect({ width: 800, height: 480, fill: '#e6f2ed', listening: false });
let model = { version: 1, text: 'Ваш следующий проект', x: 80, y: 170, color: '#177d78' };
const title = new Konva.Text({ width: 640, fontSize: 44, draggable: true });
const transformer = new Konva.Transformer({ resizeEnabled: false, rotateEnabled: false });
layer.add(background, title, transformer);
function render() {
title.setAttrs({ text: model.text, x: model.x, y: model.y, fill: model.color });
transformer.nodes([title]);
}
title.on('dragend', () => {
model.x = Math.max(0, Math.min(title.x(), 640));
model.y = Math.max(0, Math.min(title.y(), 400));
render();
});
render();
function download(href, name) {
let url = href;
let ownsUrl = false;
if (href.startsWith('data:image/png;base64,')) {
const binary = atob(href.slice(href.indexOf(',') + 1));
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
url = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
ownsUrl = true;
}
const link = document.createElement('a');
link.href = url; link.download = name;
document.body.append(link); link.click(); link.remove();
if (ownsUrl) setTimeout(() => URL.revokeObjectURL(url), 1000);
}
addButton('Скачать PNG', () => {
const wasVisible = transformer.visible();
const scale = stage.scaleX();
transformer.hide();
try {
stage.scale({ x: 1, y: 1 });
stage.size({ width: 800, height: 480 });
download(stage.toDataURL({ pixelRatio: 2 }), 'poster.png');
} finally {
stage.scale({ x: scale, y: scale });
stage.size({ width: 800 * scale, height: 480 * scale });
transformer.visible(wasVisible);
layer.draw();
}
});
addButton('Сохранить JSON', () => {
const blob = new Blob([JSON.stringify(model, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
download(url, 'poster.json');
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
const input = document.createElement('input');
input.type = 'file'; input.accept = '.json,application/json';
input.setAttribute('aria-label', 'Открыть проект постера JSON');
document.getElementById('demo-controls').append(input);
input.addEventListener('change', async () => {
try {
const file = input.files[0];
if (!file) return;
if (file.size > 100000) throw new Error('Слишком большой файл');
const data = JSON.parse(await file.text());
if (data.version !== 1 || typeof data.text !== 'string' || data.text.length > 200 ||
!Number.isFinite(data.x) || !Number.isFinite(data.y) ||
data.x < 0 || data.x > 640 || data.y < 0 || data.y > 400 ||
!/^#[0-9a-f]{6}$/i.test(data.color)) throw new Error('Неизвестный формат');
model = { version: 1, text: data.text, x: data.x, y: data.y, color: data.color };
render();
document.getElementById('demo-status').textContent = 'Проект открыт.';
} catch {
document.getElementById('demo-status').textContent = 'Не удалось открыть проект постера.';
} finally { input.value = ''; }
});
Экспорт выполняется в логическом размере, даже если на телефоне сцена уменьшена. Рамка выделения временно скрывается и возвращается после сохранения. При pixelRatio: 2 документ 800 × 480 превращается в PNG 1600 × 960. Увеличение разрешения не добавляет деталей исходной фотографии; большие холсты расходуют больше памяти. Параметр и ограничения описаны в руководстве по экспорту изображения.
В этом коротком примере восстановление обновляет уже созданный текст. При восстановлении нескольких объектов создавайте их по разрешённым типам и заново подключайте обработчики. Историю отмены тоже храните в модели: снимок до изменения, снимок после, затем повторная отрисовка.
Как превратить пример в удобный инструмент
Проверяйте композицию на реальном узком экране: уменьшенный холст может выглядеть аккуратно, но управляющие точки станут слишком маленькими. Оставьте поля размера и координат, понятные кнопки сдвига и список объектов. Сообщайте об успешном сохранении и ошибках чтения файла рядом с соответствующим действием.
Не обещайте одинаковую скорость для любого числа объектов. На неё влияют размеры холста, фильтры, тени и устройство посетителя. Начните с измерения собственной сцены; у неподвижного фона можно отключить обработку событий через listening: false. Кэш нужен по задаче, а не каждому объекту подряд. Более подробные приёмы собраны в рекомендациях по производительности.
В скачиваемом проекте есть исходники мастерской, закреплённые зависимости и инструкция запуска. Установите Node.js 24, выполните npm ci, затем npm run dev. Команда npm run build готовит папку dist: публикуйте её вместе со всеми ресурсами. В статье разобраны основы; в проекте можно изучить связку выбора, настроек, истории и экспорта.
Для оформления панели пригодятся примеры компонентов Bulma. Если нужна обычная страница с разделами и карточками, посмотрите конструктор страниц на Bootstrap 5. Выбирайте инструмент под конкретную задачу посетителя: графика должна помогать действию, а не усложнять его.

