Launch OpenGPEX

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_ZZZ string 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

  1. Public for semantic state, private for UI state. Active tool, generation status → public. Panel tabs, open/closed → private.
  2. Read across plugins, write through commands. Direct writes to another plugin's signal bypass its invariants.
  3. Prefer facades over string constants. One structured object per plugin, exposed on demand.
  4. Keep signal payloads small and serializable. Signals live in global state; do not stash large blobs — use the asset store.

Next Steps


Last updated: 2026-10-12