diff --git a/docs/content/en/advanced/page.mdx b/docs/content/en/advanced/page.mdx index b15e7256..b4218cbf 100644 --- a/docs/content/en/advanced/page.mdx +++ b/docs/content/en/advanced/page.mdx @@ -171,7 +171,7 @@ dmn.plugin.defineElement({ Only call `setState` when state actually changes: -```javascript +```javascript fragment=object-members onMount: ({ setState, getSettings }) => { let lastKps = 0; @@ -185,7 +185,7 @@ onMount: ({ setState, getSettings }) => { }, 100); return () => clearInterval(interval); -}; +}, ``` ## Multi-Window Patterns diff --git a/docs/content/en/api-reference/_meta.js b/docs/content/en/api-reference/_meta.js index 944a3cb3..7b2712b2 100644 --- a/docs/content/en/api-reference/_meta.js +++ b/docs/content/en/api-reference/_meta.js @@ -6,6 +6,7 @@ export default { settings: "Settings", overlay: "Overlay", "css-js": "CSS/JS", + sound: "Sound", presets: "Presets", i18n: "i18n", plugin: "Plugin", diff --git a/docs/content/en/api-reference/index/page.mdx b/docs/content/en/api-reference/index/page.mdx index b1340683..9f7d76d7 100644 --- a/docs/content/en/api-reference/index/page.mdx +++ b/docs/content/en/api-reference/index/page.mdx @@ -18,6 +18,7 @@ All plugins access DM Note features through the `dmn` global object. | [`dmn.knobItems`](/docs/api-reference/knobs) | HID knob elements | | [`dmn.settings`](/docs/api-reference/settings) | App settings get/set | | [`dmn.overlay`](/docs/api-reference/overlay) | Overlay control | +| [`dmn.sound`](/docs/api-reference/sound) | Key sound library | | [`dmn.css`](/docs/api-reference/css-js#css) | CSS custom code | | [`dmn.js`](/docs/api-reference/css-js#js) | JS plugin management | | [`dmn.presets`](/docs/api-reference/presets) | Preset save/load | diff --git a/docs/content/en/api-reference/plugin/page.mdx b/docs/content/en/api-reference/plugin/page.mdx index 3329c4b6..e259a0f9 100644 --- a/docs/content/en/api-reference/plugin/page.mdx +++ b/docs/content/en/api-reference/plugin/page.mdx @@ -9,7 +9,7 @@ Core plugin registration and management API. ## Element Definition -### `dmn.plugin.defineElement(options): void` +### `dmn.plugin.defineElement(definition): void` Defines a draggable overlay element. See [Declarative API](/docs/declarative-api) for details. @@ -44,17 +44,200 @@ dmn.plugin.defineElement({ --- +## PluginDefinition + +The definition object passed to `defineElement`. + +```typescript +interface PluginDefinition { + /** Plugin name (shown in the context menu) */ + name: string; + + /** Maximum instance count (0 = unlimited, default) */ + maxInstances?: number; + + /** + * Whether the element can be resized in the grid + * @default false + */ + resizable?: boolean; + + /** + * Which size axis to preserve when settings change + * Only applies when resizable is true + * - 'width': preserve width, height follows content + * - 'height': preserve height, width follows content + * - 'both': preserve both (default) + * - 'none': both follow content + * @default 'both' + */ + preserveAxis?: "width" | "height" | "both" | "none"; + + /** + * Resize anchor (fixed point when the size changes) + * @default "top-left" + */ + resizeAnchor?: ElementResizeAnchor; + + /** Context menu setup */ + contextMenu?: { + create?: string; // Create menu label + delete?: string; // Delete menu label + items?: PluginDefinitionContextMenuItem[]; + }; + + /** + * Overlay state keys mirrored to the main window for menu predicates + * (opt-in, low-frequency). Only declared keys are sent on setState changes + */ + contextMenuStateKeys?: string[]; + + /** + * Settings UI mode + * @default "panel" + */ + settingsUI?: "panel" | "modal"; + + /** Settings schema */ + settings?: Record; + + /** i18n messages (nested objects allowed — PluginMessages) */ + messages?: Record>; + + /** Preview state (for the main window) */ + previewState?: Record; + + /** Template function */ + template: ( + state: Record, + settings: Record, + helpers: DisplayElementTemplateHelpers + ) => DisplayElementTemplateResult | string; + + /** Mount logic (runs only in the overlay) */ + onMount?: (context: PluginDefinitionHookContext) => void | (() => void); +} +``` + +## ElementResizeAnchor + +The type that specifies the fixed point when an element's size changes. + +```typescript +type ElementResizeAnchor = + | "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 + +When the size changes, the specified anchor position stays fixed and the element +expands or shrinks in the other directions. + +| Anchor | Behavior | +| -------------- | ------------------------------------ | +| `top-left` | Expands/shrinks right and down | +| `center` | Expands/shrinks evenly in all directions | +| `bottom-right` | Expands/shrinks left and up | + +## PluginDefinitionHookContext + +The context object passed to the `onMount` callback. + +```typescript +interface PluginDefinitionHookContext { + /** Update state */ + setState: (updates: Record) => void; + + /** Get current settings */ + getSettings: () => Record; + + /** Set the resize anchor */ + setAnchor: (anchor: ElementResizeAnchor) => void; + + /** Get the current resize anchor */ + getAnchor: () => ElementResizeAnchor; + + /** + * Register event hooks + * - "key": mapped key events + * - "rawKey": all raw input events + */ + onHook: (event: "key" | "rawKey", callback: (...args: any[]) => void) => void; + + /** Expose functions for the context menu */ + expose: (actions: Record any>) => void; + + /** Current locale code */ + locale: string; + + /** Translation function */ + t: PluginTranslateFn; + + /** Subscribe to locale changes */ + onLocaleChange: (listener: (locale: string) => void) => Unsubscribe; + + /** Subscribe to settings changes */ + onSettingsChange: ( + listener: ( + newSettings: Record, + oldSettings: Record + ) => void + ) => void; +} +``` + +### setAnchor / getAnchor + +Dynamically change or read the resize anchor per instance. + +```javascript fragment=object-members +onMount: ({ setAnchor, getAnchor, getSettings }) => { + // Check the current anchor + console.log("Current anchor:", getAnchor()); // "top-left" (default) + + // Change the anchor to center + setAnchor("center"); + + // Change the anchor based on settings + const settings = getSettings(); + if (settings.expandFromCenter) { + setAnchor("center"); + } +}, +``` + +**Anchor priority:** + +1. Per-instance anchor set via `setAnchor()` +2. `PluginDefinition.resizeAnchor` (definition default) +3. `"top-left"` (system default) + +--- + ## Settings Definition -### `dmn.plugin.defineSettings(options): void` +### `dmn.plugin.defineSettings(definition): PluginSettingsInstance` -Defines plugin settings UI in the main window settings panel. See [Settings System](/docs/settings) for details. +Defines **plugin-global settings** independent of elements (`defineElement`). The +returned instance exposes `get`/`set`/`open`/`reset`/`subscribe`. See +[Settings System](/docs/settings) for details. -```javascript -dmn.plugin.defineSettings({ - id: "my-settings", - title: "My Plugin Settings", +```typescript fragment=signature +dmn.plugin.defineSettings( + definition: PluginSettingsDefinition +): PluginSettingsInstance +``` +```javascript +const pluginSettings = dmn.plugin.defineSettings({ settings: { enabled: { type: "boolean", default: true, label: "Enable" }, theme: { @@ -70,6 +253,38 @@ dmn.plugin.defineSettings({ }); ``` +### PluginSettingSchema + +Settings use a discriminated union of value settings and sections. Layout entries +never appear in stored settings, defaults, getters, or change callbacks. + +```typescript +type PluginSettingSchema = + | { + type: "boolean" | "color" | "number" | "string" | "select"; + default: string | number | boolean; + label: string; + min?: number; + max?: number; + step?: number; + options?: { label: string; value: string | number | boolean }[]; + placeholder?: string; + visible?: boolean | ((settings: Record) => boolean); + } + | { + type: "section"; + label?: string; // optional caption above the card + visible?: boolean | ((settings: Record) => boolean); + }; +``` + +A section starts a card and controls the whole group through `visible`. Its key is +a layout identifier and never appears in stored values, defaults, getters, or +change callbacks. Panel and modal settings UIs follow the same rules, and +visibility exceptions hide the affected item or group (fail-closed). The legacy +`divider` type was removed — divider entries in existing plugins are ignored as +an unsupported type. + --- ## Lifecycle @@ -100,6 +315,16 @@ dmn.plugin.registerCleanup(() => { Plugin-specific persistent storage. See [Storage API](/docs/storage) for details. +```typescript fragment=signature +dmn.plugin.storage.get(key: string): Promise +dmn.plugin.storage.set(key: string, value: any): Promise +dmn.plugin.storage.remove(key: string): Promise +dmn.plugin.storage.clear(): Promise +dmn.plugin.storage.keys(): Promise +dmn.plugin.storage.hasData(prefix: string): Promise +dmn.plugin.storage.clearByPrefix(prefix: string): Promise +``` + ```javascript // Save await dmn.plugin.storage.set("myKey", { value: 42 }); @@ -116,37 +341,98 @@ const keys = await dmn.plugin.storage.keys(); --- -## Plugin Info - -### `dmn.plugin.id: string` +## Example Usage -Returns current plugin ID (from `// @id` comment). +### Basic Plugin ```javascript -// @id my-plugin -console.log(dmn.plugin.id); // 'my-plugin' -``` +// @id simple-counter -### `dmn.plugin.list(): Promise` +dmn.plugin.defineElement({ + name: "Simple Counter", + maxInstances: 3, + resizeAnchor: "top-left", -Returns list of all loaded plugins. + settings: { + textColor: { + type: "color", + default: "#FFFFFF", + label: "Text Color", + }, + }, -```typescript -interface PluginInfo { - id: string; - name: string; - instances: number; -} + previewState: { + count: 42, + }, + + template: (state, settings, { html }) => html` +
+ Count: ${state.count ?? 0} +
+ `, + + onMount: ({ setState, onHook }) => { + let count = 0; -const plugins = await dmn.plugin.list(); -plugins.forEach((p) => { - console.log(`${p.name} (${p.id}): ${p.instances} instances`); + onHook("key", ({ state }) => { + if (state === "DOWN") { + count++; + setState({ count }); + } + }); + }, }); ``` ---- +### Dynamic Anchor Change -## Example Usage +```javascript +// @id dynamic-anchor-panel + +dmn.plugin.defineElement({ + name: "Dynamic Anchor Panel", + resizeAnchor: "top-left", // Default anchor + + settings: { + expandFromCenter: { + type: "boolean", + default: false, + label: "Expand From Center", + }, + }, + + template: (state, settings, { html }) => html` +
+ Anchor: ${state.currentAnchor ?? "top-left"} +
+ `, + + onMount: ({ + setState, + getSettings, + setAnchor, + getAnchor, + onSettingsChange, + }) => { + // Set the initial anchor + const settings = getSettings(); + const anchor = settings.expandFromCenter ? "center" : "top-left"; + setAnchor(anchor); + setState({ currentAnchor: anchor }); + + // Update the anchor when settings change + onSettingsChange((newSettings, oldSettings) => { + if (newSettings.expandFromCenter !== oldSettings.expandFromCenter) { + const newAnchor = newSettings.expandFromCenter ? "center" : "top-left"; + setAnchor(newAnchor); + setState({ currentAnchor: newAnchor }); + } + }); + }, +}); +``` + +### Stats Tracker ```javascript // @id stats-tracker diff --git a/docs/content/en/api-reference/sound/page.mdx b/docs/content/en/api-reference/sound/page.mdx new file mode 100644 index 00000000..30f2a980 --- /dev/null +++ b/docs/content/en/api-reference/sound/page.mdx @@ -0,0 +1,105 @@ +--- +title: Sound API +description: dmn.sound API reference for the key sound library +--- + +# Sound API + +Manage the key sound library — the pool of audio files that key elements can +play on press. Sounds live in the app data `sounds` directory and include both +user-imported files (`local`) and bundled ones (`builtin`). + +## Types + +```typescript +type SoundListItem = { + soundPath: string; // absolute path — also the stable identifier + fileName: string; + sizeBytes: number; + modifiedAtMs?: number; + hidden: boolean; // hidden from picker lists — playback is unaffected + enabled: boolean; // deprecated — inverse alias of hidden, kept for 1.6.1 compat + source: 'local' | 'builtin'; + originalPath?: string; // pre-trim original (present for edited sounds) + trimStartRatio?: number; + trimEndRatio?: number; + displayName?: string; +}; +``` + +## Library + +### `dmn.sound.list(): Promise` + +Returns every sound in the library, **including hidden ones** — filtering by +`hidden` is up to the caller (the built-in picker hides them from its default +views and shows them under a "Hidden Sounds" filter). + +```typescript +const sounds = await dmn.sound.list(); +const visible = sounds.filter((s) => !s.hidden); +``` + +### `dmn.sound.load(): Promise` + +Opens a file dialog, copies the chosen audio file into the library, and +returns its `soundPath`. + +### `dmn.sound.rename(soundPath, displayName): Promise` + +Sets the display name shown in pickers. The file itself is not renamed. + +### `dmn.sound.remove(soundPath): Promise` + +Deletes the file and unassigns the sound from every element using it. + +## Visibility + +### `dmn.sound.setHidden(soundPath, hidden): Promise` + +Hides a sound from (or restores it to) picker lists. Hiding is list-only +housekeeping: **keys that already use the sound keep playing it**. Builtin +sounds can be hidden too. + +```typescript +await dmn.sound.setHidden(sound.soundPath, true); // hide +await dmn.sound.setHidden(sound.soundPath, false); // unhide +``` + +```typescript +type SoundSetHiddenResult = { + success: boolean; + soundPath: string; + hidden: boolean; +}; +``` + +### `dmn.sound.setEnabled(soundPath, enabled): Promise` + + + Deprecated — inverse alias kept for backward compatibility. `enabled` is + simply `!hidden`; use `setHidden` instead. + + +## Editing + +### `dmn.sound.saveProcessedWav(wavBase64, fileName?, originalBase64?, originalExtension?, trimStartRatio?, trimEndRatio?): Promise` + +Saves a processed (trimmed) WAV into the library, optionally alongside the +original for later re-editing. + +### `dmn.sound.loadOriginal(soundPath): Promise` + +Returns the stored original audio (base64) of an edited sound. + +### `dmn.sound.updateProcessedWav(soundPath, wavBase64, trimStartRatio?, trimEndRatio?, displayName?): Promise` + +Overwrites an edited sound's processed WAV in place. + +## Diagnostics + +### `dmn.sound.setLatencyLogging(enabled): Promise` + +Toggles key-sound latency logging in the audio engine. Enabling is only +available in dev/debug builds — release builds reject `enabled: true` with an +error. diff --git a/docs/content/en/declarative-api/page.mdx b/docs/content/en/declarative-api/page.mdx index 33ca6a4a..186c2199 100644 --- a/docs/content/en/declarative-api/page.mdx +++ b/docs/content/en/declarative-api/page.mdx @@ -84,15 +84,21 @@ dmn.plugin.defineElement({ }); ``` -## Settings Schema +## Settings Schema (settings) Define settings schema to auto-generate settings UI. Default is property panel; use `settingsUI: "modal"` for modal style. ### Supported Types -```javascript +```javascript fragment=object-members settings: { + // Card section — starts a new settings card + appearanceSection: { + type: "section", + label: "Appearance", // optional caption above the card + }, + // String nickname: { type: "string", @@ -118,11 +124,6 @@ settings: { label: "Show Graph", }, - // Divider - sectionDivider: { - type: "divider", - }, - // Color textColor: { type: "color", @@ -143,14 +144,25 @@ settings: { }, ``` -`type: "divider"` adds only a divider in the settings UI without needing `default` or `label`. +`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). -```javascript +```javascript fragment=object-members settings: { // Mode selection showGraph: { @@ -159,6 +171,12 @@ settings: { label: "Show Graph", }, + graphSection: { + type: "section", + label: "Graph", + visible: (settings) => settings.showGraph, + }, + // Static boolean — always hidden hiddenOption: { type: "number", @@ -168,10 +186,6 @@ settings: { }, // Function — dynamically show/hide based on other settings - graphDivider: { - type: "divider", - visible: (settings) => settings.showGraph, - }, graphColor: { type: "color", default: "#86EFAC", @@ -191,15 +205,21 @@ settings: { 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: -```typescript +```typescript fragment=type-members visible?: boolean | ((settings: Record) => boolean); ``` ### Accessing Settings -```javascript +```javascript fragment=object-members // In template template: (state, settings, { html }) => html`
@@ -232,6 +252,12 @@ Sets the anchor position when element size changes. Default is `"top-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 changes +- `bottom-right`: the bottom-right corner stays fixed as the size changes + ### Definition Level Setting Define default anchor for all instances: @@ -252,7 +278,7 @@ Use `setAnchor()` and `getAnchor()` in `onMount` to dynamically change anchor: dmn.plugin.defineElement({ name: "Dynamic Anchor Panel", - onMount: ({ setAnchor, getAnchor }) => { + onMount: ({ setAnchor, getAnchor, getSettings }) => { // Check current anchor console.log("Current anchor:", getAnchor()); // "top-left" @@ -270,6 +296,12 @@ dmn.plugin.defineElement({ }); ``` +### Anchor Priority + +1. Per-instance `resizeAnchor` (set via `setAnchor`) +2. `resizeAnchor` on the definition +3. Default `"top-left"` + ## Resize Settings (resizable, preserveAxis) Use `resizable` option to let users resize elements directly in the grid. @@ -312,3 +344,386 @@ dmn.plugin.defineElement({ // ... }); ``` + +### 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 + +```javascript +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` +
+
${state.kps ?? 0}
+ ${settings.showGraph ? html`
...
` : ""} +
+ `, +}); +``` + + + 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 + +```javascript fragment=object-members +contextMenu: { + create: "Create Panel", // Right-click on empty grid space + delete: "Delete Panel", // Right-click on the panel +}, +``` + +### Custom Menu Items + +```javascript fragment=object-members +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 — + `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. + +```javascript fragment=object-members +// 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](/docs/template-syntax) for the full syntax. + +```javascript fragment=object-members +template: (state, settings, { html, t, locale }) => html` +
+ ${state.value} + ${settings.showDetails ? html` +
Details
+ ` : ""} +
+`, +``` + +### 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. + +```javascript fragment=object-members +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 + +```javascript fragment=object-members +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) + +```javascript fragment=object-members +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}`); + }); +}, +``` + +### 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. + + +```javascript fragment=object-members +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) + +```javascript +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` +
${t("label.count")}: ${state.value ?? 0}
+ `, +}); +``` + +## Practical Example: KPS Panel + +```javascript +// @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` +
+
+ ${state.kps ?? 0} + KPS +
+ ${settings.showGraph + ? html` +
+ ${(state.history ?? []).map((v) => { + const height = state.max ? (v / state.max) * 100 : 0; + return html` +
+ `; + })} +
+ ` + : ""} +
+ `, + + 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); + }, +}); +``` diff --git a/docs/content/en/guide/installation/page.mdx b/docs/content/en/guide/installation/page.mdx index 2643e750..72c0a7de 100644 --- a/docs/content/en/guide/installation/page.mdx +++ b/docs/content/en/guide/installation/page.mdx @@ -39,7 +39,7 @@ When you first run the program, two windows will appear. Program settings are automatically saved to the following path. -``` +```text %appdata%/com.dmnote.desktop/store.json ``` diff --git a/docs/content/en/guide/tips/page.mdx b/docs/content/en/guide/tips/page.mdx index 597723eb..b42fbacc 100644 --- a/docs/content/en/guide/tips/page.mdx +++ b/docs/content/en/guide/tips/page.mdx @@ -103,7 +103,7 @@ Open Developer Tools with `Ctrl+Shift+I` to inspect elements and test CSS. Regularly backup important settings: -``` +```text %appdata%/com.dmnote.desktop/ ``` diff --git a/docs/content/en/settings/page.mdx b/docs/content/en/settings/page.mdx index add01094..1ee54ca8 100644 --- a/docs/content/en/settings/page.mdx +++ b/docs/content/en/settings/page.mdx @@ -22,6 +22,10 @@ description: Plugin settings management with defineSettings const pluginSettings = dmn.plugin.defineSettings({ settings: { + connectionSection: { + type: "section", + label: "Connection", + }, apiKey: { type: "string", default: "", @@ -36,27 +40,26 @@ const pluginSettings = dmn.plugin.defineSettings({ default: "dark", label: "Theme", }, - sectionDivider: { - type: "divider", + behaviorSection: { + type: "section", + label: "Behavior", }, enabled: { type: "boolean", default: true, label: "Enabled", }, + // Conditional visibility — static boolean or function, shown only while enabled is true + advancedOption: { + type: "number", + default: 5, + label: "Advanced Option", + visible: (settings) => settings.enabled, + }, }, }); -// `type: "divider"` adds only a divider in settings panel/modal. - -// Conditional visibility — use visible property to dynamically show/hide items -// Supports both static boolean and function -advancedOption: { - type: "number", - default: 5, - label: "Advanced Option", - visible: (settings) => settings.enabled, // Only shown when enabled is true -}, +// section starts a new card. (The legacy divider type was removed — existing divider entries are ignored.) // Get settings values const current = pluginSettings.get(); @@ -72,6 +75,14 @@ if (confirmed) { } ``` +`defineSettings` uses the same section contract as `defineElement`: a section starts +a new card, its optional label appears above the card, and its key is excluded from +stored/default values and callbacks. `section.visible` controls the entire group. +Sections with no renderable value settings are hidden entirely; only when no value +setting is renderable anywhere does the settings UI show its standard empty state. +Panel and modal modes have identical semantics, including fail-closed visibility +evaluation. + ## API Reference ### defineSettings(definition) @@ -115,7 +126,7 @@ Both detect settings changes but have different purposes: ```javascript const settings = dmn.plugin.defineSettings({ - settings: { apiKey: { type: "string", default: "" } }, + settings: { apiKey: { type: "string", default: "", label: "API Key" } }, // onChange: Always executes (cannot unsubscribe) onChange: (newSettings, oldSettings) => { @@ -205,7 +216,144 @@ dmn.plugin.defineElement({ // Instance-specific settings settings: { showGraph: { type: "boolean", default: true, label: "Show Graph" }, + graphColor: { + type: "color", + default: "#86EFAC", + label: "Graph Color", + visible: (s) => s.showGraph, + }, + }, + + template: (state, instanceSettings, { html }) => { + const global = globalSettings.get(); + return html` +
KPS: ${state.kps ?? 0}
+ `; + }, + + onMount: ({ setState, onHook }) => { + const global = globalSettings.get(); + let count = 0; + + onHook("key", ({ state }) => { + if (state === "DOWN") count++; + }); + + const interval = setInterval(() => { + setState({ kps: count }); + count = 0; + }, global.refreshRate); + + return () => clearInterval(interval); }, - // ... }); ``` + +### Adding Settings to the Grid Menu + +You can also add a settings menu without any panel: + +```javascript +// @id settings-only-plugin + +const pluginSettings = dmn.plugin.defineSettings({ + settings: { + volume: { type: "number", default: 50, min: 0, max: 100, label: "Volume" }, + notifications: { type: "boolean", default: true, label: "Notifications" }, + }, +}); + +// Add to the right-click menu on empty grid space +dmn.ui.contextMenu.addGridMenuItem({ + id: "my-plugin-settings", + label: "Plugin Settings", + onClick: () => pluginSettings.open(), +}); + +dmn.plugin.registerCleanup(() => { + dmn.ui.contextMenu.clearMyMenuItems(); +}); +``` + +### Reacting to Settings Changes + +```javascript +// @id data-fetcher + +const fetcherSettings = dmn.plugin.defineSettings({ + settings: { + apiEndpoint: { + type: "string", + default: "https://api.example.com", + label: "API Endpoint", + }, + refreshInterval: { + type: "number", + default: 5000, + min: 1000, + max: 60000, + label: "Refresh Interval (ms)", + }, + autoRefresh: { + type: "boolean", + default: true, + label: "Auto Refresh", + }, + }, +}); + +let fetchInterval = null; + +function startFetching() { + const { refreshInterval, autoRefresh, apiEndpoint } = fetcherSettings.get(); + + if (fetchInterval) { + clearInterval(fetchInterval); + fetchInterval = null; + } + + if (!autoRefresh) return; + + fetchInterval = setInterval(async () => { + const response = await fetch(apiEndpoint); + const data = await response.json(); + console.log("Data:", data); + }, refreshInterval); +} + +// Restart the interval when settings change +fetcherSettings.subscribe((newSettings, oldSettings) => { + if ( + newSettings.apiEndpoint !== oldSettings.apiEndpoint || + newSettings.refreshInterval !== oldSettings.refreshInterval || + newSettings.autoRefresh !== oldSettings.autoRefresh + ) { + startFetching(); + } +}); + +startFetching(); + +dmn.plugin.registerCleanup(() => { + if (fetchInterval) clearInterval(fetchInterval); +}); +``` + +## Comparison with defineElement's onSettingsChange + +| Feature | `defineElement` | `defineSettings` | +| ------------------------ | ---------------------------- | --------------------------------------- | +| Settings change handling | `onSettingsChange(callback)` | `onChange` + `subscribe()` | +| Unsubscribing | Automatic (on unmount) | Manual, via `subscribe()` return value | +| Where to use | Inside `onMount` only | Anywhere | +| Target | Instance-specific settings | Global/standalone settings | + +## Automatic Behavior + +| Feature | Description | +| ------------------------------ | -------------------------------------------------------------- | +| **Automatic UI generation** | Property panel/modal generated from the settings schema | +| **Automatic storage handling** | Saved to and restored from `plugin.storage` | +| **i18n support** | Integrates with messages | +| **Per-type components** | boolean→checkbox, color→color picker, and so on | +| **Automatic panel sync** | All panels of the same plugin re-render when settings change | diff --git a/docs/content/en/template-syntax/page.mdx b/docs/content/en/template-syntax/page.mdx index d5aea175..508e92df 100644 --- a/docs/content/en/template-syntax/page.mdx +++ b/docs/content/en/template-syntax/page.mdx @@ -12,11 +12,11 @@ It allows intuitive writing close to standard HTML syntax. ### Value Interpolation -```javascript +```javascript fragment=object-members template: (state, settings, { html }) => html`
Current value: ${state.value}
Colored text
-`; +`, ``` @@ -112,7 +112,7 @@ html` ### Inline Styles -```javascript +```javascript fragment=object-members template: (state, settings, { html }) => html`
${state.value}
KPS
-`; +`, ``` ## Practical Example ### Stats Panel -```javascript +```javascript fragment=object-members template: (state, settings, { html }) => html`