Launch OpenGPEX

Your First Plugin

A step-by-step tutorial to create a working OpenGPEX v2 plugin from scratch. By the end you will have built a Color Picker sidebar drawer that reads the active layer, applies a background fill through the command bus, keeps a recent-colors list in plugin config, and auto-reveals itself when a document is opened.


Prerequisites

  • OpenGPEX v2 dev server running (pnpm dev)
  • Basic familiarity with React and TypeScript
  • Read Plugin System Overview for context

Step 1: Create the Plugin Directory

mkdir -p src/lib/opengpex/plugins/user/ColorPickerDrawer

All statically developed user plugins live under plugins/user/. The folder name becomes the _folderName used for resource serving.

We follow the 5-File Pattern:

ColorPickerDrawer/
├── index.tsx        # Entry: exports the EditorPlugin entity
├── protocols.ts     # Contract: IDs, config types
├── commands.ts      # Business logic
├── hooks.ts         # State bridge
└── components.tsx   # Pure UI

Step 2: Define Protocols (protocols.ts)

// protocols.ts — Constants and type definitions

// Both PLUGIN_ID and PLUGIN_AUTHOR are REQUIRED.
// The system computes the UID as: PLUGIN_AUTHOR + "." + PLUGIN_ID
export const PLUGIN_ID = 'drawers.color_picker';
export const PLUGIN_AUTHOR = 'my-username';

// Command IDs (prefixed with "cmd." by convention)
export const CMD_APPLY_COLOR = 'cmd.apply_color';

// Plugin configuration interface
export interface ColorPickerConfig {
  currentColor: string;
  recentColors: string[];
}

export const DEFAULT_CONFIG: ColorPickerConfig = {
  currentColor: '#ff6600',
  recentColors: [],
};

Step 3: Implement Commands (commands.ts)

Commands receive the full editor context (EditorContextValue) with a scoped namespace for plugin-local state.

// commands.ts — Business logic (state mutations)
import type { EditorCommand, EditorContextValue } from '@opengpex/editor/core/types';
import { CMD_APPLY_COLOR, type ColorPickerConfig } from './protocols';

export const commands: EditorCommand[] = [
  {
    id: CMD_APPLY_COLOR,
    name: 'Apply Background Color',
    undoable: true, // Creates an undo checkpoint before executing
    execute: (ctx: EditorContextValue, payload: { color: string }) => {
      const { actions, scoped } = ctx;
      const state = actions.getState();
      const frameId = state.activeFrameId;
      const layerId = state.activeLayerId;

      if (!frameId || !layerId) return;

      actions.updateLayer(frameId, layerId, {
        backgroundColor: payload.color,
      });

      // Merge-patch the plugin's own config
      const config = scoped!.selfConfig as ColorPickerConfig;
      const recent = [
        payload.color,
        ...config.recentColors.filter(c => c !== payload.color),
      ].slice(0, 8);
      scoped!.setSelfConfig({ currentColor: payload.color, recentColors: recent });
    },
  },
];

Because undoable: true, the dispatcher establishes an undo baseline (SIGNAL_COMMIT) before execution — Cmd+Z reverts the fill in one step.


Step 4: Create Hooks (hooks.ts)

// hooks.ts — Bridge between UI and commands
import { usePluginCommands, usePluginSelfConfig } from '@opengpex/editor/core/context';
import type { ColorPickerConfig } from './protocols';

export function useColorPicker() {
  // usePluginCommands() derives camelCase keys from cmd.id:
  // "cmd.apply_color" → "applyColorCmd"
  const { applyColorCmd } = usePluginCommands();

  // Zero arguments — auto-resolves this plugin's scope
  const [config] = usePluginSelfConfig<ColorPickerConfig>();

  return {
    currentColor: config.currentColor,
    recentColors: config.recentColors,
    applyColor: (color: string) => applyColorCmd.execute({ color }),
  };
}

Key derivation rules

cmd.id Generated Key
cmd.apply_color applyColorCmd
cmd.reset_all resetAllCmd

Step 5: Build the UI (components.tsx)

Components are pure presentation. Wrap slot-contributing components in React.memo. The drawer panel uses the standard padding container (px-2 pt-1 pb-1 gap-2) so it aligns with all other drawers.

// components.tsx — Pure presentation
import React from 'react';
import { FancyButton } from '@opengpex/editor/widgets/FancyButton';
import { useColorPicker } from './hooks';

export const ColorPickerPanel = React.memo(function ColorPickerPanel() {
  const { currentColor, recentColors, applyColor } = useColorPicker();

  return (
    <div className="flex flex-col gap-2 px-2 pt-1 pb-1">
      <div className="flex justify-between items-center shrink-0">
        <h3 className="text-xs font-bold uppercase">Color Picker</h3>
      </div>

      {/* Recent colors */}
      <div className="flex gap-1 flex-wrap">
        {recentColors.map((c) => (
          <FancyButton
            key={c}
            shape="rect"
            iconOnly
            variant="ghost"
            title=`Apply ${c}`
            onClick={() => applyColor(c)}
            className="w-6 h-6"
          >
            <span className="w-3 h-3 rounded-sm" style={{ backgroundColor: c }} />
          </FancyButton>
        ))}
      </div>

      <label className="text-[10px] uppercase opacity-60">Current</label>
      <div className="flex items-center gap-2">
        <span className="w-6 h-6 rounded border" style={{ backgroundColor: currentColor }} />
        <code className="text-xs">{currentColor}</code>
      </div>
    </div>
  );
});

Step 6: Assemble the Plugin Entry (index.tsx)

// index.tsx — Plugin registration entry point
import React from 'react';
import { Palette } from 'lucide-react';
import type { EditorPlugin } from '@opengpex/editor/core/types';
import { ColorPickerPanel } from './components';
import { commands } from './commands';
import * as P from './protocols';

export const plugin: EditorPlugin = {
  manifest: {
    id: P.PLUGIN_ID,
    displayName: 'Color Picker',
    version: '1.0.0',
    description: 'A simple background color picker tool',
    category: 'drawers',
    author: P.PLUGIN_AUTHOR,
    requirements: {
      coreVersion: '>=2.0.0',
      auth: 'none',
    },
  },

  slot: 'SIDE_BAR',
  show: 'frame-required',
  icon: <Palette size={20} />,
  order: 900,
  side: 'left',

  component: ColorPickerPanel,
  initialConfig: P.DEFAULT_CONFIG,
  commands,

  // Declarative auto-reveal: open the drawer when a document exists
  autoReveal: {
    when: (state) => !!state.activeFrameId,
    priority: 100,
  },
};

The autoReveal rule is edge-triggered: the drawer opens only on the false → true transition of the condition, and because collapseWhenFalse is unset (sticky mode) it never auto-closes. On page refresh the engine seeds from current state, so persisted state does not trigger a spurious reveal.


Step 7: Register and Run

pnpm scan-plugins   # generates plugins/registry-user.ts
pnpm dev            # the dev server runs the scanner automatically

Enable the plugin in the GPEX Hub panel if it starts disabled. It appears in the left sidebar with the palette icon.


Step 8: Test It

  1. Open the editor and load an image — the drawer auto-reveals (frame exists).
  2. Apply a color and verify the layer background changes.
  3. Press Cmd+Z — the change reverts in one step (undoable: true).
  4. Disable the plugin in the Hub — UI, commands, and shortcuts disappear.
  5. Reload — the disabled state persists (LocalStorage, keyed by UID).

Step 9: Package for Distribution

pnpm pack-plugin ColorPickerDrawer

This produces a .zip in dist/ ready for Hub upload. Details in Packaging & Distribution.


Next Steps


Last updated: 2026-10-12