Розробка онлайн-графічного редактора (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;
fill?: string;
stroke?: string;
strokeWidth?: number;
text?: string;
fontSize?: number;
fontFamily?: string;
src?: string; // для зображення
}
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)}
/>
))}
<Transformer ref={trRef} rotateEnabled={true} keepRatio={false} />
</Layer>
</Stage>
);
};
Undo / Redo
Історія — снапшоти масиву elements:
const commit = () => {
const { elements, history, historyIndex } = store;
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(elements)));
store.setState({
history: newHistory.slice(-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);
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') {
const svg = serializeToSVG(elements);
const blob = new Blob([svg], { type: 'image/svg+xml' });
downloadFile(URL.createObjectURL(blob), 'design.svg');
}
};
Термін
| Етап | Час |
|---|---|
| Базовий canvas (фігури, виділення, трансформація) | 3–4 дні |
| Текст + зображення | 2–3 дні |
| Undo/Redo + гарячі клавіші | 1–2 дні |
| Шари (порядок, видимість, блокування) | 1–2 дні |
| Експорт (PNG, JPEG, SVG) | 1–2 дні |
| Збереження / шаблони / автозбереження | 2 дні |
| Текстові стилі, вирівнювання, міжстрочний інтервал | 2 дні |
Мінімальний робочий редактор: 10–14 робочих днів.







