Skip to content

06 — Polotno integration

The design editor is a self-contained React 18 + MobX-State-Tree app built on the Polotno SDK. It lives in polotno-editor/, builds with Parcel into a JS+CSS bundle at the root of the main repo, and is consumed by the Nuxt app via a regular ES import.

Two builds, one runtime

Production is a two-stage build:

  1. polotno-editor/ builds via Parcel (yarn build in that directory). The output is written to ../polotno-bundle.js and ../polotno-bundle.css — i.e. the root of the main repo. This is configured in polotno-editor/package.json:
{ "main": "../polotno-bundle.js", "source": "index.js" }
  1. The main Nuxt app's editor pages then import from that bundle directly:
import { createEditor } from '../../../../polotno-bundle'

Webpack (Nuxt's bundler) resolves this as a normal ES module reference.

The Dockerfile encodes this ordering: stage 1 builds the polotno bundle and copies the .js/.css into /tmp, stage 2 builds the Nuxt app with the bundle in place (see Dockerfile, lines 4–40).

In local development, after editing anything in polotno-editor/, you must rebuild the bundle:

cd polotno-editor && yarn build

There is no watch mode that does this automatically, and the Nuxt dev server will not pick up source changes from polotno-editor/ because it only sees the bundle.

What the bundle exports

polotno-editor/index.js (the Parcel entry point) does roughly the following:

import { createStore } from 'polotno/model/store'
import App from './App'
// …blueprintjs CSS imports

const store = createStore({ key: 'FXZvloSJvAe09-bdR9iC' })   // Polotno license key
window.store = store

// localStorage autosave keyed by pathname+hash
const href = `${window.location.pathname}${window.location.hash}`
store.on('change', () => debounceSaveTo(`localStorage[${href}]`))

// Initial canvas
store.setSize(1080, 1080)
store.addPage({ width: 1080, height: 1080 })

export const createEditor = ({ container }) => {
  loadStateFromLocalStorage()
  const root = ReactDOM.createRoot(container)
  root.render(<React.StrictMode><App store={store} /></React.StrictMode>)
}
window.createEditor = createEditor
window.dispatchEvent(new Event('editor-ready'))

So the public surface from a Nuxt page's perspective is:

  • createEditor({ container }) — mount the editor into a DOM node. You only call this once per page; calling it again will create a second React root inside the same container.
  • window.store — the singleton Polotno MobX store. Editor pages call store.toJSON() and store.loadJSON(...) against this global. (See e.g. pages/_accid/posteditor/_id/index.vue.)
  • A custom editor-ready window event fired once on bundle init.

The createStore license key is hardcoded in the bundle source. Verify with the team before changing or rotating it — the Polotno license is purchased and tied to deployed origins.

How a Nuxt editor page mounts the bundle

A canonical example is pages/_accid/posteditor/_id/index.vue:

<template>
  <div>
    <div class="editorTopBar">
      <img src="~assets/images/someli-white-logo.svg" />
      <button @click.once="saveImages('exit')">Save and Exit</button>
      <button @click.once="saveImages()">Save</button>
    </div>
    <div id="polotno"></div>
  </div>
</template>

<script>
import { createEditor } from '../../../../polotno-bundle'

export default {
  middleware: 'auth',
  data: () => ({ accountId: 0, postData: {}, /* … */ }),
  async mounted() {
    this.accountId = this.$auth.user.accountId || 0
    createEditor({ container: document.getElementById('polotno') })
    this.getdata()
  },
  methods: {
    async saveImages(ext) {
      const json = store.toJSON()                 // ← the global window.store
      await this.$store.$axios.post('/saveUserposts', { , json, account_id: this.accountId })
      if (ext) window.close()
    },
    async getdata() {
      const id = this.$route.path.split('/')[3]
      const result = await this.$store.$axios.post('/getTemplate', { id, account_id: this.accountId })
      store.loadJSON(result.data.data[0].json)
    }
  }
}
</script>

A few things to notice:

  • The host page imports createEditor directly from the bundle. There is no Vue wrapper component.
  • The host page reaches into the global store (the MobX store the bundle put on window) to read/write design JSON. This is the data-flow boundary between the Nuxt host and the Polotno bundle.
  • Save logic lives in the Vue page, not in the React app. The React app handles editing UX; the surrounding Vue page handles persistence and navigation.
  • The page also imports the Blueprint.js CSS files directly (@/assets/css/blueprint.css, @/assets/css/blueprint-popover2.css) — these are not part of polotno-bundle.css. Verify with the team whether keeping them duplicated as static assets in assets/css/ is intentional.
  • middleware: 'auth' re-runs middleware/redirect.js on direct page loads. Editor pages are typically opened in a new window, so this matters.

Pages that mount the editor

pages/_accid/posteditor/_id/index.vue
pages/_accid/cuseditor/_id/index.vue
pages/_accid/usermediaditor/_id/index.vue
pages/_accid/sharedposteditor/_id/index.vue
pages/_accid/contentplanner/_id/_id/index.vue   (inline editor inside content planner)
pages/_accid/templateeditor/...                  (template editing)
pages/_accid/usereditor/_id/...                  (user-uploaded media editor)

# Legacy non-_accid editor routes (still live)
pages/posteditor/_id/index.vue
pages/cuseditor/_id/index.vue
pages/editor/_id/index.vue
pages/editor/_id/_id/index.vue
pages/designer.vue
pages/carouseleditor/_id/index.vue

appendUrl middleware (see 04-routing.md) redirects bare editor URLs into their _accid-prefixed equivalents.

State persistence

The bundle persists in-progress designs in localStorage, keyed by pathname + hash:

const href = `${window.location.pathname}${window.location.hash}`
localStorage.setItem(href, JSON.stringify(store.toJSON()))

Saves are debounced 2s. On editor load, the bundle calls loadStateFromLocalStorage first, then the host page calls store.loadJSON(serverData) from getdata(). The order matters: the most recent of the two calls wins (the server data clobbers any local autosave).

Implications:

  • Two browser tabs editing the same design path will fight over localStorage. The bundle does not coordinate.
  • A user who closes the tab without saving will see autosaved local content the next time they open the same URL — but only on the same device/browser, and only if the server data hasn't shipped a newer version.
  • Account switching does not clear these localStorage entries. Verify with the team whether this is intentional cross-account data leakage or a known papercut.

AI features in the editor

The editor itself integrates Gemini / Vertex AI / Stability AI / Leonardo AI for content generation. Source lives under polotno-editor/aiContent/ and polotno-editor/aiImage.js. Keys are read from polotno-editor/.env (build-time) — see 01-local-setup.md for the variable list.

These are independent from the Nuxt app's environment. If an AI feature isn't working in the editor, check polotno-editor/.env, not the root .env.

When to edit which side

Change Edit
Editor canvas/toolbar/UI behaviour polotno-editor/
AI generation prompts/keys/providers polotno-editor/
Persisted design schema or autosave logic polotno-editor/
Save-to-backend / load-from-backend logic pages/_accid/<editor>/_id/index.vue (the Vue host)
Top-bar buttons, save/exit, navigation in/out of the editor The Vue host
Editor route URL or auth requirements The Vue host
Adding a brand-new editor variant (e.g. video editor) Both — new pages/_accid/... host that calls createEditor

If a change requires editing both, edit and rebuild Polotno first, then the Vue host. Otherwise the host will compile against a stale bundle.

Common pitfalls

  • Stale bundle in dev. Edited the React side, didn't rebuild. The Nuxt dev server is happy because the bundle hasn't changed; nothing reflects your edits. Fix: cd polotno-editor && yarn build.
  • window.store is undefined. The bundle hasn't loaded yet. Don't call store.loadJSON(...) before createEditor() returns; mount inside mounted() and follow the canonical pattern above.
  • React 18 / Vue 2 conflicts. They don't share a tree. Don't try to render Vue components inside the editor or React components in the Nuxt app — the build will let you, but runtime will not.
  • Multiple mounts. Some host pages call createEditor more than once if mounted() is re-invoked (e.g. on hot reload). The bundle does not guard against this; you'll get duplicated React roots in one DOM node. The current code mostly avoids this by using @click.once on save buttons and not mounting in created/activated. If you write a new host page, mount strictly once.
  • Bundle size. polotno-bundle.js is large. Don't import it on non-editor pages — the import is only safe in editor host pages because Nuxt code-splits per route. Importing it from a shared component would balloon every chunk.