Разработка онлайн-графического редактора (Canva-подобный)
Онлайн-редактор с canvas-рабочей областью, перемещаемыми/масштабируемыми объектами, слоями и экспортом изображений — одна из технически сложных задач во фронтенд-разработке. Правильный выбор рендеринг-движка определяет 80% архитектуры.
Выбор движка
| Движок | Когда использовать |
|---|---|
| Fabric.js | Готовое решение для canvas-редактора, большая экосистема, но устаревшее API |
| Konva.js | React-friendly (react-konva), хорошая производительность, активная поддержка |
| Pixi.js | Высокопроизводительный WebGL рендеринг, для сложных эффектов |
| SVG (custom) | Для простых редакторов с небольшим числом объектов, легко стилизовать CSS |
| tldraw | Open-source, whiteboard-like, React, подходит для диаграмм |
Для Canva-подобного редактора с текстом, фигурами, изображениями и экспортом — Konva.js оптимален.
Архитектура состояния
Центральная структура — дерево объектов (elements), которое рендерится в canvas. Состояние управляется через Zustand или Redux:
interface EditorElement {
id: string;
type: 'rect' | 'circle' | 'text' | 'image' | 'group';
x: number;
y: number;
width: number;
height: number;
rotation: number;
opacity: number;
zIndex: number;
locked: boolean;
visible: boolean;
// type-specific
fill?: string;
stroke?: string;
strokeWidth?: number;
text?: string;
fontSize?: number;
fontFamily?: string;
src?: string; // для image
}
interface EditorState {
elements: EditorElement[];
selectedIds: string[];
canvasWidth: number;
canvasHeight: number;
zoom: number;
history: EditorElement[][];
historyIndex: number;
}
Рендеринг через react-konva
import { Stage, Layer, Rect, Circle, Text, Image, Transformer } from 'react-konva';
const Canvas: React.FC = () => {
const { elements, selectedIds, zoom } = useEditorStore();
const trRef = useRef<Konva.Transformer>(null);
const selectedNodes = useRef<Konva.Node[]>([]);
// Обновляем трансформер при смене выделения
useEffect(() => {
if (trRef.current) {
trRef.current.nodes(selectedNodes.current);
trRef.current.getLayer()?.batchDraw();
}
}, [selectedIds]);
return (
<Stage
width={canvasWidth * zoom}
height={canvasHeight * zoom}
scaleX={zoom}
scaleY={zoom}
onMouseDown={handleStageClick}
>
<Layer>
{elements
.filter(el => el.visible)
.sort((a, b) => a.zIndex - b.zIndex)
.map(el => (
<EditorElement
key={el.id}
element={el}
isSelected={selectedIds.includes(el.id)}
ref={node => {
if (selectedIds.includes(el.id) && node) {
selectedNodes.current.push(node);
}
}}
/>
))}
<Transformer
ref={trRef}
rotateEnabled={true}
keepRatio={false}
boundBoxFunc={(oldBox, newBox) => newBox.width < 5 || newBox.height < 5 ? oldBox : newBox}
/>
</Layer>
</Stage>
);
};
Undo / Redo
History — снапшоты массива elements:
const commit = () => {
const { elements, history, historyIndex } = store;
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(elements))); // deep clone
store.setState({
history: newHistory.slice(-50), // храним последние 50 состояний
historyIndex: newHistory.length - 1,
});
};
const undo = () => {
const { history, historyIndex } = store;
if (historyIndex <= 0) return;
store.setState({
elements: JSON.parse(JSON.stringify(history[historyIndex - 1])),
historyIndex: historyIndex - 1,
});
};
Горячие клавиши через useHotkeys:
useHotkeys('ctrl+z', undo);
useHotkeys('ctrl+shift+z, ctrl+y', redo);
useHotkeys('ctrl+d', duplicateSelected);
useHotkeys('delete, backspace', deleteSelected);
useHotkeys('ctrl+a', selectAll);
Загрузка и обработка изображений
Загрузка в объектное хранилище (S3) с ресайзом через Sharp:
// Backend: POST /api/editor/upload
const processUpload = async (file: File): Promise<string> => {
const buffer = await file.arrayBuffer();
const resized = await sharp(Buffer.from(buffer))
.resize(2000, 2000, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer();
const key = `editor-uploads/${uuid()}.jpg`;
await s3.upload({ Bucket: BUCKET, Key: key, Body: resized }).promise();
return `${CDN_URL}/${key}`;
};
На клиенте — drag-and-drop зона и паста из буфера:
document.addEventListener('paste', (e) => {
const items = e.clipboardData?.items;
for (const item of items ?? []) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) addImageElement(file);
}
}
});
Текстовый редактор
Встроенное редактирование текста через двойной клик:
const EditableText: React.FC<TextElementProps> = ({ element, onUpdate }) => {
const [isEditing, setIsEditing] = useState(false);
const handleDblClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
setIsEditing(true);
// Показываем textarea поверх canvas
const pos = e.target.getAbsolutePosition();
showTextArea({ x: pos.x, y: pos.y, element, onSave: (text) => {
onUpdate({ ...element, text });
setIsEditing(false);
}});
};
return (
<Text
{...element}
onDblClick={handleDblClick}
visible={!isEditing}
/>
);
};
Экспорт
const exportCanvas = async (format: 'png' | 'jpeg' | 'svg', quality = 1) => {
const stage = stageRef.current;
if (format === 'png' || format === 'jpeg') {
const dataUrl = stage.toDataURL({
mimeType: `image/${format}`,
quality,
pixelRatio: 2, // Ретина
});
downloadFile(dataUrl, `design.${format}`);
}
if (format === 'svg') {
// Konva не экспортирует SVG нативно — используем fabric или ручную сериализацию
const svg = serializeToSVG(elements);
const blob = new Blob([svg], { type: 'image/svg+xml' });
downloadFile(URL.createObjectURL(blob), 'design.svg');
}
};
Шаблоны и сохранение
Дизайн сериализуется в JSON и сохраняется на сервере:
// Модель Design
Schema::create('designs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('title');
$table->jsonb('data'); // весь массив elements
$table->string('thumbnail')->nullable(); // base64 preview
$table->timestamps();
});
Превью генерируется через stage.toDataURL({ pixelRatio: 0.2 }) при каждом автосохранении.
Сроки
| Этап | Время |
|---|---|
| Базовый canvas (фигуры, выделение, трансформация) | 3–4 дня |
| Текст + изображения | 2–3 дня |
| Undo/Redo + горячие клавиши | 1–2 дня |
| Слои (порядок, видимость, блокировка) | 1–2 дня |
| Экспорт (PNG, JPEG, SVG) | 1–2 дня |
| Сохранение / шаблоны / автосохранение | 2 дня |
| Текстовые стили, выравнивание, межстрочный интервал | 2 дня |
Минимальный рабочий редактор: 10–14 рабочих дней.







