Signals & Communication
Signals are the reactive state primitives of the plugin system. They let plugins maintain internal state and communicate with other plugins in a structured, namespaced, auditable way.
What Are Signals?
Signals are named reactive values stored in the global interaction state, scoped to a plugin via UID namespacing. They are:
- Reactive — UI re-renders when signal values change
- Scoped — storage keys are auto-prefixed with the plugin UID
- Typed — values may be boolean, string, number, or structured objects
Declaring Signals
Declare signals in index.tsx as part of the plugin definition:
export const plugin: EditorPlugin = {
// ...
signals: [
{
id: 'signal.active_tool', // Local ID (auto-prefixed at registration)
name: 'Active Tool',
defaultValue: 'brush',
scope: 'public', // other plugins may read this
},
{
id: 'signal.panel_open',
name: 'Panel Open State',
defaultValue: false,
scope: 'private', // internal use only
},
],
};
Signal Scope
| Scope | Meaning | External Access |
|---|---|---|
public |
Intentionally shared for cross-plugin consumption | Yes, via the owner's exported facade |
private |
Internal UI state only | No (by convention) |
The legacy scope: 'shared' value is removed. All owned, externally consumable signals use public.
Reading & Writing Within the Plugin
import { usePluginSignals } from '@opengpex/editor/core/context';
function MyPanel() {
const { activeToolSignal, panelOpenSignal } = usePluginSignals();
return (
<div>
<p>Current: {activeToolSignal.value}</p>
<button onClick={() => activeToolSignal.set('eraser')}>Eraser</button>
<button onClick={() => panelOpenSignal.set(!panelOpenSignal.value)}>Toggle</button>
</div>
);
}
In Commands (ctx.scoped)
execute: (ctx) => {
const tool = ctx.scoped!.getSignal('signal.active_tool', 'brush'); // auto-prefixed
ctx.scoped!.setSignal('signal.active_tool', 'eraser');
ctx.scoped!.toggleSignal('signal.panel_open');
}
Typed Signal Maps (commands.d.ts)
Generate a typed map for compile-time safety and IDE completion:
pnpm gen-plugin-types src/lib/opengpex/plugins/base/drawers/CraftDrawer
// commands.d.ts (generated — do not hand-edit)
import type { CommandInstance, InteractionSignalValue } from '@opengpex/editor/core/types';
/** Type map for usePluginSignals<CraftDrawerSignalsMap>() */
export interface CraftDrawerSignalsMap {
[key: string]: { value: InteractionSignalValue; set: (val: InteractionSignalValue) => void };
activeCraftSignal: {
value: unknown;
set: (val: unknown) => void;
};
}
The generator derives keys from the declared signal ids, but it cannot infer value types from defaultValue alone — signal values are emitted as unknown. Narrow at the call site if needed.
import type { CraftDrawerSignalsMap } from './commands.d';
const { activeCraftSignal } = usePluginSignals<CraftDrawerSignalsMap>();
Key derivation: strip the signal. prefix, camelCase, append Signal — signal.active_craft → activeCraftSignal.
Cross-Plugin Access: The XxxAPI Facade
Cross-plugin access goes through a structured facade exported from the owner's protocols.ts. Do not scatter raw UID string constants across consumers.
// CraftDrawer/protocols.ts
export const PLUGIN_AUTHOR = 'opengpex';
export const PLUGIN_ID = 'drawers.craft_tools';
export const CMD_DEACTIVATE_CRAFT = 'cmd.deactivate_craft';
export const SIGNAL_ACTIVE_CRAFT = 'signal.active_craft';
export const CraftDrawerAPI = {
signals: {
activeCraft: `${PLUGIN_AUTHOR}.${PLUGIN_ID}.${SIGNAL_ACTIVE_CRAFT}` as const,
},
commands: {
// Only expose what is actually consumed externally
deactivate: {
uid: `${PLUGIN_AUTHOR}.${PLUGIN_ID}.${CMD_DEACTIVATE_CRAFT}`,
},
},
configKey: `${PLUGIN_AUTHOR}.${PLUGIN_ID}` as const,
} as const;
Consumer usage:
import { CraftDrawerAPI } from '../../drawers/CraftDrawer/protocols';
// Read a signal (cross-plugin reads go through the global state API, not usePluginSignals)
const craft = state.interaction.signals[CraftDrawerAPI.signals.activeCraft];
// Invoke a command
actions.executeCommand(CraftDrawerAPI.commands.deactivate.uid);
// Read config
const [config] = usePluginConfig<CraftDrawerConfig>(CraftDrawerAPI.configKey);
Rules:
- Only expose capabilities with real external consumers. Internal-only commands/signals stay out of the facade.
- Cross-plugin signal writes are discouraged — route mutations through the owner's commands instead.
- When a facade is introduced, delete the old
XXX_YYY_CMD_ZZZstring constants and migrate all callers.
Cursor Overrides
The viewport cursor is plugin-extensible via the cursorOverride interaction field. Any plugin (typically through an InteractionHandler) can claim the cursor:
export const createTextMoveHandler = (): InteractionHandler => ({
id: 'text-move',
priority: 170,
test: (e) => e.keys.meta,
onStart: (e) => {
e.actions.setInteraction({ cursorOverride: 'grabbing' });
},
onMove: (e) => {
// per-frame updates during the drag, if needed
},
onEnd: (e) => {
const stillHoldingCmd = e.keys.meta;
e.actions.setInteraction({
cursorOverride: stillHoldingCmd ? 'grab' : null, // null = restore default logic
});
},
});
While cursorOverride is non-null it is applied as an inline style.cursor (any CSS cursor value, including SVG data URIs); null falls back to the built-in mode-based cursor. Always clear your override in cleanup paths — component unmount, blur, and tool exit.
Best Practices
- Public for semantic state, private for UI state. Active tool, generation status →
public. Panel tabs, open/closed →private. - Read across plugins, write through commands. Direct writes to another plugin's signal bypass its invariants.
- Prefer facades over string constants. One structured object per plugin, exposed on demand.
- Keep signal payloads small and serializable. Signals live in global state; do not stash large blobs — use the asset store.
Next Steps
- API Reference —
usePluginSignalsand typed maps - Slots & UI — consuming signals in drawer panels
- Your First Plugin — signals in practice
Last updated: 2026-10-12