Skip to content
On this page

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

  1. Always return a function from your plugin factory
  2. Accept an options object for configurability
  3. Provide sensible defaults for all options
  4. Export styles separately if needed
  5. Clean up event listeners when possible
  6. Document your plugin with TypeScript types
  7. Test across themes to ensure compatibility
  8. Handle edge cases gracefully
  9. Use CSS variables for theming
  10. 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:

  1. Publishing to npm as a package
  2. Sharing on GitHub
  3. Adding documentation
  4. Including examples
  5. Writing tests

See Also

Released under the Apache 2.0 License