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.
Step 2: Define Protocols (protocols.ts)
// protocols.ts — Constants and type definitions
export const PLUGIN_ID = 'drawers.color_picker';
export const CMD = {
APPLY_COLOR: 'cmd.apply_color',
} as const;
export interface ColorPickerConfig {
currentColor: string;
recentColors: string[];
}
export const DEFAULT_CONFIG: ColorPickerConfig = {
currentColor: '#ff6600',
recentColors: [],
};
Step 3: Implement Commands (commands.ts)
// commands.ts — Business logic (state mutations)
import type { EditorCommand } from '@/lib/opengpex/core/types/plugins';
import { CMD } from './protocols';
export const commands: EditorCommand[] = [
{
id: CMD.APPLY_COLOR,
displayName: 'Apply Background Color',
execute: (ctx, payload: { color: string }) => {
const { actions, scoped } = ctx;
const { frameId, layerId } = actions.getActiveTarget();
if (!frameId || !layerId) return;
// Update the layer's fill color
actions.updateLayer(frameId, layerId, {
backgroundColor: payload.color,
});
// Update plugin config (recent colors list)
const config = scoped.selfConfig() as { recentColors: string[] };
const recent = [payload.color, ...config.recentColors.filter(c => c !== payload.color)].slice(0, 8);
scoped.updateConfig({ currentColor: payload.color, recentColors: recent });
},
},
];
Step 4: Create Hooks (hooks.ts)
// hooks.ts — Bridge between UI and commands
import { useEditorServices } from '@/lib/opengpex/core/context';
import { usePluginConfig } from '@/lib/opengpex/core/plugin';
import { CMD, type ColorPickerConfig, DEFAULT_CONFIG } from './protocols';
export function useColorPicker() {
const { actions } = useEditorServices();
const config = usePluginConfig<ColorPickerConfig>(DEFAULT_CONFIG);
const applyColorCmd = (color: string) => {
actions.executeCommand(CMD.APPLY_COLOR, { color });
};
return {
currentColor: config.currentColor,
recentColors: config.recentColors,
applyColorCmd,
};
}
Step 5: Build the UI (components.tsx)
// components.tsx — Pure presentation React component
import React, { useState } from 'react';
import { useColorPicker } from './hooks';
export function ColorPickerPanel() {
const { currentColor, recentColors, applyColorCmd } = 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={() => applyColorCmd(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); applyColorCmd(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 '@/lib/opengpex/core/types/plugins';
import { ColorPickerPanel } from './components';
import { commands } from './commands';
import { PLUGIN_ID, DEFAULT_CONFIG } from './protocols';
export const plugin: EditorPlugin = {
manifest: {
id: PLUGIN_ID,
displayName: 'Color Picker',
version: '1.0.0',
description: 'A simple background color picker tool',
category: 'drawers',
author: 'my-username',
},
slot: 'SIDE_BAR',
show: 'frame-required',
icon: <Palette size={20} />,
order: 900,
side: 'left',
component: ColorPickerPanel,
initialConfig: 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
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
Step 9: Package for Distribution
When you are ready to share your plugin:
pnpm pack-plugin ColorPickerDrawer
This produces a .zip file in dist/plugins/ that can be uploaded to GPEX Hub or shared directly.
Summary: The 5-File Pattern
| File | Responsibility |
|---|---|
protocols.ts |
Constants, command IDs, config types |
commands.ts |
Business logic (state mutations) |
hooks.ts |
Bridge layer (UI ↔ commands) |
components.tsx |
Pure React presentation |
index.tsx |
Plugin metadata & assembly |
Next Steps
- Plugin API Reference — All available hooks and context APIs
- Slots & UI — Choose the right slot for your plugin
- Packaging & Distribution — ZIP structure and Hub upload
Last updated: 2026-06-14