Extension & developer docs
Build your own generators, tools, panels, effects, export formats and more on the public extension API. It is additive and versioned; feature-detect newer points and your extension keeps working across editor versions.
Extension API Developers #
Build your own generators, tools, panels and more.
The editor exposes a public extension API. An extension can add design generators, asset-library sections, starter-template packs, local AI tools (with access to the self-hosted transformer runtime), pixel effects, filter presets, menu items, panels, properties-panel sections, canvas tools, export formats and stock providers, and react to save events, all without touching the plugin’s code.
Download the
extension starter - a buildable package with the
canonical register pattern, the UI kit, mounted editor components, per-user storage, i18n and a
Playwright smoke test wired up (npm install && npm run build). IntelliSense comes
from wpie-extension.d.ts, and
manifest.schema.json validates your manifest in any editor via
its $schema line.
There are two ways to ship an extension:
- Extension package (recommended): a ZIP with a
manifest.jsonand a JavaScript entry file, installed inside the editor under Extensions, then Manage Extensions. It never appears in the WordPress plugin list, loads only on the editor page, and is pure client-side (the archive may not contain PHP). - WordPress plugin: for extensions that also need server-side hooks (stock providers, save events, bootstrap data).
Extension packages Developers #
A package is a ZIP whose top level contains a manifest.json:
{
"name": "Paper Doodles",
"slug": "wpie-paper-doodles",
"version": "1.0.0",
"author": "Jane Doe",
"description": "Doodle shapes for the asset library.",
"main": "extension.js",
"style": "style.css",
"requiresApi": "2.0.0",
"provides": [ "librarySection" ]
}
nameandmainare required;slugdefaults to a sanitized name;styleis enqueued alongside the script.- Allowed file types: JS, JSON, CSS, source maps, images, fonts, text and 3D assets. PHP is rejected, so packages can never run server-side code.
- Installing needs
manage_options; packages install touploads/wpie-extensions/<slug>/, and re-installing a slug replaces it (that is the update path). category(optional) sorts your package into the Extensions menu. One slug from the curated, host-translated vocabulary:photo(Photo & Effects),motion(Motion & Video),3d(3D & Scenes),art(Art & Illustration),data(Data & Diagrams),marketing(Marketing & Social),print(Print & Posters),tools(Tools & Workflow). Anything else lands under Other. Items registered viaregisterMenuItem( 'extensions', … )declare the same slug on the item:{ label, run, category: '3d' }.generatorPrefixes(optional): if your generator ids use a namespace that differs from the package slug (legacy ids live inside saved documents and must never change), list those prefixes so the menu can still attribute your generators.
The entry script registers the same way whether it ships in a package or a plugin:
function register( api ) { /* api.register…() calls */ }
if ( window.WPIE?.api ) {
register( window.WPIE.api );
} else {
wp.hooks.addAction( 'wpie.ready', 'my-extension', register );
}
From a WordPress plugin, enqueue on the wpie_editor_assets action (it fires only on
the editor page) and depend on wpie-editor and wp-hooks.
The editor text domain only covers core strings; packs ship their own translations. Read the editor user’s locale from window.WPIE.locale (falling back to <html lang>) and pick a bundled dictionary.
JavaScript API Developers #
window.WPIE.api is the registration surface. api.version is the API
semver; feature-detect newer points with if ( api.registerAiTool ) { … }. All ids must
be namespaced like Gutenberg blocks: my-plugin/my-thing. Every callback receives
{ editor, extras } (see The bridge). One example per point:
registerGenerator( generator )
An entry in the Extensions menu that inserts layers. If the layer carries generator = { id, params }, it gets an Edit re-entry in the context menus.
api.registerGenerator( {
id: 'my-plugin/confetti',
label: 'Confetti Generator',
run( { editor, extras } ) {
const { makeShape } = window.WPIE.bridge.documents;
const layer = makeShape( { name: 'Confetti', x: 40, y: 40, w: 300, h: 300, pathD: '…' } );
layer.generator = { id: 'my-plugin/confetti', params: { density: 5 } };
editor.dispatch( { type: 'ADD_LAYER', layer } );
editor.commit( 'Insert Confetti' );
},
edit( { editor, extras, layer } ) { /* reopen your UI for `layer` */ },
} );
To insert several layers as one unit, an image with its own editable caption for instance, build a makeGroup, dispatch it, then dispatch each child with child.parent = group.id, and select the group with editor.dispatch( { type: 'SET_ACTIVE', id: group.id } ) before you commit. Load any text layers' faces first with bridge.fonts.ensureFontsForLayers( children ), or they paint in a fallback font until the real one arrives.
Dynamic re-render: the resolve hook API 2.11
An optional async resolve makes generator layers dynamic: inside dynamic templates,
automations and Preview with Post, the editor asks every generator layer to re-render for the
current post context. The hook receives
{ layer, params, ctx, children, expandTokens, hasTokens, renderTemplate } and may
return three shapes: { src, naturalW, naturalH } (fresh pixels for this layer, the
3D Mockup pattern), { hide: true } (the layer disappears for this context, the
sale-badge pattern), or, new in API 2.11 for group layers,
{ group, layers }: a full re-composition where the group and all its children are
replaced and child counts may change. That is the repeater case; Showcase Studio re-runs its saved
query and rebuilds its cards this way.
Group generators with a resolve hook automatically get a Refresh entry next to
Edit in the canvas and layers-panel context menus: one click re-runs the query and swaps
the children in place. Convention: return null when the query comes back empty,
because stale content beats a blank graphic in a batch run. A throwing resolve never
breaks a batch; the design-time layers stay.
registerEffect( effect )
A pure pixel kernel (no DOM, no async). It appears in the Filter menu, the Adjust panel and as a Smart Filter; preview and export share the code path.
api.registerEffect( {
id: 'my-plugin/scanlines',
label: 'Scanlines',
params: {
gap: { min: 2, max: 12, default: 3 }, // slider
strong: { type: 'bool', default: false }, // checkbox
tint: { type: 'color', default: '#001122' }, // color
},
apply( img, params ) {
// img = { data: Uint8ClampedArray (RGBA), width, height }. Mutate in place.
return img;
},
} );
registerFilterPreset( preset )
A one-click CSS filter preset, like the built-in filter list. Use widely-supported filter functions; the editor emulates them pixel-exactly where ctx.filter is missing.
api.registerFilterPreset( {
id: 'my-plugin/hero',
label: 'Hero',
css: 'saturate(1.4) contrast(1.15)',
} );
registerMenuItem( menuId, item )
menuId is one of file, edit, image, layer, select, filter, view, tools, extensions, help (plus the automation submenus). Items appear behind a divider at the end of the menu.
api.registerMenuItem( 'filter', {
label: 'My Action…',
run( { editor, extras } ) {
extras.toasts.success( 'Layers: ' + editor.state.layers.length );
},
when: ( { editor } ) => !! editor.state.activeId, // optional enable-gate
} );
registerPanel( panel )
A tab in the right dock. render gets a plain DOM element (mount any framework yourself) and re-runs when layers or the active layer change; return a cleanup function.
api.registerPanel( {
id: 'my-plugin/inspector',
title: 'Inspector',
render( el, { editor, extras } ) {
el.innerHTML = '<div style="padding:12px">…</div>';
return () => {/* cleanup */};
},
} );
registerPanelSection( section )
A section in the Properties panel, gated per layer via when. Read live values from editor.state inside handlers, not from captured variables.
api.registerPanelSection( {
id: 'my-plugin/confetti-props',
title: 'Confetti',
when: ( layer ) => layer.generator?.id === 'my-plugin/confetti',
render( el, { editor, extras, layer } ) {
el.innerHTML = '<label>…controls…</label>';
return () => {/* cleanup */};
},
} );
registerTool( tool )
A button in the left tool rail whose pointer handlers dispatch like built-in tools. Handlers receive the tool context tc, the DOM event and the point in document coordinates.
api.registerTool( {
id: 'my-plugin/stamp-circle',
label: 'Circle Stamp',
icon: '<svg viewBox="0 0 24 24" width="18">…</svg>',
handlers: {
onDown( tc, event, p ) { /* start */ },
onMove( tc, event, p ) { /* drag */ },
onUp( tc, event, p ) { tc.editor.commit( 'Circle Stamp' ); },
},
} );
registerLibrarySection( section )
A chip in the Asset Library tray, the home for asset packs. items is a static array or a function of the query (may return a Promise, so remote sources work).
api.registerLibrarySection( {
id: 'my-plugin/doodles',
label: 'Doodles',
items: [
{
name: 'Heart',
preview: 'data:image/svg+xml;utf8,…', // any image URL
asset: { kind: 'user-element', descriptor: { name: 'Heart', pathD: 'M …' } },
},
],
} );
registerTemplatePack( pack )
Own gallery categories (namespaced since API 2.10): declare
categories: [ { id, label } ] and reference them via
descriptor.category. Reusing a built-in id (sale,
event, food, …) files your templates into that chip on purpose; any other
id is automatically prefixed with your vendor slug, so two packs declaring yoga get two
chips and can never collide. Give every descriptor a stable id (fallback is a slug of
the name); duplicate pack.id registrations throw.
Installable starter templates that join the built-in ones. templates is an array or a function returning one (a Promise works, so large packs fetch a JSON asset lazily).
// The script's own URL makes fetches package-relative.
const baseUrl = ( document.currentScript?.src || '' ).replace( /[^/]*$/, '' );
api.registerTemplatePack( {
id: 'my-plugin/holiday',
label: 'Holiday Templates',
templates: () =>
window.fetch( baseUrl + 'templates.json' ).then( ( r ) => r.json() ),
} );
registerAiTool( tool )
A button in AI Studio, Local tools. It runs inside the panel busy/error harness; use the bridge for self-hosted transformer models, so inference never leaves the browser.
api.registerAiTool( {
id: 'my-plugin/name-layers',
label: 'Name Layers',
title: 'Names every image layer with a local vision transformer.',
async run( { editor, extras, layer } ) {
const TF = await window.WPIE.bridge.ml.loadTransformers();
const pipe = await TF.pipeline( 'image-to-text', 'Xenova/vit-gpt2-image-captioning' );
// … caption layers, dispatch UPDATE_LAYER patches, commit.
},
} );
registerExportFormat( format )
Adds a button to the Export dialog. render() gives you the flattened canvas from the real pipeline; encode must return a Blob.
api.registerExportFormat( {
id: 'my-plugin/tiff',
label: 'TIFF',
ext: 'tiff',
async encode( { doc, layers, render, scale, quality } ) {
const canvas = await render();
return myTiffEncoder( canvas );
},
} );
Effects are stored by id in the project JSON; if a project is opened without your extension the effect is skipped gracefully and preserved in the file, so no data is lost.
registerBinding( binding ) API 2.10
A custom dynamic-content variable: {{my-plugin/name}} tokens then resolve everywhere
core tokens do (bound text layers, QR contents, chart titles, batch runs, Preview with Post) and the
variable appears in every {…} picker under your group label. resolve( ctx )
is synchronous and receives the run’s post context; throwing or returning nothing degrades to an
empty string, so a broken resolver never kills a batch.
api.registerBinding( {
id: 'my-plugin/reading-time',
label: 'Reading time',
group: 'My Plugin',
resolve: ( ctx ) => {
const words = String( ctx.fields?.[ 'post.content' ] || '' ).split( /\s+/ ).length;
return Math.max( 1, Math.round( words / 220 ) ) + ' min';
},
} );
PHP hooks Developers #
For WordPress-plugin extensions that need server-side behavior:
| Hook | Type | Purpose |
|---|---|---|
wpie_editor_assets | action ( $attachment_id ) | Enqueue editor-page scripts and styles. |
wpie_bootstrap_data | filter ( $data, $attachment_id, $is_new ) | Add values to window.WPIE. |
wpie_stock_providers | filter ( $providers ) | Register stock-image sources. |
wpie_after_save | action ( $attachment_id, $file, $context ) | React to saves (save = overwrite, saveAs = new attachment). |
wpie_settings_tabs | filter ( $tabs ) | Add a tab to the settings page. |
wpie_settings_extra_tabs | action ( $active_tab ) | Render your added settings tab. |
wpie_ai_models | filter ( $models, $provider ) | Adjust the AI model list per provider. |
The bridge Developers #
window.WPIE.bridge hands extensions the editor’s own building blocks, so they never
bundle their own copies. It is additive: members are never renamed or removed. The full surface:
Documents, rendering & assets
documents: layer factories and (de)serialization,makeShape,makeText,makeImage,makeGroup,createBlankDoc,loadImage,hydrateLayers,serializeLayers.raster:renderToCanvas,renderToBlob,sharedImageCache, anddocBackdrop( editor.state, { exclude } )(API 2.10) – the document rendered without one group, the ready-made “Show document” preview backdrop.svg:exportSvg(keeps dynamic-binding markers),importSvg.fonts:ensureFontsForLayers.icons: the Tabler icon set.aspect:nearestAspect.
Data, templates & export
dynamicContent:resolveBindings,prepareGeneratorLayers(re-render generator layers per post),collectMetaKeys, plus the token helpersexpandTokens,hasTokensandbindingGroupsfor{{token}}fields and pickers.templatesLib:loadTemplate,invalidateTemplate,renderResolved(a template plus post context to a finished canvas).csv:parseCsv.exportPresets:listExportPresets.watermark:applyWatermark.providers:PROVIDER_LABELS,hasTextProvider.macros:listMacros,runMacro,createHeadlessEditor(run edits off-screen).
AI & local models
ml:loadTransformers(the self-hosted transformers.js runtime),mlPipelinewith WebGPU auto-fallback,preferredDevice,isModelInstalled,modelsBaseUrl. Inference never leaves the browser.api.ai.complete( { prompt, system, schema, tier, maxTokens } )(API 2.10): one generic text call against the site’s configured providers – plain text, or schema-shaped JSON when you pass a JSON Schema ({ data }). Tiercaptionis fast and cheap,designis strong; calls are rate-limited, metered and budget-capped like every other AI action. Feature-detect withbridge.providers.hasTextProvider().
The 2.10 toolbox API 2.10
ui: framework-free DOM builders that emit the editor’s shared design-system classes –dialog(the studio dialog shell),section(icon-headed settings card),row,slider,select,check,btn,el. A studio dialog matches the editor CI without local helpers or CSS.storage:get( ns )/set( ns, object )– one JSON object (up to 32 KB) per extension namespace per user, stored server-side. The right home for favorites and toggles that localStorage would lose on the next device.brand:kits(),kitById( id )andactiveKitColors( doc.brandKitId )– the palette resolution (document’s active kit, then site kit, then first usable kit).video:recordCanvas( canvas, { fps, durationMs, bitsPerSecond, onProgress } )→{ stop, blob, mimeType }with the vp9 → vp8 → webm fallback chain, for WebM exports.util:rng( seed )(deterministic mulberry32, safe for stored designs),EASINGS,downloadBlob.colors: the Color Schemer’s engine (buildScheme,randomSchemeInput,schemeRoles,labelColorFor),extractPalette( image, k )(dominant colors) andcontrastRatio(WCAG).qr: the editor’s QR engine –payload,ready,render( payload, style )→{ canvas, modules, ecl }with styled modules and eyes (drawresult.canvas),warnings.optimize(core 1.297):optimizeCanvas( canvas, { format: 'auto'|'webp'|'jpeg', target: 'auto'|1..100, maxDim, ssimTarget, encode } )re-encodes a canvas as the smallest file with no visible difference (perceptual SSIM search, WebP-preferring;encodesince 1.298 accepts a custom encoder with theencodeCanvascontract), plusencodeCanvas,fitCanvas,ssimLuma.
Editor UI components
components: mount the editor’s own controls into your extension, framework-free.mountColorButton( node, { color, onChange, title } )renders the native color swatch and picker (returns{ set, unmount });mountVarButtoninserts{{token}}variables;mountStyleButtonis the AI style-preset picker;HelpLinkis the standard help link.mountFontPicker( node, { value, onChange, width, families } )(API 2.9,familiessince 2.10): the editor’s font selector – a grouped dropdown (Brand / Custom / System fonts / Editor fonts / Google Fonts) that previews every family in its own face and lazy-loads the preview faces.valueis a family name,onChange( family )fires on pick; passfamilies(array) to restrict the catalog to a curated allow-list. Returns{ set, unmount }. Feature-detect it and keep a plain<select>as the fallback on older cores; callbridge.fonts.ensureFont( family )before painting the family to a canvas.mountKitPicker( node, { value, onChange, allowEmpty } )(API 2.10): the brand-kit dropdown. Controlled and self-updating; renders nothing when the site has no kits. Resolve the actual palette withbridge.brand.activeKitColors( id ).mountMediaPicker( node, { onPick, height, selectedIds } )(API 2.10): the editor’s in-modal media chooser with title and local semantic search and endless scroll.onPickreceives{ id, url, fullUrl, title }; the host owns selection (passselectedIdsfor multi-select display, update viaset( ids )).mountIconPicker( node, { onPick, icons, emoji, height } )(API 2.10): search plus tabs over the bundled Tabler icons and emoji as an inline panel;onPickreceives{ type: 'icon', name, path }or{ type: 'emoji', char, name }. It positions nothing itself: to open it as a popover, append it to the dialogbackdrop(not thedialogbox, which isposition:static) and place it from the button viewport rect. See the example below.
Open the icon/emoji picker as a popover at a click. The dialog box is
position:static, so anchor the popover to the backdrop
(fixed, viewport-filling) and use the button viewport rect (an icon keeps its
name and path, an emoji its char):
// modal = bridge.ui.dialog() -> { backdrop, dialog, ... }
const pop = document.createElement( 'div' );
pop.style.cssText = 'position:absolute;z-index:50';
modal.backdrop.appendChild( pop ); // NOT modal.dialog (static)
const r = anchor.getBoundingClientRect();
pop.style.left = Math.max( 8, Math.min( r.left, innerWidth - 308 ) ) + 'px';
pop.style.top = ( r.bottom + 344 > innerHeight ? r.top - 350 : r.bottom + 6 ) + 'px';
const h = bridge.components.mountIconPicker( pop, {
icons: true, emoji: true,
onPick: ( sel ) => {
sel.type === 'icon' ? useIcon( sel.name, sel.path ) : useEmoji( sel.char );
h.unmount(); pop.remove();
},
} );
// draw an icon: g.stroke( new Path2D( sel.path ) ), scaled by size / 24
Server-backed helpers (bridge.api)
posts(post and product catalog / search),templates,automation, andai(image and text generation).geo: a capability-gated, server-side OpenStreetMap proxy for map extensions (geo.search,geo.map), cached on your server.- Media helpers:
saveToLibrary,saveAsNew,buildFormData,getMediaMeta,updateMediaAlt,proxyImageUrl.
Every registration callback receives
{ editor, extras }. editor is the live editor: state (doc,
layers, selection, activeId), dispatch( action ), commit( label ) (a history
snapshot), undo() and redo(). extras carries toasts
and around forty dialog openers, so an extension can launch core features directly, for example
extras.openColorSchemer(), extras.openChart(), extras.openQr(),
extras.openStock(), extras.openGenerate(), extras.openMockup(),
extras.openExport() and many more.
REST: 3D model library & Meshy proxy Developers #
Since core 1.295 two server-side services back the 3D studios and are open to every extension via
window.wp.apiFetch (nonce handling included). Namespace wpie/v1, editor capability required.
Model library
GLB storage under uploads/wpie-3d-models/, shared per site: reads for every editor user,
rename and delete for the owner or an administrator. Uploads are validated by the GLB magic bytes and capped
at 50 MB (filter wpie_3d_model_max_bytes), 100 models per site; models and their PNG
thumbnails travel with plugin backups.
GET /wpie/v1/models3d→[ { id, name, size, created, source, mine, url, thumb } ]POST /wpie/v1/models3d– multipart, fieldfile(.glb) plus optionalnamePOST /wpie/v1/models3d/<id>–{ name? }renames (owner/admin);{ thumb? }stores a data-URL PNG preview (any editor user, ≤ 300 KB)DELETE /wpie/v1/models3d/<id>– owner/admin
Meshy proxy
AI 3D generation with the site's stored key – it never reaches the browser. The site owner connects
Meshy under Settings → Integrations; without a key every route returns wpie_meshy_key.
The site-wide model and detail-quality defaults are applied server-side.
POST /wpie/v1/meshy/generate–{ mode: 'image', attachment },{ mode: 'text', prompt }or{ mode: 'text-refine', preview_id }→{ id, mode }GET /wpie/v1/meshy/task?mode=&id=→{ status, progress, thumbnail, error }– poll every ~5 sPOST /wpie/v1/meshy/import–{ mode, id, name? }downloads the finished GLB into the model library and keeps Meshy's preview as the thumbnail
Rules of the road Developers #
- Namespace everything (
my-plugin/thing); unprefixed ids are rejected. - Effects must be pure pixel functions: no DOM, no async, no state. They run in the live preview on every frame; keep them linear over pixels.
- Detect API capabilities with
api.versionor!! api.registerGeneratorinstead of failing hard on older editor versions. - Projects that reference a missing extension effect still open; the effect is skipped at render and preserved in the file.
- The editor is GPL; extensions interacting this deeply are derivative works, license accordingly.