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:
polotno-editor/builds via Parcel (yarn buildin that directory). The output is written to../polotno-bundle.jsand../polotno-bundle.css— i.e. the root of the main repo. This is configured inpolotno-editor/package.json:
- The main Nuxt app's editor pages then import from that bundle directly:
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:
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 callstore.toJSON()andstore.loadJSON(...)against this global. (See e.g.pages/_accid/posteditor/_id/index.vue.)- A custom
editor-readywindow 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
createEditordirectly from the bundle. There is no Vue wrapper component. - The host page reaches into the global
store(the MobX store the bundle put onwindow) 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 ofpolotno-bundle.css. Verify with the team whether keeping them duplicated as static assets inassets/css/is intentional. middleware: 'auth're-runsmiddleware/redirect.json 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
localStorageentries. 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.storeis undefined. The bundle hasn't loaded yet. Don't callstore.loadJSON(...)beforecreateEditor()returns; mount insidemounted()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
createEditormore than once ifmounted()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.onceon save buttons and not mounting increated/activated. If you write a new host page, mount strictly once. - Bundle size.
polotno-bundle.jsis 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.