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
- Open the editor and load an image — the drawer auto-reveals (frame exists).
- Apply a color and verify the layer background changes.
- Press
Cmd+Z— the change reverts in one step (undoable: true). - Disable the plugin in the Hub — UI, commands, and shortcuts disappear.
- 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
- API Reference — all hooks and context services
- Signals & Communication — reactive state and cross-plugin calls
- Slots & UI — slot selection and styling conventions
Last updated: 2026-10-12