Electron
Build a desktop image and design editor with Electron and Polotno. Run the editor in the renderer, save designs to disk, and export PNG or print-ready PDF.
How to add an image editor to an Electron app?
Electron's renderer is Chromium, so Polotno runs there the same way it runs in a browser. Your users get the full design editor with text, images, templates, layers and multi-page documents. The main process handles what a web page can't: native menus, file dialogs and writing to disk.
Unlike the iOS and Android setups, there is no WebView bridge to build. The renderer imports Polotno directly.
Architecture overview
- Renderer: a React app that runs the Polotno editor.
- Preload script: exposes a small, typed API to the renderer through
contextBridge. - Main process: owns the File menu, opens dialogs, and reads and writes designs and exports on disk.
1) Create the app
Any Electron + React setup works. With Electron Forge and Vite:
npm init electron-app@latest my-editor -- --template=vite
cd my-editor
npm install polotno react react-dom
npm install -D @vitejs/plugin-reactAdd @vitejs/plugin-react to the plugins in vite.renderer.config.mjs so the renderer can use JSX.
2) Render the editor
// src/editor.jsx
import { PolotnoContainer, SidePanelWrap, WorkspaceWrap } from 'polotno';
import { Toolbar } from 'polotno/toolbar/toolbar';
import { ZoomButtons } from 'polotno/toolbar/zoom-buttons';
import { SidePanel } from 'polotno/side-panel';
import { Workspace } from 'polotno/canvas/workspace';
import { createStore } from 'polotno/model/store';
import 'polotno/ui.css';
export const store = createStore({
key: import.meta.env.VITE_POLOTNO_KEY,
showCredit: true,
});
store.addPage();
export function Editor() {
return (
<PolotnoContainer style={{ width: '100vw', height: '100vh' }}>
<SidePanelWrap>
<SidePanel store={store} />
</SidePanelWrap>
<WorkspaceWrap>
<Toolbar store={store} />
<Workspace store={store} />
<ZoomButtons store={store} />
</WorkspaceWrap>
</PolotnoContainer>
);
}Mount it from the renderer entry, and point index.html at that entry with a <div id="root"></div>:
// src/renderer.jsx
import { createRoot } from 'react-dom/client';
import { Editor } from './editor';
createRoot(document.getElementById('root')).render(<Editor />);Set VITE_POLOTNO_KEY in .env.local to your key from the Polotno cabinet. In the cabinet, add two entries to the key's allowed domains:
localhost, becausenpm startserves the renderer from the Vite dev serverelectron, because the packaged app loads the renderer fromfile://and Polotno reports that origin aselectron
Without them, the editor shows a license warning and the photo and template panels stay empty.
3) Save files through the main process
The renderer can't touch the disk, so it hands designs and exports to the main process. The preload script exposes a small API with contextBridge:
// src/preload.js
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('files', {
openDesign: () => ipcRenderer.invoke('files:open-design'),
saveDesign: (json) => ipcRenderer.invoke('files:save-design', json),
saveExport: (name, dataUrl) => ipcRenderer.invoke('files:save-export', name, dataUrl),
onMenuCommand: (callback) =>
ipcRenderer.on('menu-command', (_event, command) => callback(command)),
});The main process shows the dialogs and writes the files:
// src/main.js
import { dialog, ipcMain } from 'electron';
import fs from 'node:fs/promises';
const designFilters = [{ name: 'Polotno design', extensions: ['json'] }];
ipcMain.handle('files:open-design', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: designFilters,
});
if (canceled) return null;
return fs.readFile(filePaths[0], 'utf8');
});
ipcMain.handle('files:save-design', async (_event, json) => {
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: 'design.json',
filters: designFilters,
});
if (canceled) return null;
await fs.writeFile(filePath, json, 'utf8');
return filePath;
});
// Exports arrive as base64 data URLs.
ipcMain.handle('files:save-export', async (_event, name, dataUrl) => {
const { canceled, filePath } = await dialog.showSaveDialog({ defaultPath: name });
if (canceled) return null;
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
await fs.writeFile(filePath, Buffer.from(base64, 'base64'));
return filePath;
});Keep contextIsolation on and nodeIntegration off, which are Electron's defaults. The renderer only gets the functions the preload script exposes.
4) Add a File menu
Menu items send a command to the window they were clicked in:
// src/main.js
import { Menu } from 'electron';
const sendCommand = (command) => (_item, window) =>
window?.webContents.send('menu-command', command);
Menu.setApplicationMenu(
Menu.buildFromTemplate([
...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []),
{
label: 'File',
submenu: [
{ label: 'Open…', accelerator: 'CmdOrCtrl+O', click: sendCommand('open') },
{ label: 'Save…', accelerator: 'CmdOrCtrl+S', click: sendCommand('save') },
{ type: 'separator' },
{ label: 'Export PNG…', click: sendCommand('export-png') },
{ label: 'Export PDF…', click: sendCommand('export-pdf') },
],
},
{ role: 'editMenu' },
{ role: 'viewMenu' },
]),
);Call it inside app.whenReady(). The renderer runs each command against the store:
// src/renderer.jsx
import { store } from './editor';
const commands = {
open: async () => {
const json = await window.files.openDesign();
if (json) store.loadJSON(JSON.parse(json));
},
save: () => window.files.saveDesign(JSON.stringify(store.toJSON())),
'export-png': async () =>
window.files.saveExport('design.png', await store.toDataURL({ pixelRatio: 2 })),
'export-pdf': async () =>
window.files.saveExport('design.pdf', await store.toPDFDataURL()),
};
window.files.onMenuCommand(async (command) => {
try {
await commands[command]?.();
} catch (error) {
console.error(`${command} failed`, error);
}
});Core concepts
Loading and saving designs
store.toJSON() returns the whole design as plain JSON, and store.loadJSON(json) opens it again. That JSON is your document format: save it as a file, sync it to your backend, or both. See the Store API.
Print-ready PDF
store.toPDFDataURL() exports every page to one PDF. Polotno supports bleed, crop marks and print sizes in physical units. See PDF export for print options.
FAQ
Which domain do I add to the API key?
Add localhost for development and electron for the packaged app. Polotno reports a file:// page as electron, and its console message names the domain to add if one is missing.
Can the editor work offline?
The editor code ships inside your app, but Polotno loads stock photos, templates, fonts and the PDF export library from the network. The computer must be online for these features.
Enterprise clients: Full offline support (bundling all assets and fonts) is available with enterprise licenses. Contact support for details.
Can I edit PDFs in Electron?
Polotno exports designs to PDF. To open an existing PDF as an editable design, use the @polotno/pdf-import package, currently in beta. See PDF import.
How do I customize the editor UI?
Edit src/editor.jsx. That is where you add side panels, change the default sections or restyle the toolbar. See Customizations.
Demo repository
See a complete working example with source code: https://github.com/polotno-project/polotno-electron
The repository is an Electron Forge + Vite app with the editor in the renderer, a native File menu that opens and saves .json designs, and PNG and PDF export written by the main process.