Quickstart
A plugin is a zip with three files. The screen is a plain HTML document — the editor injects the SSHOWPlugin SDK ahead of your scripts, opens it in a sandboxed panel, and everything below is the full surface it can reach.
my-plugin.sshowplugin (zip)
├─ plugin.json — manifest
├─ ui.html — the plugin screen (manifest.main)
└─ icon.svg — listing icon (optional)
plugin.json declares identity and the entry document:
{
"id": "com.example.hello",
"name": "Hello",
"version": "1.0.0",
"api": 1,
"main": "ui.html",
"description": "Inserts a greeting card.",
"author": "SSHOW",
"icon": "icon.svg"
}
ui.html is the whole plugin — connect, then read and write through the handle:
<!doctype html>
<button id="insert">Insert</button>
<script>
(async () => {
const api = await SSHOWPlugin.connect();
api.ui.resize(160);
document.querySelector('#insert').addEventListener('click', async () => {
await api.document.applyActions([{
op: 'create_object', type: 'text', config: {
name: 'greeting',
data: { text: 'Hello, SSHOW!', fontSize: 48, autoSize: true },
transform: { x: 200, y: 200 }
}
}], 'Hello plugin');
});
})();
</script>
Try it immediately: zip the three files as my-plugin.sshowplugin and import it with the + button in the editor's Plugins panel. Re-import after each change, or use the desktop dev loop below.
Manifest reference
Five required fields, three optional. Unknown fields are ignored, so a newer manifest still loads on an older editor.
| Field | Description |
|---|---|
id |
Unique id — lowercase reverse-domain style (required) |
name |
Name shown in the list and panel title (required) |
version |
x.y.z version (required) |
api |
Plugin API version — currently 1 (required) |
main |
Screen document filename, e.g. ui.html (required) |
description |
One-line description shown in the list (optional) |
author |
Author name shown in the list (optional) |
icon |
Icon filename inside the package — png/svg/jpg/webp (optional) |
Constraints — id: lowercase reverse-domain ([a-z0-9.-], max 100 chars, yours forever after first submission) · version: exact x.y.z · api: currently 1 · name ≤ 100 chars · description ≤ 2000 chars · author ≤ 100 chars · icon: png / svg / jpg / webp inside the package. In the catalog the author line always shows the verified submitter account.
Connecting
Call SSHOWPlugin.connect() inside the screen document to get the API handle. It carries apiVersion (currently 1), engineVersion, and the document, events and ui namespaces below.
const api = await SSHOWPlugin.connect();
api.apiVersion; // 1 — the bridge contract this editor speaks
api.engineVersion; // engine build, for display only
api.document; // getState() · getObject(id) · getSelection() · setSelection(ids)
// setActiveScene(sceneId) · getTimelineTime() · applyActions(actions, label)
api.assets; // get(uri) · register(bytes, { mimeType, originalName })
api.events; // on(type, callback) · off(type, callback)
api.ui; // resize(size) · getTheme()
Reading
document.getState()- A snapshot of the whole document — canvas size, the active scene id, the scene list (full objects for the active scene, summaries for the rest) and the font list.
document.getObject(id)- A snapshot of one object, or null.
document.getSelection()- Snapshots of the currently selected objects.
Typical read loop:
const { canvas, activeSceneId, scenes } = await api.document.getState();
const selection = await api.document.getSelection();
// selection[0] → { id, type, name, transform, size, style, data, … }
Everything returned is a snapshot (a copy). Mutating it changes nothing — send actions to edit.
Very large fields are elided in snapshots — a long data.src arrives as a <src len=…> marker, not the real value. Never copy a marker back into a set; read the actual bytes through assets.get instead.
Editor state
Two UI-state setters and one editor-state read round out the reads. None of them touches the document or the undo history.
document.setSelection(ids)- Select the given active-scene object ids in the editor — hand freshly created objects to the user selected. Stale ids drop silently.
document.setActiveScene(sceneId)- Switch the active scene. Unknown ids reject, so a plugin never keeps writing into the wrong scene.
document.getTimelineTime()- The editor's animation clock in ms — the playhead while Animation mode holds, 0 in Design mode (the document pose). Start timeline work (a bake, a preset) here, and re-read it right before you write.
Writing — applyActions
document.applyActions(actions, label) is the only way to edit the document. The action array commits as one edit — a single undo reverts it all — and returns { applied, skipped }. A malformed action is reported in skipped instead of blocking the rest.
Each action is an op, a target and the change. Target objects by id and scenes by sceneId (omit for the active scene); update_* actions carry only the changed fields in a set object.
All 19 ops ride in the same actions array and commit in order. Fields in parentheses are optional; omitting sceneId anywhere targets the active scene.
Objects
| Op | Fields (+ optional) | Notes |
|---|---|---|
create_object |
type, config (+ sceneId, options) |
type is one of rect · circle · path · text · image · video · audio · group · frame. Give config.id your own id to target the object from later actions in the same batch; options.parentObjectId creates inside a group or frame, options.index sets the list position. |
update_object |
id, set (+ sceneId) |
Only the keys inside set change — see the valid set keys below. |
delete_object |
id (+ sceneId) |
A stale id lands in skipped instead of failing the batch. |
duplicate_object |
id (+ sceneId, options) |
|
move_object |
id (+ sceneId, options) |
options.parentObjectId reparents into a container; options.index reorders within it. |
group_objects |
ids (+ config, sceneId) |
At least two ids; the list may include config.id values minted earlier in the same batch. |
ungroup |
id (+ sceneId) |
|
convert_to_path |
id (+ sceneId) |
Scenes
| Op | Fields (+ optional) | Notes |
|---|---|---|
create_scene |
config (+ options) |
config.id self-assignment works here too. |
update_scene |
set (+ sceneId) |
|
delete_scene |
(+ sceneId) |
|
duplicate_scene |
(+ sceneId) |
|
move_scene |
sceneId, newIndex |
|
set_scene_size |
size: { width, height } |
The canvas size is document-global — it applies to every scene. |
Variables
| Op | Fields (+ optional) | Notes |
|---|---|---|
create_variable |
config (+ options) |
|
update_variable |
variableId, set |
|
delete_variable |
variableId |
|
move_variable |
variableId, newIndex |
Document
| Op | Fields (+ optional) | Notes |
|---|---|---|
set_document |
set |
Document metadata — see the valid set keys below. |
Valid set keys
An unknown key inside set skips the whole action (it lands in skipped with a reason):
update_object.set — name · description · size · transform · distort · layout · style ·
opacity · blendMode · locked · visible · motion · interaction · data
update_scene.set — name · description · notes · style · visible · motion · interaction · data · clip
set_document.set — name · description · notes
One call, one undo step — targets are object ids from the snapshots:
const { applied, skipped } = await api.document.applyActions([
{
op: 'create_object', type: 'rect', config: {
name: 'bar',
size: { width: 120, height: 240 },
transform: { x: 400, y: 300, anchorX: 0.5, anchorY: 0 },
style: { fills: [{ type: 'solid', color: '#8A8A8E' }], strokes: [], effects: [] }
}
},
{ op: 'update_object', id: selection[0].id, set: { transform: { rotateZ: 15 } } },
{ op: 'delete_object', id: obsoleteId }
], 'My plugin edit');
// applied — actions committed as ONE undo step · skipped — malformed entries with reasons
How set merges
- transform · size · data · layout — only the keys you send change; the rest are kept.
- style · distort — replaced wholesale. For style, always send the full fills·strokes·effects.
- motion — merged per sub-container. Sending only animations keeps transitions (and vice versa). To edit one keyframe, read the whole sub-container from a snapshot, modify it, and send it back whole.
Normalization bridges
- transform.rotateX / rotateY / rotateZ (degrees; the legacy transform.rotate means rotateZ) are converted to radians for you. Motion-track transform.rotate* values are radians (engine units).
- A style paint carrying a color but no type defaults to 'solid'; invalid effects entries are dropped.
- Literal \n and \t inside data.text become real newlines and tabs.
Assets
assets.get(uri)- Returns the raw bytes, MIME type and filename behind an asset:// uri, or null. Read a selected image's data.src to process its pixels on a canvas.
assets.register(bytes, { mimeType, originalName })- Registers bytes as a project asset and returns its asset:// uri. Identical content dedupes to the same uri; up to 10MB each (the default plan's per-file upload limit).
Full pixel-editing round trip on the selected image:
const [image] = await api.document.getSelection(); // an image object
const { bytes, mimeType } = await api.assets.get(image.data.src);
const edited = await process(bytes); // your pixel work
const uri = await api.assets.register(edited, { mimeType, originalName: 'edited.png' });
await api.document.applyActions([
{ op: 'update_object', id: image.id, set: { data: { src: uri } } }
], 'Edit image');
Reference the returned uri in an action (e.g. an image's data.src) right away — an asset referenced nowhere becomes eligible for cleanup.
Events
Subscribe with events.on(type, callback) / events.off(type, callback). Callbacks carry no payload — they are a signal to re-read, so query again through the reading API. Exactly the three types below exist; anything else is rejected. Subscriptions are torn down automatically when the plugin closes.
- history:update — whenever the document changes (edits, undo, redo).
- ui:modes:edit:changeSelectedObjects — whenever the selection changes.
- motion:animation:timeUpdate — whenever the animation clock moves (a seek, or every playback frame — debounce).
await api.events.on('ui:modes:edit:changeSelectedObjects', async () => {
const selection = await api.document.getSelection(); // re-query — events carry no payload
render(selection);
});
Theme
Every plugin document receives the editor's design tokens as CSS custom properties, wired to the same light/dark media query the editor uses — style with var(--sshow-…) and a theme flip restyles your panel automatically, no code needed.
--sshow-primary /* accent (#2196F3) */
--sshow-primary-strong /* filled active/selected surface */
--sshow-primary-soft /* its hover */
--sshow-primary-foreground /* text over the accent */
--sshow-secondary
--sshow-foreground /* body text — follows light/dark */
--sshow-background /* panel surface (translucent) */
--sshow-background-solid
--sshow-border-color
--sshow-radius /* 13px */
--sshow-font-size /* 12px — matches native panel text */
--sshow-scrollbar-size /* 6px */
--sshow-scrollbar-radius /* 3px */
Your document also gets the editor's own scrollbar — the slim 6px bar the panels use, instead of the platform's (a 15px system scrollbar on Windows). Declare your own ::-webkit-scrollbar rules to override it.
For script logic (e.g. canvas drawing), read the active mode and resolved colors:
const { mode, colors } = await api.ui.getTheme();
// mode → 'light' | 'dark'
// colors → { primary, primaryForeground, secondary,
// foreground, background, backgroundSolid, borderColor }
// React to a theme flip in JS (styles via var() follow automatically):
matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => render());
Panel UI
ui.resize(size) — request a screen size: a number is a height request, { width, height } carries either axis. The panel resizes to fit your screen (clamped to its own bounds); without a request the screen fills the default panel.
Keyboard — editor shortcuts keep working while your screen has focus: keys your plugin leaves unhandled are relayed to the editor (Tab cycles Design/Animation, Ctrl+Z undoes, Space pans). Keep a key for yourself by calling preventDefault() or stopPropagation() before it reaches window; nothing is relayed while a text field has focus.
Limits and versioning
- The screen document must be self-contained — the network is blocked, so inline your scripts and styles; images work via data:/blob: only. WebAssembly compiles; eval and new Function do not.
- Packages allow up to 64 zip entries, 5MB per file uncompressed and 10MB per package; a registered asset is capped at 10MB as well.
- If plugin.json's api differs from the editor's API version (1), the plugin is refused. The API only grows in ways that keep existing plugins working.
- A failed call rejects with a reason string.
Development loop
Two ways to iterate before anything is published:
- Re-import (web + desktop)
- The + button in the editor's Plugins panel imports a .sshowplugin. A duplicate id is refused — remove the old one with the row's − button first, then re-import.
- devPath hot reload (desktop)
- Point the plugins.devPath setting at your plugin folder (plugin.json + main + icon). Every save re-registers it in open editors, and a running plugin reopens — no zipping while you iterate.
Publishing to the catalog
Anyone can submit a plugin from the developer console at /developers. Every version is human-reviewed before it goes live; verdicts arrive in your inbox and by email.
- 1 Zip plugin.json, your main document and the icon into a .sshowplugin package.
- 2 Upload it at /developers — the server validates the package and extracts the manifest; nothing is re-typed.
- 3 Track the review in the console. Approved versions publish to the catalog immediately.
- Versions are immutable and must increase (x.y.z) — fixing a rejection means submitting a higher version.
- The first submitter owns a manifest id forever; pick a reverse-domain id you control.
- The reviewed zip is exactly what users receive — the server never rewrites a package.