Declarative API (defineElement)
dmn.plugin.defineElement is an API that lets you define plugin UI declaratively without complex DOM manipulation.
Key Benefits
| Feature | Description |
|---|---|
| Automatic settings UI | Just define settings schema, panel (default) or modal auto-generated |
| Instance state isolation | Create multiple panels from the same plugin |
| Tab-based isolation | Panels only appear in the tab they were created |
| Automatic state sync | Main ↔ Overlay state auto-synchronized |
| Context menu integration | Right-click menu auto-generated |
| Lifecycle management | Auto mount/unmount on tab switch |
| Free resize | Resize directly in the grid |
Basic Structure
// @id my-plugin
dmn.plugin.defineElement({
// Plugin name (shown in context menu)
name: "My Plugin",
// Max instances (optional, 0 = unlimited)
maxInstances: 1,
// Enable grid resize (optional, default: false)
resizable: true,
// Axis to preserve on settings change (optional, only when resizable is true, default: "both")
// "width" | "height" | "both" | "none"
preserveAxis: "both",
// Resize anchor (optional, default: "top-left")
resizeAnchor: "center",
// Context menu settings
contextMenu: {
create: "Create Panel",
delete: "Delete Panel",
items: [
/* custom menu items */
],
},
// Settings UI mode (optional, default: "panel")
// "panel" | "modal"
settingsUI: "panel",
// Settings schema (auto UI generation)
settings: {
/* settings definition */
},
// i18n messages
messages: {
/* translation data */
},
// Main window preview state
previewState: {
/* initial state for preview */
},
// HTML template function
template: (state, settings, helpers) => {
/* return template */
},
// Overlay mount logic
onMount: (context) => {
/* initialization logic */
return () => {
/* cleanup */
};
},
});Settings Schema (settings)
Define settings schema to auto-generate settings UI. Default is property panel;
use settingsUI: "modal" for modal style.
Supported Types
settings: {
// Card section: starts a new settings card
appearanceSection: {
type: "section",
label: "Appearance", // optional caption above the card
},
// String
nickname: {
type: "string",
default: "",
label: "Nickname",
placeholder: "Enter value", // optional
},
// Number
fontSize: {
type: "number",
default: 14,
label: "Font Size",
min: 8, // optional
max: 48, // optional
step: 2, // optional
},
// Boolean (toggle)
showGraph: {
type: "boolean",
default: true,
label: "Show Graph",
},
// Color
textColor: {
type: "color",
default: "#FFFFFF",
label: "Text Color",
},
// Select (dropdown)
theme: {
type: "select",
default: "dark",
label: "Theme",
options: [
{ label: "Dark", value: "dark" },
{ label: "Light", value: "light" },
],
},
},type: "section" starts a new card. Its key is a layout identifier and is never
included in stored values, defaults, getSettings(), or change callbacks. Settings
before the first section appear in an implicit untitled card. A section without a
label still creates a card boundary.
The legacy type: "divider" (an in-card separator line) was removed together
with the introduction of sections. Divider entries in existing plugins are
ignored (no crash). Use sections to group settings instead.
Sections behave identically in the property panel and with settingsUI: "modal".
Conditional Visibility (visible)
Add a visible property to setting items to dynamically show/hide them based on other setting values.
Settings without visible are always shown (same as existing behavior).
settings: {
// Mode selection
showGraph: {
type: "boolean",
default: true,
label: "Show Graph",
},
graphSection: {
type: "section",
label: "Graph",
visible: (settings) => settings.showGraph,
},
// Static boolean, always hidden
hiddenOption: {
type: "number",
default: 5,
label: "Hidden Option",
visible: false,
},
// Function, dynamically show/hide based on other settings
graphColor: {
type: "color",
default: "#86EFAC",
label: "Graph Color",
visible: (settings) => settings.showGraph,
},
graphSpeed: {
type: "number",
default: 1000,
label: "Graph Speed (ms)",
visible: (settings) => settings.showGraph,
},
},Hidden settings retain their stored values. Only display is controlled, no data loss.
On a section, visible hides the entire group up to the next section. The boundary
is retained even while hidden, so adjacent groups never merge. If every value setting
inside a section is hidden, its card and caption are hidden too. If a visibility
function throws, that item or section is hidden (fail-closed) and the error is logged.
The same rules and empty-state behavior apply to panel and modal settings UIs.
The visible signature follows the same pattern as PluginMenuItem.visible in context menus:
visible?: boolean | ((settings: Record<string, any>) => boolean);Accessing Settings
// In template
template: (state, settings, { html }) => html`
<div style="color: ${settings.textColor};">
${settings.showGraph ? html`<div class="graph">...</div>` : ""}
</div>
`,
// In onMount
onMount: ({ getSettings }) => {
const settings = getSettings();
console.log("Current settings:", settings);
},Resize Anchor (resizeAnchor)
Sets the anchor position when element size changes. Default is "top-left".
Supported Anchor Positions (9 directions)
| Value | Description |
|---|---|
top-left | Top-left (default) |
top-center | Top center |
top-right | Top-right |
center-left | Center-left |
center | Center |
center-right | Center-right |
bottom-left | Bottom-left |
bottom-center | Bottom center |
bottom-right | Bottom-right |
Anchor Behavior
top-left: the top-left corner stays fixed as the size changes (default)center: the center position stays fixed as the size changesbottom-right: the bottom-right corner stays fixed as the size changes
Definition Level Setting
Define default anchor for all instances:
dmn.plugin.defineElement({
name: "Centered Panel",
resizeAnchor: "center", // Default anchor for all instances
// ...
});Dynamic Anchor Change (onMount)
Use setAnchor() and getAnchor() in onMount to dynamically change anchor:
dmn.plugin.defineElement({
name: "Dynamic Anchor Panel",
onMount: ({ setAnchor, getAnchor, getSettings }) => {
// Check current anchor
console.log("Current anchor:", getAnchor()); // "top-left"
// Change anchor to center
setAnchor("center");
// Change anchor based on condition
const settings = getSettings();
if (settings.expandFromCenter) {
setAnchor("center");
} else {
setAnchor("top-left");
}
},
});Anchor Priority
- Per-instance
resizeAnchor(set viasetAnchor) resizeAnchoron the definition- Default
"top-left"
Resize Settings (resizable, preserveAxis)
Use resizable option to let users resize elements directly in the grid.
resizable
Set resizable: true to show 8-direction resize handles:
The initial size is 200×150. A saved or estimated size takes precedence when available.
dmn.plugin.defineElement({
name: "Resizable Panel",
resizable: true, // Enable grid resize
// ...
});Responsive design required: When using resizable, plugin internal
elements must be responsive. Use relative units (%, flex, fr, etc.)
instead of fixed sizes (px), and apply width: 100%; height: 100% to root
element. Otherwise, internal content won’t adjust to container size on resize.
preserveAxis
Specifies which size axis to preserve when settings change. Only applies when resizable: true.
| Value | Description |
|---|---|
both | Preserve both width and height (default) |
width | Preserve width, adjust height to content |
height | Preserve height, adjust width to content |
none | Fit both to content (resets resize effect) |
dmn.plugin.defineElement({
name: "KPS Panel",
resizable: true,
preserveAxis: "width", // Preserve width on settings change
// ...
});Usage Example
For a panel with a graph toggle, like the KPS panel:
- With
preserveAxis: "width", the width is preserved when the graph is toggled on and off - The height adjusts automatically depending on whether the graph is shown
dmn.plugin.defineElement({
name: "KPS Panel",
resizable: true,
preserveAxis: "width",
resizeAnchor: "bottom-left", // Bottom fixed (graph expands upward)
settings: {
showGraph: { type: "boolean", default: true, label: "Show Graph" },
},
template: (state, settings, { html }) => html`
<div style="width: 100%; height: 100%;">
<div class="kps-value">${state.kps ?? 0}</div>
${settings.showGraph ? html`<div class="graph">...</div>` : ""}
</div>
`,
});Apply width: 100%; height: 100% to the root element of a resizable
element’s template so the content fills the size the user chose.
Context Menu (contextMenu)
Basic Setup
contextMenu: {
create: "Create Panel", // Right-click on empty grid space
delete: "Delete Panel", // Right-click on the panel
},Custom Menu Items
contextMenu: {
create: "Create Panel",
delete: "Delete Panel",
items: [
{
label: "Reset Stats",
onClick: ({ actions }) => actions.reset(),
},
{
label: "Export Data",
onClick: async ({ element, actions }) => {
await actions.exportData();
},
// Conditional visibility
visible: ({ element }) => !!element.settings.enableExport,
// Conditional disable
disabled: ({ element }) => element.settings.exportFormat === "none",
// Position (top or bottom, default: bottom)
position: "bottom",
},
],
},
// Register actions in onMount
onMount: ({ expose, setState }) => {
expose({
reset: () => setState({ count: 0 }),
exportData: async () => { /* export logic */ },
});
},Items with position: "top" appear above the built-in menu entries; the rest
appear below them.
Menu predicates run in the main window with { element, actions }. Overlay
runtime state set via setState is not synced into this context by default, so
element.state here reflects the main-window state (initialized from
previewState). To base conditions on overlay state, declare the keys in
contextMenuStateKeys.
Overlay State in Predicates (contextMenuStateKeys)
Declare overlay state keys that menu predicates need. Only the declared keys are
mirrored to the main window (on setState changes) and merged into
element.state during predicate evaluation. High-frequency state is never sent
unless declared, and the main-window preview state stays untouched.
// Mirror only `active` for menu predicates
contextMenuStateKeys: ["active"],
contextMenu: {
items: [
{
label: "Stop Capture",
action: "stopCapture",
visible: ({ element }) => !!element.state?.active,
},
],
},Template (template)
The template function receives state and settings and returns the UI.
See Template Syntax for the full syntax.
template: (state, settings, { html, t, locale }) => html`
<div style="color: ${settings.textColor};">
<strong>${state.value}</strong>
${settings.showDetails ? html`
<div class="details">Details</div>
` : ""}
</div>
`,Template Helpers
| Helper | Description |
|---|---|
html | htm tag function (creates React Elements) |
t(key) | i18n translation function |
locale | Current locale code |
Preview State (previewState)
Initial state shown as a preview in the main window. The actual logic runs only in the overlay, so the main window displays this state.
previewState: {
kps: 12,
history: [5, 8, 12, 10, 15],
},Mount Logic (onMount)
onMount runs only in the overlay and implements the actual behavior.
Context Object
onMount: (context) => {
const {
setState, // Update state
getSettings, // Get current settings
setAnchor, // Set resize anchor
getAnchor, // Get current anchor
onHook, // Register event hooks
expose, // Expose functions for the context menu
locale, // Current locale code
t, // Translation function
onLocaleChange, // Subscribe to locale changes
onSettingsChange, // Subscribe to settings changes
} = context;
// Return cleanup function
return () => { /* cleanup */ };
},Event Hooks (onHook)
onMount: ({ onHook, setState }) => {
// Mapped key events
onHook("key", ({ key, state, mode }) => {
if (state === "DOWN") {
console.log(`${key} pressed (${mode})`);
}
});
// All raw input events (keyboard, mouse)
onHook("rawKey", ({ device, label, state }) => {
console.log(`[${device}] ${label} ${state}`);
});
},The "key" hook receives the same canonical payload as
dmn.keys.onKeyState. On UP, holdDurationMs is present only when the same
physical input produced both canonical transitions; otherwise it is omitted.
Track the matching canonical DOWN and UP timestamps when the full active
duration is required.
Settings Change Detection (onSettingsChange)
Use this when you need to react to settings changes immediately.
In most cases, reading the latest settings with getSettings() is enough.
Use onSettingsChange only when you need external API calls or resource
re-initialization.
onMount: ({ setState, getSettings, onSettingsChange }) => {
const fetchData = async (nickname) => {
const response = await fetch(`/api/user/${nickname}`);
const data = await response.json();
setState({ data });
};
// Initial load
fetchData(getSettings().nickname);
// Refetch when the nickname changes
onSettingsChange((newSettings, oldSettings) => {
if (newSettings.nickname !== oldSettings.nickname) {
fetchData(newSettings.nickname);
}
});
},i18n Support (messages)
dmn.plugin.defineElement({
name: "Localized Panel",
messages: {
ko: {
"menu.create": "패널 생성",
"menu.delete": "패널 삭제",
"label.count": "카운트",
},
en: {
"menu.create": "Create Panel",
"menu.delete": "Delete Panel",
"label.count": "Count",
},
},
contextMenu: {
create: "menu.create", // Use message keys
delete: "menu.delete",
},
settings: {
count: {
type: "number",
default: 0,
label: "label.count", // Use message keys
},
},
template: (state, settings, { html, t, locale }) => html`
<div data-locale="${locale}">${t("label.count")}: ${state.value ?? 0}</div>
`,
});Practical Example: KPS Panel
// @id kps-panel
dmn.plugin.defineElement({
name: "KPS Panel",
maxInstances: 1,
contextMenu: {
create: "Create KPS Panel",
delete: "Delete KPS Panel",
items: [
{
label: "Reset Stats",
onClick: ({ actions }) => actions.reset(),
},
],
},
settings: {
showGraph: { type: "boolean", default: true, label: "Show Graph" },
textColor: { type: "color", default: "#FFFFFF", label: "Text Color" },
graphColor: {
type: "color",
default: "#86EFAC",
label: "Graph Color",
visible: (s) => s.showGraph,
},
},
previewState: {
kps: 12,
max: 20,
history: [5, 8, 12, 15, 10, 12],
},
template: (state, settings, { html }) => html`
<div
style="
background: rgba(0, 0, 0, 0.8);
padding: 16px;
border-radius: 8px;
color: ${settings.textColor};
min-width: 120px;
"
>
<div style="font-size: 32px; font-weight: bold;">
${state.kps ?? 0}
<span style="font-size: 14px; opacity: 0.7;">KPS</span>
</div>
${settings.showGraph
? html`
<div
style="
display: flex;
gap: 2px;
height: 40px;
align-items: flex-end;
margin-top: 8px;
"
>
${(state.history ?? []).map((v) => {
const height = state.max ? (v / state.max) * 100 : 0;
return html`
<div
style="
flex: 1;
height: ${height}%;
background: ${settings.graphColor};
border-radius: 2px 2px 0 0;
opacity: 0.7;
"
></div>
`;
})}
</div>
`
: ""}
</div>
`,
onMount: ({ setState, expose, onHook }) => {
const timestamps = [];
let max = 0;
const historySize = 20;
const history = [];
onHook("key", ({ state }) => {
if (state === "DOWN") {
timestamps.push(Date.now());
}
});
const interval = setInterval(() => {
const now = Date.now();
// Keep only timestamps within the last second
while (timestamps.length && timestamps[0] < now - 1000) {
timestamps.shift();
}
const kps = timestamps.length;
max = Math.max(max, kps);
history.push(kps);
if (history.length > historySize) history.shift();
setState({ kps, max, history: [...history] });
}, 50);
expose({
reset: () => {
timestamps.length = 0;
history.length = 0;
max = 0;
setState({ kps: 0, max: 0, history: [] });
},
});
return () => clearInterval(interval);
},
});