Your First Plugin
A step-by-step tutorial to create a working OpenGPEX plugin from scratch. By the end, you will have built a Color Picker sidebar drawer that reads the selected layer's color and lets you apply a new background fill.
Prerequisites
- OpenGPEX 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 user plugins live under plugins/user/. The folder name becomes part of the plugin identity and is used for resource path resolution.
Step 2: Define Protocols (protocols.ts)
// protocols.ts — Constants and type definitions
// ⚠️ Both PLUGIN_ID and PLUGIN_AUTHOR are REQUIRED
// The system computes 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 are the "business logic" layer. They receive the full editor context (EditorContextValue) with a scoped namespace for accessing 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,
displayName: '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;
// Update the layer's fill color
actions.updateLayer(frameId, layerId, {
backgroundColor: payload.color,
});
// Read and update plugin config (recent colors list)
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 });
},
},
];
Key ctx.scoped Properties
| Property | Type | Description |
|---|---|---|
scoped.selfConfig |
Record<string, unknown> |
Current plugin's config state |
scoped.setSelfConfig(patch) |
(patch) => void |
Merge-patch plugin config |
scoped.getSignal(key, default?) |
(key, default?) => T |
Read plugin signal (auto-prefixes UID) |
scoped.setSignal(key, val) |
(key, val) => void |
Write plugin signal (auto-prefixes UID) |
scoped.toggleSignal(key) |
(key) => void |
Toggle boolean signal |
scoped.setBusy(busy) |
(boolean) => void |
Set plugin busy state (shows spinner on sidebar icon) |
Step 4: Create Hooks (hooks.ts)
Hooks bridge the UI and command layers. Use usePluginCommands() to get auto-mapped command executors, and usePluginSelfConfig() to read plugin config.
// hooks.ts — Bridge between UI and commands
import { usePluginCommands, usePluginSelfConfig } from '@opengpex/editor/core/context';
import type { ColorPickerConfig } from './protocols';
export function useColorPicker() {
// usePluginCommands() auto-generates camelCase keys from cmd.id:
// "cmd.apply_color" → "applyColorCmd" (strips "cmd." prefix, camelCases, appends "Cmd")
const { applyColorCmd } = usePluginCommands();
// usePluginSelfConfig() reads this plugin's config (zero arguments — auto-resolves scope)
const [config] = usePluginSelfConfig<ColorPickerConfig>();
return {
currentColor: config.currentColor,
recentColors: config.recentColors,
// Each cmd is an object with .execute(), .name, .shortcutLabel
applyColor: (color: string) => applyColorCmd.execute({ color }),
};
}
Hook Naming Convention
| cmd.id | Generated Key | Usage |
|---|---|---|
cmd.apply_color |
applyColorCmd |
applyColorCmd.execute(payload) |
cmd.reset_all |
resetAllCmd |
resetAllCmd.execute() |
cmd.base.clip.toggle_mask |
baseClipToggleMaskCmd |
baseClipToggleMaskCmd.execute() |
Step 5: Build the UI (components.tsx)
UI components should be pure presentation — all logic flows through hooks. Wrap exported components with React.memo for performance.
// components.tsx — Pure presentation React component
import React, { useState } from 'react';
import { useColorPicker } from './hooks';
export const ColorPickerPanel = React.memo(function ColorPickerPanel() {
const { currentColor, recentColors, applyColor } = useColorPicker();
const [inputColor, setInputColor] = useState(currentColor);
return (
<div className="p-3 space-y-3">
<h3 className="text-xs font-bold uppercase text-muted">Color Picker</h3>
{/* Color input */}
<div className="flex items-center gap-2">
<input
type="color"
value={inputColor}
onChange={(e) => setInputColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer"
/>
<input
type="text"
value={inputColor}
onChange={(e) => setInputColor(e.target.value)}
className="flex-1 px-2 py-1 text-xs bg-panel border border-subtle rounded"
/>
</div>
{/* Apply button */}
<button
onClick={() => applyColor(inputColor)}
className="w-full py-1.5 text-xs font-bold rounded bg-accent text-white"
>
Apply Color
</button>
{/* Recent colors */}
{recentColors.length > 0 && (
<div>
<p className="text-[10px] text-muted mb-1">Recent</p>
<div className="flex gap-1 flex-wrap">
{recentColors.map((c) => (
<button
key={c}
onClick={() => { setInputColor(c); applyColor(c); }}
className="w-5 h-5 rounded border border-subtle"
style={{ backgroundColor: c }}
/>
))}
</div>
</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: '>=1.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,
};
Step 7: Register the Plugin
Run the plugin scanner to automatically detect your new plugin:
pnpm scan-plugins
Or simply restart the dev server — it runs the scanner automatically:
pnpm dev
The scanner generates plugins/registry-user.ts with your plugin's entry (including _folderName for resource serving).
Your plugin should now appear in the left sidebar with a 🎨 palette icon!
Step 8: Test It
- Open the editor and load an image
- Click your Color Picker icon in the sidebar
- Choose a color and click "Apply Color"
- Verify the layer background changes
- Press Ctrl+Z — the change should undo cleanly (because
undoable: true)
Step 9: Package for Distribution
When you are ready to share your plugin:
pnpm pack-plugin ColorPickerDrawer
This produces a .zip file in dist/ that can be uploaded to GPEX Hub or shared directly.
Summary: The 5-File Pattern
| File | Responsibility |
|---|---|
protocols.ts |
Constants (PLUGIN_ID, PLUGIN_AUTHOR, cmd IDs, signal IDs), config types |
commands.ts |
Business logic (state mutations via ctx + ctx.scoped) |
hooks.ts |
Bridge layer (usePluginCommands + usePluginSelfConfig + usePluginSignals) |
components.tsx |
Pure React presentation (memo-wrapped) |
index.tsx |
Plugin metadata assembly & export |
Next Steps
- Plugin API Reference — All available hooks and context APIs
- Signals & Cross-Plugin Communication — Reactive state system
- Slots & UI — Choose the right slot for your plugin
- Packaging & Distribution — ZIP structure and Hub upload
Last updated: 2026-07-30