Skip to content

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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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();
      },
    };
  },
};