Polotno
Getting started

Flutter

Add a full image and design editor to a Flutter app. Embed Polotno in webview_flutter, load and save designs, and export the canvas to PNG or PDF.

How to add an image editor to a Flutter app?

Polotno is a React-based web editor. In a Flutter app you run it inside webview_flutter: your users get the full design editor with text, images, templates, layers and multi-page documents, and your Dart code decides where designs and exports go.

The same setup works on iOS and Android.

Flutter canvas vs a full design editor

Flutter's Canvas and CustomPainter give you a drawing surface. You still build selection, transform handles, text editing, undo, layers, fonts, templates and export yourself. Image editor packages cover crop, rotate and filters on a single photo.

If your users need to edit designs, such as social posts, flyers, cards or print products, Polotno is that editor already built. You embed it and talk to it through a JavaScript channel.

Architecture overview

  • Native layer: a Flutter screen that hosts a WebViewWidget and owns storage, sharing and navigation.
  • Web layer: a React + Vite app that runs the Polotno editor, built into one HTML file that ships as a Flutter asset.

Dart calls the editor with runJavaScript(). The editor answers through a JavaScript channel that Dart registers with addJavaScriptChannel().

Prerequisites

  • Flutter 3.47 with webview_flutter 4.14 (the versions the demo is tested with)
  • Node.js 20+ (for building the web editor)
  • A Polotno API key from the Polotno cabinet

1) Build the web editor

Create a React + Vite app for the editor inside your Flutter project and install Polotno and the single-file plugin:

npm create vite@latest web-editor -- --template react
cd web-editor
npm install polotno
npm install -D vite-plugin-singlefile

Add viteSingleFile() to the plugins in vite.config.js. The build then inlines all JavaScript and CSS into one dist/index.html.

The bridge exposes window.polotnoNative for Dart to call and posts every answer to the PolotnoFlutter channel as a JSON string:

// web-editor/src/nativeBridge.js
const post = (message) => window.PolotnoFlutter?.postMessage(JSON.stringify(message));

const base64Of = (dataUrl) => dataUrl.split(',')[1];

// The app waits for a reply to every request, so failures are reported too.
const replying = (task) => async (...args) => {
  try {
    await task(...args);
  } catch (error) {
    post({ type: 'error', message: String(error?.message || error) });
  }
};

export const connectNativeBridge = (store) => {
  window.polotnoNative = {
    load: (json) => store.loadJSON(json),

    save: replying(async () => {
      const preview = await store.toDataURL({ pixelRatio: 0.5 });
      post({
        type: 'save',
        docJson: JSON.stringify(store.toJSON()),
        previewBase64: base64Of(preview),
      });
    }),

    export: replying(async (format) => {
      const dataUrl =
        format === 'pdf'
          ? await store.toPDFDataURL()
          : await store.toDataURL({ pageId: store.activePage?.id });
      post({ type: 'export', format, base64: base64Of(dataUrl) });
    }),
  };

  // Ask Dart for the design now that `load` exists.
  post({ type: 'ready' });
};

Connect it in the editor component:

// web-editor/src/App.jsx
import { createStore } from 'polotno/model/store';
import { PolotnoContainer, SidePanelWrap, WorkspaceWrap } from 'polotno';
import { Workspace } from 'polotno/canvas/workspace';
import { Toolbar } from 'polotno/toolbar/toolbar';
import { ZoomButtons } from 'polotno/toolbar/zoom-buttons';
import { PagesTimeline } from 'polotno/pages-timeline';
import { SidePanel } from 'polotno/side-panel';
import { connectNativeBridge } from './nativeBridge';

const store = createStore({ key: import.meta.env.VITE_POLOTNO_KEY, showCredit: true });

const inApp = !!window.PolotnoFlutter;
if (inApp) {
  connectNativeBridge(store);
} else {
  // Plain browser (npm run dev): start with an empty page.
  store.addPage();
}

export default function App() {
  return (
    <PolotnoContainer style={{ width: '100%', height: '100%' }}>
      <SidePanelWrap>
        <SidePanel store={store} />
      </SidePanelWrap>
      <WorkspaceWrap>
        {/* A browser download does nothing in a WebView. */}
        <Toolbar store={store} downloadButtonEnabled={!inApp} />
        <Workspace store={store} />
        <ZoomButtons store={store} />
        <PagesTimeline store={store} />
      </WorkspaceWrap>
    </PolotnoContainer>
  );
}

Import polotno/ui.css in main.jsx, and give html, body and #root a height of 100% in index.html.

2) Ship the editor as a Flutter asset

After vite build, copy dist/index.html to assets/editor/index.html and list it in pubspec.yaml:

flutter:
  assets:
    - assets/editor/index.html

Remove the crossorigin attributes Vite adds to the inlined scripts when you copy the file. The demo's npm run build:flutter does both steps.

3) Render the editor in Flutter

Add the packages:

flutter pub add webview_flutter webview_flutter_android image_picker path_provider share_plus

Read the asset, load it with an origin, and handle the channel's messages:

// lib/editor_screen.dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';

/// The page's origin. Add it to your API key's allowed domains.
const editorOrigin = 'http://localhost/';

class EditorScreen extends StatefulWidget {
  const EditorScreen({super.key, required this.design});
  final String design;

  @override
  State<EditorScreen> createState() => _EditorScreenState();
}

class _EditorScreenState extends State<EditorScreen> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      // The channel name becomes `window.PolotnoFlutter` in the page.
      ..addJavaScriptChannel('PolotnoFlutter', onMessageReceived: _onMessage);
    _loadEditor();
  }

  Future<void> _loadEditor() async {
    final html = await rootBundle.loadString('assets/editor/index.html');
    await _controller.loadHtmlString(html, baseUrl: editorOrigin);
  }

  Future<void> _onMessage(JavaScriptMessage message) async {
    final data = jsonDecode(message.message) as Map<String, dynamic>;
    switch (data['type']) {
      case 'ready':
        // A JSON document is a valid JavaScript object literal.
        await _controller.runJavaScript('window.polotnoNative.load(${widget.design})');
      case 'save':
        // data['docJson']: the design. data['previewBase64']: a PNG preview.
        break;
      case 'export':
        // data['base64']: the PNG or PDF, ready for base64Decode().
        break;
      case 'error':
        debugPrint(data['message'] as String);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        actions: [
          TextButton(
            onPressed: () => _controller.runJavaScript("window.polotnoNative.export('png')"),
            child: const Text('PNG'),
          ),
          TextButton(
            onPressed: () => _controller.runJavaScript("window.polotnoNative.export('pdf')"),
            child: const Text('PDF'),
          ),
          TextButton(
            onPressed: () => _controller.runJavaScript('window.polotnoNative.save()'),
            child: const Text('Save'),
          ),
        ],
      ),
      body: WebViewWidget(controller: _controller),
    );
  }
}

baseUrl gives the page a regular http origin, and that origin is what Polotno checks against your key. In the Polotno cabinet, add localhost to the key's allowed domains, or set editorOrigin to a domain of your own and add that.

The editor sends { type: "ready" } once its script has run, and Dart answers with load(), so it never calls the bridge before the page defines it. Save and Export live in the native app bar, which leaves the Polotno toolbar its full width on a phone.

Core concepts

Saving designs

store.toJSON() returns the whole design as plain JSON: pages, elements, fonts and sizes. The demo writes it to the documents directory from path_provider, shows the preview PNG on its home screen, and passes the JSON back to load() the next time the editor opens. See the Store API.

Exporting the canvas to an image or PDF

Exports cross the channel as base64. Decode them with base64Decode(), write the bytes to a file, and share it with share_plus, or upload the bytes directly. On iPad, pass sharePositionOrigin so the share sheet knows where to point.

Text and images on the canvas

Text, fonts, images, shapes and SVG are all Polotno elements, so you don't draw them yourself. To add content from Dart, extend the bridge with a method that calls the store, for example store.activePage.addElement({ type: 'text', text: 'Hello' }). See the Element API.

Image uploads

Polotno's Upload panel uses a regular file input. iOS opens the system picker for it on its own. Android's WebView ignores the input until the app supplies the files, so register a file selector with webview_flutter_android and pick images with image_picker:

import 'package:image_picker/image_picker.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';

// In initState, after creating the controller:
final platform = _controller.platform;
if (platform is AndroidWebViewController) {
  platform.setOnShowFileSelector(_pickFiles);
}

/// Returns the picked images as file URIs, or an empty list when the user cancels.
Future<List<String>> _pickFiles(FileSelectorParams params) async {
  final picker = ImagePicker();
  final files = params.mode == FileSelectorMode.openMultiple
      ? await picker.pickMultiImage()
      : [?await picker.pickImage(source: ImageSource.gallery)];
  return files.map((file) => Uri.file(file.path).toString()).toList();
}

Polotno stores uploaded images in the design JSON as data URLs. For production, upload the file to your own storage and put its URL in the design instead.

FAQ

Does it work on Flutter web?

webview_flutter targets iOS and Android, and the demo is built for those. On Flutter web you would host the editor yourself and show it in an <iframe> through HtmlElementView; that setup is not part of the demo.

Can I build the editor with Flutter widgets instead of a WebView?

No. Polotno renders to an HTML canvas and its UI is built for the DOM, so it needs a web view on mobile. The WebView approach is what gives you the full editor rather than a reduced mobile version.

Does the editor work with touch?

Yes. Selection, dragging, resizing, rotation and pinch-to-zoom work with touch. See Mobile support for layout tips on small screens.

Can the editor work offline?

The editor code ships inside the app, but Polotno loads stock photos, templates, fonts and the PDF export library from the network. The device 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.

What are the memory considerations?

Exports cross the bridge as base64 strings, so large multi-page PDFs or high pixel ratios use a lot of memory on low-end devices. Use lower pixel ratios for previews and render print-size exports on the server.

How do I customize the editor UI?

Edit web-editor/src/App.jsx and rebuild the bundle. 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-flutter

The repository is a Flutter app with a home screen that shows the last saved design, the editor screen with native Save and Export buttons, and the React + Vite web editor that runs inside the WebView.

On this page