Plugins
Plugin Examples
Real-world patterns from Readied's built-in plugins — status bar, commands, focus mode, themes, and more
Plugin Examples
Real-world patterns from Readied's built-in plugins.
Status Bar Plugin
Shows information in the editor status bar. Updates when the document changes.
import { useState, useEffect } from 'react';
import type { PluginManifest, ZoneComponentProps, EditorAPI } from '@readied/plugin-api';
function ReadingTime({ meta }: ZoneComponentProps) {
const editor = meta?.editor as EditorAPI | undefined;
const [minutes, setMinutes] = useState(0);
useEffect(() => {
if (!editor) return;
const update = () => {
const words = editor.getWordCount();
setMinutes(Math.max(1, Math.ceil(words / 200)));
};
update();
return editor.onDocChanged(update);
}, [editor]);
if (!editor) return null;
return <span>{minutes} min read</span>;
}
export const readingTimePlugin: PluginManifest = {
id: 'reading-time',
name: 'Reading Time',
version: '1.0.0',
activate(context) {
context.layout.addComponent('editor-status-bar', {
id: 'reading-time:status',
component: ReadingTime,
order: 20,
meta: { editor: context.editor },
});
return {
dispose() {
context.layout.removeComponent('reading-time:status');
},
};
},
};Command-Only Plugin
Registers commands without any UI components.
import type { PluginManifest } from '@readied/plugin-api';
export const exportPlugin: PluginManifest = {
id: 'export-markdown',
name: 'Export Markdown',
version: '1.0.0',
activate(context) {
const unregister = context.registerCommand(
{
id: 'copy-markdown',
name: 'Copy as Markdown',
keybinding: { key: 'C', modifiers: ['Mod', 'Shift'] },
icon: 'Copy',
},
() => {
const content = context.editor.getContent();
if (!content) return false;
navigator.clipboard.writeText(content);
context.log.info('Copied to clipboard');
return true;
}
);
return { dispose: unregister };
},
};Focus Mode Plugin
Adds a CSS class to the editor to dim non-active lines.
import type { PluginManifest } from '@readied/plugin-api';
const FOCUS_CLASS = 'my-focus-mode';
// Inject styles once
let injected = false;
function injectStyles() {
if (injected) return;
injected = true;
const style = document.createElement('style');
style.textContent = `
.${FOCUS_CLASS} .cm-line { opacity: 0.3; transition: opacity 150ms; }
.${FOCUS_CLASS} .cm-line.cm-activeLine { opacity: 1; }
`;
document.head.appendChild(style);
}
export const focusModePlugin: PluginManifest = {
id: 'focus-mode',
name: 'Focus Mode',
version: '1.0.0',
configSchema: {
enabled: {
type: 'boolean',
default: false,
description: 'Enable focus mode on startup',
},
},
activate(context) {
injectStyles();
let active = context.config.get<boolean>('enabled') ?? false;
function apply() {
const el = document.querySelector('.cm-editor');
if (!el) return;
el.classList.toggle(FOCUS_CLASS, active);
}
if (active) setTimeout(apply, 100);
const offNoteSelected = context.app.onNoteSelected(() => {
setTimeout(apply, 50);
});
const unregister = context.registerCommand(
{
id: 'toggle',
name: 'Toggle Focus Mode',
keybinding: { key: 'F', modifiers: ['Mod', 'Shift'] },
icon: 'Eye',
},
() => {
active = !active;
context.config.set('enabled', active);
apply();
return true;
}
);
return {
dispose() {
active = false;
apply();
offNoteSelected();
unregister();
},
};
},
};Plugin with Config
Demonstrates using the config schema for user-configurable settings.
import type { PluginManifest } from '@readied/plugin-api';
export const plugin: PluginManifest = {
id: 'my-configurable-plugin',
name: 'Configurable Plugin',
version: '1.0.0',
configSchema: {
greeting: {
type: 'string',
default: 'Hello',
description: 'Greeting message to show',
},
volume: {
type: 'range',
default: 50,
min: 0,
max: 100,
step: 10,
description: 'Notification volume',
},
style: {
type: 'enum',
default: 'minimal',
options: [
{ value: 'minimal', label: 'Minimal' },
{ value: 'detailed', label: 'Detailed' },
{ value: 'verbose', label: 'Verbose' },
],
description: 'Display style',
},
notifications: {
type: 'boolean',
default: true,
description: 'Show notification popups',
},
},
activate(context) {
const greeting = context.config.get<string>('greeting') ?? 'Hello';
context.log.info(`${greeting} from Configurable Plugin!`);
return { dispose() {} };
},
};Panel Plugin with Toggle
Adds a button to the editor header that toggles a side panel.
import { useState, useCallback, useEffect } from 'react';
import { BookOpen } from 'lucide-react';
import type { PluginManifest, ZoneComponentProps, PluginContext } from '@readied/plugin-api';
// Bridge between plugin context and React components
const bridge = {
visible: false,
listeners: new Set<(v: boolean) => void>(),
toggle() {
bridge.visible = !bridge.visible;
bridge.listeners.forEach(fn => fn(bridge.visible));
},
};
function useBridgeVisible(): [boolean, (v: boolean) => void] {
const [visible, setVisible] = useState(bridge.visible);
useEffect(() => {
bridge.listeners.add(setVisible);
setVisible(bridge.visible);
return () => { bridge.listeners.delete(setVisible); };
}, []);
const set = useCallback((v: boolean) => {
bridge.visible = v;
bridge.listeners.forEach(fn => fn(v));
}, []);
return [visible, set];
}
function ToggleButton() {
const [visible] = useBridgeVisible();
return (
<button
className={`note-editor-actions-btn${visible ? ' active' : ''}`}
onClick={() => bridge.toggle()}
title="My Panel"
>
<BookOpen size={18} />
</button>
);
}
function MyPanel({ meta }: ZoneComponentProps) {
const [visible] = useBridgeVisible();
if (!visible) return null;
return (
<div style={{ padding: '1rem', borderLeft: '1px solid var(--border)' }}>
<h3>My Panel</h3>
<p>Content goes here</p>
</div>
);
}
export const plugin: PluginManifest = {
id: 'my-panel-plugin',
name: 'Panel Plugin',
version: '1.0.0',
activate(context) {
context.layout.addComponent('editor-header-actions', {
id: 'my-panel:toggle',
component: ToggleButton,
order: 20,
});
context.layout.addComponent('panel', {
id: 'my-panel:panel',
component: MyPanel,
order: 50,
meta: { context },
});
const unregister = context.registerCommand(
{ id: 'toggle', name: 'Toggle My Panel', icon: 'BookOpen' },
() => { bridge.toggle(); return true; }
);
return {
dispose() {
bridge.visible = false;
bridge.listeners.clear();
unregister();
},
};
},
};Theme Plugin
Creates a custom theme that responds to dark/light mode changes using getTheme() and onThemeChanged().
import type { PluginManifest } from '@readied/plugin-api';
const DARK_VARS: Record<string, string> = {
'--bg-base': '#1a1b26',
'--bg-surface': '#1f2335',
'--text-primary': '#c0caf5',
'--text-secondary': '#a9b1d6',
'--accent-primary': '#7aa2f7',
};
const LIGHT_VARS: Record<string, string> = {
'--bg-base': '#f5f5f5',
'--bg-surface': '#ffffff',
'--text-primary': '#1a1b26',
'--text-secondary': '#565a6e',
'--accent-primary': '#2e7de9',
};
export const myThemePlugin: PluginManifest = {
id: 'my-theme',
name: 'My Custom Theme',
version: '1.0.0',
themeType: 'ui',
activate(context) {
let cleanup: (() => void) | null = null;
const apply = () => {
if (cleanup) cleanup();
const isDark = context.getTheme() === 'dark';
cleanup = context.registerCssVariables('my-theme-colors', isDark ? DARK_VARS : LIGHT_VARS);
};
apply();
const unsub = context.onThemeChanged(apply);
return {
dispose() {
unsub();
if (cleanup) cleanup();
},
};
},
};Remark Plugin
Registers a remark plugin that transforms the markdown AST in the preview pipeline. This example automatically converts plain-text URLs into clickable links.
import type { PluginManifest } from '@readied/plugin-api';
import type { Root, Text, Link } from 'mdast';
import { visit } from 'unist-util-visit';
const URL_REGEX = /https?:\/\/[^\s)]+/g;
/**
* A remark plugin that finds bare URLs in text nodes
* and wraps them in link nodes.
*/
function remarkAutolink() {
return (tree: Root) => {
visit(tree, 'text', (node: Text, index, parent) => {
if (!parent || index === undefined) return;
const matches = [...node.value.matchAll(URL_REGEX)];
if (matches.length === 0) return;
// Split the text node into text + link segments
const children: (Text | Link)[] = [];
let lastIndex = 0;
for (const match of matches) {
const url = match[0];
const start = match.index!;
// Text before the URL
if (start > lastIndex) {
children.push({ type: 'text', value: node.value.slice(lastIndex, start) });
}
// The URL as a link
children.push({
type: 'link',
url,
children: [{ type: 'text', value: url }],
});
lastIndex = start + url.length;
}
// Remaining text after the last URL
if (lastIndex < node.value.length) {
children.push({ type: 'text', value: node.value.slice(lastIndex) });
}
// Replace the original text node with our new nodes
parent.children.splice(index, 1, ...children);
});
};
}
export const autolinkPlugin: PluginManifest = {
id: 'autolink',
name: 'Auto-link URLs',
version: '1.0.0',
description: 'Converts plain-text URLs to clickable links in the preview',
activate(context) {
const unregister = context.registerRemarkPlugin('autolink', remarkAutolink);
return {
dispose() {
unregister();
},
};
},
};