Development of Online Graphic Editor (Canva-like)
An online editor with canvas work area, movable/scalable objects, layers and image export is one of the technically complex frontend tasks. Choosing the right rendering engine determines 80% of the architecture.
Engine Selection
| Engine | When to use |
|---|---|
| Fabric.js | Ready solution for canvas editor, large ecosystem, but outdated API |
| Konva.js | React-friendly (react-konva), good performance, active support |
| Pixi.js | High-performance WebGL rendering, for complex effects |
| SVG (custom) | For simple editors with few objects, easy CSS styling |
| tldraw | Open-source, whiteboard-like, React, suitable for diagrams |
For Canva-like editor with text, shapes, images and export — Konva.js is optimal.
State Architecture
Central structure — tree of objects (elements), rendered in canvas. State managed via Zustand or 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; // for image
}
interface EditorState {
elements: EditorElement[];
selectedIds: string[];
canvasWidth: number;
canvasHeight: number;
zoom: number;
history: EditorElement[][];
historyIndex: number;
}
Rendering via 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
History — snapshots of elements array:
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,
});
};
Hotkeys via useHotkeys:
useHotkeys('ctrl+z', undo);
useHotkeys('ctrl+shift+z, ctrl+y', redo);
useHotkeys('ctrl+d', duplicateSelected);
useHotkeys('delete, backspace', deleteSelected);
useHotkeys('ctrl+a', selectAll);
Image Loading and Processing
Upload to object storage (S3) with resize via 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}`;
};
Client-side — drag-and-drop zone and paste from clipboard:
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);
}
}
});
Text Editor
Built-in text editing via double-click:
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}
/>
);
};
Export
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');
}
};
Timeline
| Stage | Time |
|---|---|
| Basic canvas (shapes, selection, transform) | 3–4 days |
| Text + images | 2–3 days |
| Undo/Redo + hotkeys | 1–2 days |
| Layers (order, visibility, lock) | 1–2 days |
| Export (PNG, JPEG, SVG) | 1–2 days |
| Save / templates / autosave | 2 days |
| Text styles, alignment, line-height | 2 days |
Minimum working editor: 10–14 working days.







