Every operation in Photo Slop is a JSON command — the same bus drives the UI, Python callbacks and AI agents.

Commands

Every operation in Photo Slop is a JSON command — the same bus drives the UI, Python callbacks and AI agents.


The command bus

Nothing in the editor mutates the canvas directly. A toolbar button, a keyboard shortcut, a Python callback and an AI agent all do the same thing: submit a command.

{ "id": "any-unique-string", "type": "layer.addShape",
  "payload": { "shape": "rect", "width": 400, "height": 240, "fill": "#2f9e44" } }

From Python, set the component's command prop. The component runs it, clears command, and always answers on commandResult:

{ "id": "any-unique-string", "type": "layer.addShape", "ok": true, "data": { "layerId": "l_gpcgpdlm" } }

On failure ok is false and error explains why. Rules worth knowing:

from dash import Input, Output, State, callback
from dash_photo_canvas import ops

@callback(Output("pc-canvas", "command"), Input("add-logo", "n_clicks"), prevent_initial_call=True)
def add_logo(_):
    return ops.cmd("layer.addImage", src="https://example.com/logo.png", name="Logo", x=200, y=120)

ops.cmd(type, **payload) fills in a unique id. ops.validate(command) is a cheap structural check (an id, and a type that is one of the published agent commands); the component itself validates payload values and reports problems on commandResult.

Reference

Document and canvas

CommandPayloadNotes
document.get—Returns {document, rev}
document.newwidth?, height?, background?Discards the current document
document.loaddocumentReplace the whole document
canvas.resizewidth, heightMax 16384 px per side. Layers keep their positions; the Document tab's Scale layers option is a batch of canvas.resize plus one layer.update (x, y, scaleX, scaleY) per layer
canvas.backgroundcolorA CSS colour, or transparent

Layers

CommandPayloadNotes
layer.addImagesrc, fit? (auto · contain · cover · none), name?, x?, y?, scale?, angle?, opacity?, blend?, effects?, index?Returns {layerId, natural, scale}. auto shrinks only images larger than 90 % of the artboard
layer.addTexttext, fontFamily?, fontSize?, fill?, textAlign?, width?, …Returns {layerId}
layer.addShapeshape (rect · ellipse · triangle · line), width?, height?, fill?, stroke?, strokeWidth?, rx?Returns {layerId}. fill (here, on text and in layer.update) is a CSS colour or a CSS gradient string — linear-gradient(<angle>deg, <colour> <pct>%, …) with 0deg = to top, or radial-gradient(<colour> <pct>%, …) — painted across the layer's box; the document keeps the string
layer.updatelayerId or layerIds, any creation property, live?live: true skips the undo entry — use it while dragging a sliderText style fields: fontFamily, fontSize, fontWeight, fontStyle, underline, linethrough, fill, stroke, strokeWidth, textAlign, lineHeight; shape: fill, stroke, strokeWidth, rx, width, height. The editor's style bar is this command.
layer.removelayerId? / layerIds?Omit ids to delete the selection
layer.duplicatelayerId?Returns {layerId}
layer.reorderlayerId?, tofront · back · forward · backward · integer index (0 = bottom)
layer.selectlayerIds[] clears the selection
layer.selectAll—Selects every visible, unlocked layer
layer.copy · layer.cut · layer.pastelayerIds? · layerIds? · offset?The engine's internal clipboard (what ⌘C / ⌘X / ⌘V use). Paste lands 24 px further each time
layer.togglelayerId? / layerIds?, prop: visible · lockedFlip a flag on the targets. A locked layer keeps its place (no move, scale or rotate) and is skipped by the pointer, so clicks reach the layers under it; layer.select still selects it
project.save · project.openfilename? · —Download the document JSON · open a file picker for one (open returns at once; the file loads as a document.load)
layer.fitlayerId?, mode?, margin?contain · cover · width · height
layer.croplayerId?, x, y, width, height or reset: trueWindow in the image's natural pixels
layer.fliplayerId?, axis?x (default) or y

Effects

CommandPayload
effect.setlayerId?, effect: {type, value?}, live?
effect.removelayerId?, type
effect.clearlayerId?

Effect types and ranges are listed in the Document Model. Where layerId is optional, the first selected layer is used.

Interactive crop

layer.crop sets a window numerically. The crop tool is the interactive version: a window the user drags and resizes on the canvas, with the rest of the image dimmed and rule-of-thirds guides. It is opened through tool.set and driven by four commands:

CommandPayloadNotes
tool.settool: "crop", layerId?, aspect?Opens the window over an image layer (the selection when layerId is omitted). aspect locks a ratio: a number (1.5) or "original". Returns {tool: "crop", crop: {layerId, x, y, width, height}}
crop.setx?, y?, width?, height?, aspect?Moves or resizes the window programmatically (natural pixels, clamped inside the image)
crop.get—The current window, or null when the tool is not active
crop.apply—Commits: the layer keeps its place on the artboard and only its visible pixels change. Returns {layerId, crop, changed}
crop.cancel—Closes the window without changing anything

Keyboard: C opens the tool on the selected image layer, Enter applies, Esc cancels; a double-click on the window applies too. Any other editing command sent while the tool is open cancels it first, because the window is a proposal, not document state — view.*, document.get and export.render are allowed and leave it open.

View, tools and history

CommandPayload
view.fit—
view.zoomlevel (absolute, 1 = 100 %) or delta (multiplier)
tool.settool: select · hand · draw · crop (see above) · marquee · lasso · wand · paint · erase (the pixel tools: layerId? names the image, else the selected one; they enter pixel mode, see below)
history.undo, history.redo—

Batch

batch runs commands: [{type, payload}, …] in order as one undo step. It stops at the first failing step; earlier steps are kept. On failure the acknowledgement is ok: false with error: "batch stopped at step N: …" and data: {results, failedStep} — the per-step results up to and including the failure — so a caller (the agent loop uses this) knows exactly which steps ran.

Export

export.render renders the artboard and, depending on dest, downloads it or sends it to the server.

FieldMeaning
formatpng · jpeg · webp · svg (in the browser) or tiff · pdf · avif · heic · gif · bmp · ico (transcoded by the server)
scalePixel multiplier, default 1
quality0–1 for lossy formats
regioncanvas (default) or selection
destdownload (default) · disk · r2 · catalog (the signed-in user's catalogue) — see Storage & Export
filename, keyOutput name and, for disk/r2, an optional storage key
name, projectFor dest: catalog: the item's name, and project: true to keep the document JSON beside the PNG so the composition can be reopened
preview: trueRender a small JPEG and return it as data.dataUrl, so an agent can look at the result

The result carries width, height, bytes, format and, for server destinations, a server object with the stored key (a signed url for R2; id, kind and url for the catalogue). dest: catalog fails with 401 when nobody is signed in.

Pixel selections and pixel edits (image layers)

The pixel tools act on ONE image layer's pixels in its natural pixel space. Picking a pixel tool, or sending any select.* / pixels.* command, enters pixel mode: the image loses its handles (a dashed accent frame marks it), the other layers freeze, the options bar tints, and the keyboard scopes to the SELECTION — ⌘C / ⌘X copy or cut the selected pixels, ⌘V pastes them as a new layer in the same place, ⌘J lifts them into a layer, ⌫ erases them, ⌘A / ⌘D select all / none, ⌘⇧I inverts, the arrows move the outline, Esc deselects and then leaves the mode. Picking another image in the Layers panel retargets the tools; picking anything else ends the mode. The selection is never part of the document and never exported. Points are artboard pixels unless space: "image".

CommandPayloadNotes
select.wandlayerId?, x, y, tolerance? (0–100, default 32), contiguous? (default true), mode? (replace · add · subtract), space?Magic wand from the colour under the point; alpha counts, so a transparent region never bleeds into a painted one. Returns the summary: pixels, coverage, bbox (natural px), bboxArtboard
select.lassolayerId?, points: [[x, y], …] (≥3), mode?, space?Polygon selection (even-odd)
select.rectlayerId?, x, y, width, height, mode?, space?Rectangle; x, y is a corner
select.all · select.none · select.invert · select.getlayerId?Every pixel · drop · invert · the summary (or null)
select.movedx, dy, space?Shift the outline without touching pixels (the arrow keys)
pixels.filllayerId?, color?Fill the selection — the whole image when nothing is selected — with a colour (default: the brush colour). A pixel edit: one undo step, edited: true, originalSrc kept
pixels.erase · pixels.keeplayerId?Erase the selection to transparent · keep the selection and drop everything else (the mask)
pixels.paintlayerId?, points, color?, size? (natural px, default 24), erase?, space?A round-brush stroke, clipped to the selection when there is one
pixels.brushcolor?, size?The brush the interactive paint / erase tools and the defaults use; emits a brush event
pixels.copy · pixels.cutlayerId?The selected pixels to the editor's clipboard as an image layer (and the OS clipboard where the browser allows); layer.paste then adds it in the same place. Cut also erases
pixels.liftlayerId?, name?New layer from the selection, directly above the source, selected; the mode ends
pixels.revertlayerId?Every pixel edit on the layer undone at once: src goes back to originalSrc

A pixel edit replaces the layer's source. The new pixels ride under a blob: URL — never a data URL, which would put megabytes into every history entry and the document prop — and are uploaded through the asset endpoint in the background; the layer, the clipboard and history then move to the stable URL, so a saved project names a URL every page can resolve.


Source: /guide/commands

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: