Plugin Development
Learn how to create custom plugins for Marzipan.
Creating a Plugin
Plugins are functions that receive the editor instance and enhance its functionality:
ts
import type { MarzipanInstance } from '@pinkpixel/marzipan';
export function myPlugin(options = {}) {
return (editor: MarzipanInstance) => {
// Plugin implementation
};
}
Plugin Structure
Basic Plugin
ts
export function simplePlugin() {
return (editor) => {
const { container, textarea, updatePreview } = editor;
// Your plugin logic
console.log('Plugin loaded!');
};
}
Plugin with Options
ts
interface MyPluginOptions {
label?: string;
defaultValue?: string;
}
export function configurablePlugin(opts: MyPluginOptions = {}) {
const { label = '⭐', defaultValue = 'default' } = opts;
return (editor) => {
// Use options
console.log(`Plugin with ${label}`);
};
}
Accessing Editor Components
Container
The main editor container:
ts
function containerPlugin() {
return (editor) => {
const { container } = editor;
container.classList.add('custom-class');
};
}
Textarea
The markdown input textarea:
ts
function textareaPlugin() {
return (editor) => {
const { textarea } = editor;
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
// Handle tab
}
});
};
}
Preview Element
The rendered preview:
ts
function previewPlugin() {
return (editor) => {
const preview = editor.container.querySelector('.marzipan-preview');
if (preview) {
preview.addEventListener('click', (e) => {
// Handle preview clicks
});
}
};
}
Adding Toolbar Buttons
ts
function toolbarPlugin(options = {}) {
return (editor) => {
const toolbar = editor.container.querySelector('.marzipan-toolbar');
if (!toolbar) return;
const button = document.createElement('button');
button.type = 'button';
button.className = 'mz-btn';
button.textContent = options.label || '🔧';
button.title = options.title || 'My Action';
button.onclick = () => {
// Button action
const { textarea, updatePreview } = editor;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.setRangeText('**inserted**', start, end, 'end');
updatePreview();
textarea.focus();
};
toolbar.appendChild(button);
};
}
Inserting Content
At Cursor Position
ts
function insertAtCursor(editor, text: string) {
const { textarea, updatePreview } = editor;
const start = textarea.selectionStart ?? 0;
const end = textarea.selectionEnd ?? 0;
textarea.setRangeText(text, start, end, 'end');
updatePreview();
textarea.focus();
}
Wrapping Selection
ts
function wrapSelection(editor, prefix: string, suffix: string) {
const { textarea, updatePreview } = editor;
const start = textarea.selectionStart ?? 0;
const end = textarea.selectionEnd ?? 0;
const selection = textarea.value.substring(start, end);
textarea.setRangeText(
`${prefix}${selection}${suffix}`,
start,
end,
'end'
);
updatePreview();
textarea.focus();
}
Working with Preview
Rendering Custom Content
ts
function customRenderPlugin() {
return (editor) => {
const { container, updatePreview } = editor;
// Hook into preview updates
const originalUpdate = updatePreview.bind(editor);
editor.updatePreview = function() {
originalUpdate();
// Post-process preview
const preview = container.querySelector('.marzipan-preview');
if (preview) {
// Add custom rendering
preview.querySelectorAll('.custom-block').forEach(el => {
// Transform elements
});
}
};
};
}
Event Handling
Listen to Editor Events
ts
function eventPlugin() {
return (editor) => {
const { textarea, container } = editor;
// Textarea events
textarea.addEventListener('input', () => {
console.log('Content changed');
});
textarea.addEventListener('selectionchange', () => {
console.log('Selection changed');
});
// Container events
container.addEventListener('click', (e) => {
console.log('Editor clicked');
});
};
}
Custom Events
ts
function customEventPlugin() {
return (editor) => {
const { container } = editor;
// Dispatch custom event
container.dispatchEvent(
new CustomEvent('marzipan:custom', {
detail: { data: 'value' }
})
);
// Listen for custom events
container.addEventListener('marzipan:custom', (e) => {
console.log(e.detail);
});
};
}
Styling Plugins
Exporting Styles
ts
export const myPluginStyles = `
.my-plugin-button {
background: var(--mz-accent);
color: white;
border: none;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
}
.my-plugin-button:hover {
opacity: 0.9;
}
`;
export function myPlugin() {
return (editor) => {
// Plugin implementation
};
}
Using CSS Variables
ts
export const styledPlugin = () => (editor) => {
const button = document.createElement('button');
button.style.background = 'var(--mz-accent)';
button.style.color = 'var(--mz-fg)';
button.style.border = '1px solid var(--mz-border)';
// ...
};
Best Practices
Plugin Guidelines
- Always return a function from your plugin factory
- Accept an options object for configurability
- Provide sensible defaults for all options
- Export styles separately if needed
- Clean up event listeners when possible
- Document your plugin with TypeScript types
- Test across themes to ensure compatibility
- Handle edge cases gracefully
- Use CSS variables for theming
- Keep plugins focused on one task
TypeScript Types
ts
interface MarzipanInstance {
container: HTMLElement;
textarea: HTMLTextAreaElement;
updatePreview: () => void;
getValue: () => string;
setValue: (value: string) => void;
// ... other properties
}
type MarzipanPlugin = (editor: MarzipanInstance) => void;
type PluginFactory<T = any> = (options?: T) => MarzipanPlugin;
Complete Plugin Example
ts
// myAwesomePlugin.ts
import type { MarzipanInstance } from '@pinkpixel/marzipan';
interface AwesomePluginOptions {
label?: string;
icon?: string;
placeholder?: string;
}
export function awesomePlugin(opts: AwesomePluginOptions = {}) {
const {
label = 'Awesome',
icon = '✨',
placeholder = 'Enter text...'
} = opts;
return (editor: MarzipanInstance) => {
const { container, textarea, updatePreview } = editor;
// Find toolbar
const toolbar = container.querySelector('.marzipan-toolbar');
if (!toolbar) return;
// Create button
const button = document.createElement('button');
button.type = 'button';
button.className = 'mz-btn mz-btn-awesome';
button.innerHTML = `${icon} <span>${label}</span>`;
button.title = `Insert ${label}`;
// Create popup
let popup: HTMLElement | null = null;
const closePopup = () => {
popup?.remove();
popup = null;
};
const openPopup = () => {
if (popup) {
closePopup();
return;
}
popup = document.createElement('div');
popup.className = 'mz-pop mz-awesome-popup';
popup.innerHTML = `
<input
type="text"
placeholder="${placeholder}"
class="mz-awesome-input"
/>
<button type="button" class="mz-awesome-submit">Insert</button>
`;
document.body.appendChild(popup);
// Position popup
const rect = button.getBoundingClientRect();
popup.style.left = `${window.scrollX + rect.left}px`;
popup.style.top = `${window.scrollY + rect.bottom + 6}px`;
// Handle input
const input = popup.querySelector('.mz-awesome-input') as HTMLInputElement;
const submit = popup.querySelector('.mz-awesome-submit') as HTMLButtonElement;
const insertText = () => {
const value = input.value.trim();
if (!value) return;
const start = textarea.selectionStart ?? 0;
const end = textarea.selectionEnd ?? 0;
textarea.setRangeText(`**${value}**`, start, end, 'end');
updatePreview();
textarea.focus();
closePopup();
};
submit.onclick = insertText;
input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
insertText();
} else if (e.key === 'Escape') {
closePopup();
}
};
input.focus();
// Close on outside click
setTimeout(() => {
document.addEventListener('click', (e) => {
if (!popup?.contains(e.target as Node) && e.target !== button) {
closePopup();
}
}, { once: true });
}, 0);
};
button.onclick = openPopup;
toolbar.appendChild(button);
};
}
export const awesomePluginStyles = `
.mz-awesome-popup {
display: flex;
gap: 8px;
padding: 12px;
min-width: 300px;
}
.mz-awesome-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--mz-border);
border-radius: 6px;
background: var(--mz-bg);
color: var(--mz-fg);
}
.mz-awesome-submit {
padding: 8px 16px;
border: 1px solid var(--mz-border);
border-radius: 6px;
background: var(--mz-accent);
color: white;
cursor: pointer;
}
`;
Testing Your Plugin
ts
// Example usage
import { Marzipan } from '@pinkpixel/marzipan';
import { awesomePlugin, awesomePluginStyles } from './myAwesomePlugin';
// Inject styles
const style = document.createElement('style');
style.textContent = awesomePluginStyles;
document.head.appendChild(style);
// Use plugin
const [editor] = new Marzipan('#editor', {
toolbar: true,
plugins: [
awesomePlugin({
label: 'Magic',
icon: '🪄',
placeholder: 'Enter magic text...'
})
]
});
Publishing Your Plugin
If you create a useful plugin, consider:
- Publishing to npm as a package
- Sharing on GitHub
- Adding documentation
- Including examples
- Writing tests
See Also
- Plugin Overview - Overview of plugin system
- Plugin API - Complete API reference
- Examples - First-party plugin source code
- Contributing - Contribute to Marzipan
Marzipan