Skip to Content

Editor API

OBS is read-only. Native Tauri main and overlay windows can write through the revision coordinator; OBS can only read and subscribe to the canonical document.

dmn.editor reads and updates the seven collections that make up the editor layout as one revisioned document. Use it when one user action changes more than one collection, or when a consumer needs a single ordered change stream.

Document Types

type EditorField = | 'keys' | 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' | 'spritePositions' | 'layerGroups'; interface EditorDocumentV1 { schemaVersion: 1; keys: KeyMappings; keyPositions: KeyPositions; statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; spritePositions: SpritePositions; layerGroups: LayerGroups; } type EditorPatchV1 = { schemaVersion: 1; } & Partial<Pick<EditorDocumentV1, EditorField>>;

Each field included in an EditorPatchV1 is the complete canonical value of that top-level collection. It is not an item-level diff.

Since the multi-key update, keys entries in EditorDocumentV1 (returned by get()) and in EditorPatchV1 (delivered by onCommitted()) use the KeySlot union (string | MultiKeySlot), not plain strings. See the Keys API for the slot shape and canonical identifier rules.

Every element in the position collections (keyPositions, statPositions, graphPositions, knobPositions, spritePositions) carries a stable id (UUID) assigned and owned by the app. Treat it as opaque: echo back the value you read and never invent one. A written element whose id is missing or unknown inherits the id of an existing element whose remaining fields match; with no match, the backend assigns a fresh one. Omitting an id does not guarantee a fresh identity, and editing an element’s values while omitting its id may give it one. If the same id appears more than once, for example when you copy an element you read and append it, the element in its original slot keeps the identity and the remaining copies get fresh ones. The poseId carried by each pose of a sprite element follows the same rules. See the Keys API for the identity rules.

Sprite Position Types

Reactive sprites are image layers that switch poses while their trigger keys are held. Unlike key elements they are not index-coupled to key mappings; each pose references the responsible key element ids directly. Documents you read always carry id and poseId; echo both back when you write. The collection and value limits noted below are enforced when you commit; documents you read echo stored data as is.

// Keyed by tab/mode id (e.g., "4key") type SpritePositions = Record<string, ReactiveSpritePosition[]>; interface ReactiveSpritePosition { id?: string; // stable element identity (UUID) dx: number; // element box origin and size. The box is the base image box and feeds overlay window sizing dy: number; width: number; height: number; rotation?: number; // placement rotation around the element-box center, -180–180 deg, default 0 hidden: boolean; zIndex: number | null; layerName?: string | null; // omitted by the backend when unset groupId?: string | null; // omitted by the backend when unset className: string | null; useInlineStyles: boolean | null; baseImage: string | null; // image shown when no pose matches; always fills the element box referenceNaturalSize: { source: string | null; width: number; height: number; } | null; // intrinsic pixel size that defines the pixel scale (integers, 1–32768). source must equal baseImage when one is set and be null otherwise; null falls back to drawing every image in the element box pivot: { x: number; y: number }; // normalized shared axis, 0–1 each, default {0.5, 0.5}. Base-image pivot and transform origin for every pose idleTransform: SpriteTransform; // base transform when no pose matches poses: SpritePose[]; // commits accept at most 64 activation: 'whileHeld' | 'onPress'; // default whileHeld. onPress plays the pose once on key-down, then returns to idle pressDurationMs: number; // onPress playback duration, 1–5000, default 300 transitionMs: number; // pose transition duration, 0–1000, default 0. Ignored by onPress, which plays for pressDurationMs transitionEasing: string; // CSS easing, default 'cubic-bezier(0.4, 0, 0.2, 1)' } interface SpritePose { poseId?: string; // stable pose identity (UUID) name?: string | null; // omitted by the backend when unset triggers: string[]; // key element ids; commits accept at most 512, sorted and deduplicated transform: SpriteTransform; pivot?: { x: number; y: number } | null; // independent pivot within the pose image; null or omission follows the sprite's shared pivot imageOverride: string | null; // replaces baseImage while this pose is active imageOverrideMetrics: { source: string; width: number; height: number; } | null; // intrinsic pixel size of imageOverride (integers, 1–32768). Optional; when set, source must equal the path exactly. Normalized to null when imageOverride is absent } interface SpriteTransform { x: number; // -2000–2000 px y: number; // -2000–2000 px rotation: number; // -180–180 deg scale: number; // 0.1–10 }

The element-level rotation rotates the entire sprite, including all poses, around the center of its logical box. It is independent of idleTransform.rotation and each pose’s transform.rotation, which remain local rotations around the shared pivot. Rotating a selection changes the sprite’s dx, dy, and element-level rotation together; pose transforms, triggers, image references, and transition directions remain unchanged. New sprites default to 0 when rotation is omitted. For an existing sprite, schemaVersion 1 and 2 commits preserve its stored rotation when the field is omitted. null, non-finite values, and values outside -180..180 are rejected.

A pose activates while the set of held keys, among the keys the sprite references, exactly matches its trigger set; keys the sprite does not reference are ignored. When no pose matches exactly, the single-trigger poses of the held keys are blended with equal weight: x, y and scale are averaged, rotation uses a circular mean, and the image comes from the first override in poseId order. Poses with two or more triggers never take part in blending, so a sprite whose held keys only appear in such poses returns to idle. Two poses of one sprite may not share the same trigger set, and commits reject empty trigger lists.

The element box is the base-image box. The base image always fills it, and the shared axis P is the sprite pivot × (width, height). A pose whose pivot is null or omitted uses the same normalized point. A pose with a pivot value aligns that independent point within its image to P. A pose image with a different intrinsic size is drawn at intrinsic pixels × the reference scale, where the reference scale is (width, height) ÷ referenceNaturalSize per axis. Without a reference size or the pose image’s own metrics, the image uses the element-box size. Moving the shared pivot keeps the base image in place while linked poses follow the new axis. Poses with an independent pivot keep their screen position by compensating x/y in the same commit. Moving a pose pivot or switching the link also compensates x/y to avoid a sudden image jump. An out-of-range pivot rejects the commit with VALIDATION_FAILED and a details.validationCode of INVALID_SPRITE_PIVOT or INVALID_SPRITE_POSE_PIVOT. Invalid image metrics use STALE_SPRITE_IMAGE_METRICS or INVALID_SPRITE_IMAGE_METRICS. A schemaVersion 1 patch that omits pivot, referenceNaturalSize, imageOverrideMetrics, activation, or pressDurationMs on an existing element or pose keeps the stored value. Sending null for a pose pivot links it to the shared pivot. Changing baseImage or imageOverride while omitting its metrics clears them.

Read the Current Document

dmn.editor.get(): Promise<EditorGetResult>

Returns the current revision and its matching full document in one snapshot.

interface EditorGetResult { revision: number; document: EditorDocumentV1; } const { revision, document } = await dmn.editor.get(); console.log(revision, document.keyPositions);

Keep the returned revision. A later commit uses it as baseRevision so that an older snapshot cannot silently overwrite a newer edit.

Commit Changes Atomically

dmn.editor.commit(request): Promise<EditorPluginCommitResult>

Merges changes into the document at baseRevision, validates the completed document, and persists all included collections in one atomic replacement. A rejected commit does not partially apply its changes. DM Note requests the strongest file and directory durability barriers available on each supported platform, while no application can guarantee recovery from hardware or firmware that violates those barriers.

The app’s Undo/Redo path also restores the seven editor collections, custom-tab metadata, selected mode, counters, preset settings, and per-tab note settings in one backend store transaction. This internal command is intentionally not part of the public plugin API.

interface EditorCommitRequest { baseRevision: number; mutationId: string; gestureId?: string; gestureIds?: string[]; // Declares multi-key slot support for commits that include `keys` multiKey?: boolean; changes: EditorPatchV1; } interface EditorPluginCommitResult { revision: number; changedFields: EditorField[]; } const snapshot = await dmn.editor.get(); const result = await dmn.editor.commit({ baseRevision: snapshot.revision, mutationId: crypto.randomUUID(), changes: { schemaVersion: 1, statPositions: nextStatPositions, layerGroups: nextLayerGroups, }, }); console.log('Committed revision:', result.revision);

The request accepts only the six keys shown above: baseRevision, mutationId, changes, gestureId, gestureIds, and multiKey. Any other key rejects with a TypeError before the request reaches the backend, and so does a request that is not a plain JSON-serializable object. These rejections carry no errorCode, so a handler that branches on errorCode alone will not recognize them.

mutationId must be a UUID string no longer than 64 bytes. A recent request retried with the same ID in the current app process is deduplicated. This is intended for immediate IPC retries; the in-memory deduplication window does not survive an app restart. Reusing a retained ID for a different request is rejected.

gestureId is the representative history gesture. gestureIds carries every preview session coalesced into the request so the committed event can echo the complete set. Both fields are optional and accept UUIDs only. The gestureIds array can contain at most 32 entries, and the combined unique set across both fields is also limited to 32 IDs.

If the submitted values are already current, the result keeps the current revision, returns an empty changedFields, and emits no canonical committed event. Compatibility wrappers may still project their legacy per-field refresh event once; retrying the same mutationId does not project it again.

Paired key structure changes

keys[mode][i] and keyPositions[mode][i] describe the same item by index. When an edit adds or removes a mode, or changes an array length, submit both collections in the same commit. Their mode sets and lengths must match.

await dmn.editor.commit({ baseRevision, mutationId: crypto.randomUUID(), multiKey: true, changes: { schemaVersion: 1, keys: nextKeys, keyPositions: nextKeyPositions, }, });

A same-shape edit, such as changing a key label or a position property, may update only its own collection. A shape-changing keys-only or keyPositions-only request is rejected with PAIRED_UPDATE_REQUIRED.

A commit that includes keys must also declare multiKey: true whenever the current mappings contain at least one multi-key slot. An undeclared write is rejected with MULTI_KEY_UNSUPPORTED (non-retryable) so that a plugin unaware of multi-key slots cannot destroy them by round-tripping mappings.

When using the compatibility dmn.keys API, call dmn.keys.updateWithPositions(keys, keyPositions) for these structural changes. Do not split them across update() and updatePositions().

Commit Errors

commit() rejects with this structured error. Branch on errorCode, not on the human-readable message.

type EditorCommitErrorCode = | 'REVISION_CONFLICT' | 'PLUGIN_REVISION_CONFLICT' | 'VALIDATION_FAILED' | 'TOO_MANY_GESTURE_IDS' | 'INVALID_GESTURE_ID' | 'PAIRED_UPDATE_REQUIRED' | 'MULTI_KEY_UNSUPPORTED' | 'MUTATION_ID_REUSED' | 'HISTORY_IN_PROGRESS' | 'HISTORY_EPOCH_CONFLICT' | 'IO_ERROR'; interface EditorCommitError { errorCode: EditorCommitErrorCode; message: string; details?: { currentRevision?: number; validationCode?: string; field?: string; currentHistoryEpoch?: number; }; retryable: boolean; }
CodeMeaningretryable
REVISION_CONFLICTbaseRevision is stale; read, reconcile, and commit againtrue
PLUGIN_REVISION_CONFLICTThe plugin-scoped revision is stale; read, reconcile, and commit againtrue
VALIDATION_FAILEDThe completed document violates an editor validation rulefalse
TOO_MANY_GESTURE_IDSgestureIds exceeds 32 entries or the combined unique set exceeds 32 IDsfalse
INVALID_GESTURE_IDA gesture ID is not a UUID within the 64-byte limitfalse
PAIRED_UPDATE_REQUIREDA structural key change omitted its paired collectionfalse
MULTI_KEY_UNSUPPORTEDA keys write did not declare multiKey while multi-key slots existfalse
MUTATION_ID_REUSEDThe same mutation ID was used for a different requestfalse
IO_ERRORThe document could not be persistedtrue
HISTORY_IN_PROGRESSAn undo/redo barrier is in progress; retry after it settlestrue
HISTORY_EPOCH_CONFLICTThe observed history epoch is stale; re-read and commit againtrue

details.currentRevision, details.validationCode, details.field, or details.currentHistoryEpoch is included when it applies to the error.

Subscribe to Committed Changes

dmn.editor.onCommitted(callback): ReadyUnsubscribe

Subscribes to the canonical editor:committed stream. One event represents one successful atomic editor commit.

interface EditorCommittedV1 { schemaVersion: 1; revision: number; mutationId: string; gestureId?: string | null; gestureIds?: string[]; origin?: string; changedFields: EditorField[]; // patch.keys uses the KeySlot union (string | MultiKeySlot) patch: EditorPatchV1; } const unsubscribe = dmn.editor.onCommitted((event) => { console.log(event.revision, event.changedFields); applyEditorPatch(event.patch); }); await unsubscribe.ready; dmn.plugin.registerCleanup(() => { unsubscribe(); });

The returned unsubscribe function has a ready: Promise<void> property. Await it when no event may be missed before taking a snapshot.

Events can arrive before the matching commit() promise resolves. Use mutationId and revision to avoid applying the same change twice. If an observed revision skips one or more values, call dmn.editor.get() and replace the local snapshot. Ignore unknown origin values and future unknown fields.

Legacy Change Events

The per-collection events below remain available with their existing payloads for plugin compatibility, but they are deprecated for new editor state synchronization. Use dmn.editor.onCommitted() so one multi-collection edit is observed as one ordered change.

Compatibility eventReplacement
dmn.keys.onChanged()dmn.editor.onCommitted()
dmn.keys.onPositionsChanged()dmn.editor.onCommitted()
dmn.statItems.onPositionsChanged()dmn.editor.onCommitted()
dmn.graphItems.onPositionsChanged()dmn.editor.onCommitted()
dmn.knobItems.onPositionsChanged()dmn.editor.onCommitted()
dmn.layerGroups.onChanged()dmn.editor.onCommitted()

Existing plugins do not need to migrate immediately. New synchronization code should subscribe to only the canonical event stream instead of applying both the canonical and compatibility events.