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 six 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'
| 'layerGroups';
interface EditorDocumentV1 {
schemaVersion: 1;
keys: KeyMappings;
keyPositions: KeyPositions;
statPositions: StatItemPositions;
graphPositions: GraphItemPositions;
knobPositions: KnobItemPositions;
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) 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 with a missing or unknown id gets a fresh one
from the backend. 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. See the
Keys API for the identity rules.
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 six 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;
}| Code | Meaning | retryable |
|---|---|---|
REVISION_CONFLICT | baseRevision is stale; read, reconcile, and commit again | true |
PLUGIN_REVISION_CONFLICT | The plugin-scoped revision is stale; read, reconcile, and commit again | true |
VALIDATION_FAILED | The completed document violates an editor validation rule | false |
TOO_MANY_GESTURE_IDS | gestureIds exceeds 32 entries or the combined unique set exceeds 32 IDs | false |
INVALID_GESTURE_ID | A gesture ID is not a UUID within the 64-byte limit | false |
PAIRED_UPDATE_REQUIRED | A structural key change omitted its paired collection | false |
MULTI_KEY_UNSUPPORTED | A keys write did not declare multiKey while multi-key slots exist | false |
MUTATION_ID_REUSED | The same mutation ID was used for a different request | false |
IO_ERROR | The document could not be persisted | true |
HISTORY_IN_PROGRESS | An undo/redo barrier is in progress; retry after it settles | true |
HISTORY_EPOCH_CONFLICT | The observed history epoch is stale; re-read and commit again | true |
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 event | Replacement |
|---|---|
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.