Plugin Anatomy
A Yoopta plugin is created using theYooptaPlugin 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
Type your elements
Always define TypeScript types for your elements:
type MyElementMap = {
'my-element': MyElement;
};
const plugin = new YooptaPlugin<MyElementMap>({ ... });
Use semantic HTML
Render semantic HTML elements for accessibility:
render: (props) => <article {...props.attributes}>{props.children}</article>
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">');
});
});