Skip to main content
Yoopta Editor’s plugin system allows you to create custom block types with full control over rendering, behavior, and serialization.

Plugin Anatomy

A Yoopta plugin is created using the YooptaPlugin class:
import { YooptaPlugin } from '@yoopta/editor';

const MyPlugin = new YooptaPlugin({
  type: 'MyPlugin',           // PascalCase identifier
  elements: { /* ... */ },     // Element definitions
  options: { /* ... */ },      // Display options
  parsers: { /* ... */ },      // Import/export
  events: { /* ... */ },       // Event handlers
  lifecycle: { /* ... */ },    // Lifecycle hooks
  commands: { /* ... */ },     // Custom commands
  extensions: () => {},        // Slate extensions
});

Basic Plugin Example

Let’s create a simple callout plugin:
import { YooptaPlugin, serializeTextNodes, serializeTextNodesIntoMarkdown } from '@yoopta/editor';
import type { PluginElementRenderProps } from '@yoopta/editor';

type CalloutElement = {
  id: string;
  type: 'callout';
  children: any[];
  props: {
    nodeType: 'block';
    theme?: 'info' | 'warning' | 'success' | 'error';
  };
};

type CalloutElementMap = {
  callout: CalloutElement;
};

const Callout = new YooptaPlugin<CalloutElementMap>({
  type: 'Callout',
  
  elements: {
    callout: {
      render: ({ element, attributes, children }: PluginElementRenderProps) => {
        const theme = element.props?.theme || 'info';
        
        return (
          <div
            {...attributes}
            className={`callout callout-${theme}`}
            data-theme={theme}
          >
            {children}
          </div>
        );
      },
      props: {
        nodeType: 'block',
        theme: 'info',
      },
    },
  },
  
  options: {
    display: {
      title: 'Callout',
      description: 'Insert a callout block',
    },
    shortcuts: ['callout', 'info'],
  },
  
  parsers: {
    html: {
      deserialize: {
        nodeNames: ['DIV'],
        parse: (el, editor) => {
          if (el.className.includes('callout')) {
            return {
              type: 'callout',
              props: {
                nodeType: 'block',
                theme: el.dataset.theme || 'info',
              },
            };
          }
        },
      },
      serialize: (element, text) => {
        const theme = element.props?.theme || 'info';
        return `<div class="callout callout-${theme}" data-theme="${theme}">
          ${serializeTextNodes(element.children)}
        </div>`;
      },
    },
    markdown: {
      serialize: (element) => {
        return `> ${serializeTextNodesIntoMarkdown(element.children)}\n`;
      },
    },
  },
});

export default Callout;

Element Configuration

Elements define the structure of your plugin’s content:
elements: {
  'element-type': {
    // Render function
    render: (props: PluginElementRenderProps) => JSX.Element,
    
    // Default props
    props: {
      nodeType: 'block' | 'inline' | 'void' | 'inlineVoid',
      // Custom props
    },
    
    // Mark as root element (default: first element)
    asRoot: true,
    
    // Allowed child elements
    children: ['other-element-type'],
    
    // Inject elements from other plugins
    injectElementsFromPlugins: ['Link'],
    
    // Placeholder text
    placeholder: 'Type something...',
  },
}

Node Types

props: {
  nodeType: 'block',  // Regular block element
}

Plugin Options

Configure how your plugin appears in the UI:
options: {
  display: {
    title: 'My Block',
    description: 'Insert a custom block',
    icon: <IconComponent />,  // Optional React icon
  },
  
  // Slash command shortcuts
  shortcuts: ['myblock', 'custom', 'mb'],
  
  // HTML attributes applied to root element
  HTMLAttributes: {
    className: 'my-block',
    'data-custom': 'value',
  },
}

Event Handlers

Handle keyboard and DOM events:
events: {
  onKeyDown: (editor, slate, options) => {
    const { hotkeys, currentBlock, defaultBlock } = options;
    
    return (event) => {
      // Handle Enter key
      if (hotkeys.isEnter(event)) {
        event.preventDefault();
        
        // Insert a new paragraph below
        editor.insertBlock('Paragraph', {
          at: currentBlock.meta.order + 1,
          focus: true,
        });
      }
      
      // Handle Tab key
      if (hotkeys.isTab(event)) {
        event.preventDefault();
        editor.increaseBlockDepth({});
      }
    };
  },
  
  onPaste: (editor, slate, options) => {
    return (event) => {
      // Handle paste
      const text = event.clipboardData.getData('text/plain');
      // Custom paste logic
    };
  },
  
  // Other events: onCopy, onCut, onDrop, onClick, etc.
}

Available Hotkeys

hotkeys.isEnter(event)        // Enter key
hotkeys.isShiftEnter(event)   // Shift + Enter
hotkeys.isBackspace(event)    // Backspace
hotkeys.isTab(event)          // Tab
hotkeys.isShiftTab(event)     // Shift + Tab
hotkeys.isBold(event)         // Cmd/Ctrl + B
hotkeys.isItalic(event)       // Cmd/Ctrl + I
hotkeys.isUndo(event)         // Cmd/Ctrl + Z
hotkeys.isRedo(event)         // Cmd/Ctrl + Shift + Z
hotkeys.isSelect(event)       // Cmd/Ctrl + A
hotkeys.isCopy(event)         // Cmd/Ctrl + C
hotkeys.isCut(event)          // Cmd/Ctrl + X
hotkeys.isSlashCommand(event) // /
// ... and more

Lifecycle Hooks

lifecycle: {
  // Called before block is created
  beforeCreate: (editor) => {
    return editor.y('my-element', {
      children: [editor.y.text('Default content')],
    });
  },
  
  // Called after block is created
  onCreate: (editor, blockId) => {
    console.log('Block created:', blockId);
  },
  
  // Called when block is destroyed
  onDestroy: (editor, blockId) => {
    console.log('Block destroyed:', blockId);
    // Cleanup
  },
}

Custom Commands

Define plugin-specific commands:
const CalloutCommands = {
  setTheme: (editor: YooEditor, blockId: string, theme: 'info' | 'warning' | 'success' | 'error') => {
    editor.updateBlock(blockId, {
      value: [{
        ...editor.children[blockId].value[0],
        props: {
          ...editor.children[blockId].value[0].props,
          theme,
        },
      }],
    });
  },
};

const Callout = new YooptaPlugin({
  type: 'Callout',
  commands: CalloutCommands,
  // ...
});

// Usage
CalloutCommands.setTheme(editor, blockId, 'warning');

Slate Extensions

Extend Slate editor behavior:
import type { SlateEditor, YooEditor } from '@yoopta/editor';

function withMyPlugin(slate: SlateEditor, editor: YooEditor, blockId: string): SlateEditor {
  const { isVoid, isInline } = slate;
  
  // Make specific elements void
  slate.isVoid = (element) => {
    return element.type === 'my-void-element' || isVoid(element);
  };
  
  // Make specific elements inline
  slate.isInline = (element) => {
    return element.type === 'my-inline-element' || isInline(element);
  };
  
  return slate;
}

const MyPlugin = new YooptaPlugin({
  type: 'MyPlugin',
  extensions: withMyPlugin,
  // ...
});

Complex Plugin Example

Here’s a more complex plugin with multiple elements:
type AccordionElement = {
  id: string;
  type: 'accordion';
  children: [AccordionItemElement];
  props: { nodeType: 'block' };
};

type AccordionItemElement = {
  id: string;
  type: 'accordion-item';
  children: [AccordionTitleElement, AccordionContentElement];
  props: { nodeType: 'block'; expanded?: boolean };
};

type AccordionTitleElement = {
  id: string;
  type: 'accordion-title';
  children: any[];
  props: { nodeType: 'block' };
};

type AccordionContentElement = {
  id: string;
  type: 'accordion-content';
  children: any[];
  props: { nodeType: 'block' };
};

type AccordionElementMap = {
  accordion: AccordionElement;
  'accordion-item': AccordionItemElement;
  'accordion-title': AccordionTitleElement;
  'accordion-content': AccordionContentElement;
};

const Accordion = new YooptaPlugin<AccordionElementMap>({
  type: 'Accordion',
  
  elements: {
    accordion: {
      render: (props) => <div className="accordion">{props.children}</div>,
      props: { nodeType: 'block' },
      asRoot: true,
      children: ['accordion-item'],
    },
    'accordion-item': {
      render: (props) => {
        const [isOpen, setIsOpen] = useState(props.element.props?.expanded || false);
        
        return (
          <div className="accordion-item" data-open={isOpen}>
            {props.children}
          </div>
        );
      },
      props: { nodeType: 'block', expanded: false },
      children: ['accordion-title', 'accordion-content'],
    },
    'accordion-title': {
      render: (props) => <div className="accordion-title">{props.children}</div>,
      props: { nodeType: 'block' },
      placeholder: 'Accordion title...',
    },
    'accordion-content': {
      render: (props) => <div className="accordion-content">{props.children}</div>,
      props: { nodeType: 'block' },
      placeholder: 'Accordion content...',
    },
  },
  
  options: {
    display: {
      title: 'Accordion',
      description: 'Create collapsible sections',
    },
    shortcuts: ['accordion', 'collapse'],
  },
});

Best Practices

1

Type your elements

Always define TypeScript types for your elements:
type MyElementMap = {
  'my-element': MyElement;
};

const plugin = new YooptaPlugin<MyElementMap>({ ... });
2

Use semantic HTML

Render semantic HTML elements for accessibility:
render: (props) => <article {...props.attributes}>{props.children}</article>
3

Handle edge cases

Test your plugin with empty content, special characters, and nested structures.
4

Provide parsers

Implement HTML and Markdown parsers for import/export compatibility.
5

Document your plugin

Add JSDoc comments for better IDE support:
/**
 * Callout block for highlighting important information.
 * Supports themes: info, warning, success, error.
 */
const Callout = new YooptaPlugin({ ... });

Plugin Testing

Test your plugin with Vitest:
import { describe, it, expect } from 'vitest';
import { createYooptaEditor } from '@yoopta/editor';
import MyPlugin from './my-plugin';

describe('MyPlugin', () => {
  it('should insert block', () => {
    const editor = createYooptaEditor({
      plugins: [MyPlugin],
    });
    
    editor.insertBlock('MyPlugin', { focus: true });
    
    const blocks = Object.values(editor.getEditorValue());
    expect(blocks).toHaveLength(1);
    expect(blocks[0].type).toBe('MyPlugin');
  });
  
  it('should serialize to HTML', () => {
    const editor = createYooptaEditor({
      plugins: [MyPlugin],
    });
    
    editor.insertBlock('MyPlugin');
    const html = editor.getHTML(editor.getEditorValue());
    
    expect(html).toContain('<div class="my-plugin">');
  });
});

Build docs developers (and LLMs) love