Skip to main content
Yoopta Editor provides a comprehensive event system that allows you to listen to and respond to various editor events. This is essential for implementing features like auto-save, analytics, real-time collaboration, and custom UI updates.

Event Types

Yoopta Editor supports several event types:
packages/core/editor/src/editor/types.ts
type YooptaEditorEventKeys = 
  | 'change' 
  | 'focus' 
  | 'blur' 
  | 'block:copy' 
  | 'path-change'
  | 'decorations:change';

type YooptaEventsMap = {
  change: YooptaEventChangePayload;
  focus: boolean;
  blur: boolean;
  'block:copy': YooptaBlockData;
  'path-change': YooptaPath;
  'decorations:change': undefined;
};

Listening to Events

Use the editor.on() method to listen to events:
editor.on('change', (payload) => {
  console.log('Editor changed:', payload);
});

editor.on('focus', (isFocused) => {
  console.log('Editor focused:', isFocused);
});

editor.on('blur', (isBlurred) => {
  console.log('Editor blurred:', isBlurred);
});

Event API Methods

packages/core/editor/src/editor/types.ts
interface YooEditor {
  // Register event listener
  on: <K extends keyof YooptaEventsMap>(
    event: K,
    fn: (payload: YooptaEventsMap[K]) => void,
  ) => void;
  
  // Register one-time listener (fires once then removes)
  once: <K extends keyof YooptaEventsMap>(
    event: K,
    fn: (payload: YooptaEventsMap[K]) => void,
  ) => void;
  
  // Remove event listener
  off: <K extends keyof YooptaEventsMap>(
    event: K,
    fn: (payload: YooptaEventsMap[K]) => void,
  ) => void;
  
  // Emit event manually
  emit: <K extends keyof YooptaEventsMap>(
    event: K,
    payload: YooptaEventsMap[K]
  ) => void;
}
The event system is built on EventEmitter3, providing a familiar and performant event handling pattern.

Change Event

The most important event - fires whenever editor content changes:
packages/core/editor/src/editor/types.ts
type YooptaEventChangePayload = {
  operations: YooptaOperation[];  // List of operations performed
  value: YooptaContentValue;      // New editor content
};

Usage Example

editor.on('change', ({ operations, value }) => {
  console.log('Operations performed:', operations.length);
  console.log('New content:', value);
  
  // Auto-save
  saveToBackend(value);
  
  // Update local state
  setValue(value);
  
  // Track analytics
  trackEditorChange({
    operationTypes: operations.map(op => op.type),
    blockCount: Object.keys(value).length,
  });
});

Change Operations

The operations array contains all operations that caused the change:
editor.on('change', ({ operations }) => {
  operations.forEach(op => {
    switch (op.type) {
      case 'insert_block':
        console.log('Block inserted:', op.block);
        break;
      case 'delete_block':
        console.log('Block deleted:', op.block);
        break;
      case 'set_block_value':
        console.log('Block value changed:', op.id);
        break;
      case 'split_block':
        console.log('Block split:', op.properties.nextBlock);
        break;
      case 'merge_block':
        console.log('Block merged:', op.properties.mergedBlock);
        break;
      case 'toggle_block':
        console.log('Block toggled:', op.properties.toggledBlock);
        break;
      case 'move_block':
        console.log('Block moved:', op.properties);
        break;
      // ... more operation types
    }
  });
});
Use the operations array to implement granular change tracking, undo/redo UI, or collaborative editing features.

Focus Event

Fires when the editor gains focus:
editor.on('focus', (isFocused) => {
  console.log('Editor focused:', isFocused);
  
  // Update UI
  setEditorFocused(true);
  
  // Show floating toolbar
  showToolbar();
});

Blur Event

Fires when the editor loses focus:
editor.on('blur', (isBlurred) => {
  console.log('Editor blurred:', isBlurred);
  
  // Update UI
  setEditorFocused(false);
  
  // Hide floating toolbar
  hideToolbar();
  
  // Save changes
  saveContent();
});

Path Change Event

Fires when the current block path or selection changes:
packages/core/editor/src/editor/types.ts
type YooptaPath = {
  current: YooptaPathIndex;           // Current block index
  selected?: number[] | null;          // Selected block indices
  selection?: Selection | null;        // Slate selection
  source?: null | 'selection-box' | 'native-selection' | 'mousemove' | 'keyboard' | 'copy-paste';
};

Usage Example

editor.on('path-change', (path) => {
  console.log('Current block:', path.current);
  console.log('Selected blocks:', path.selected);
  console.log('Selection source:', path.source);
  
  // Update UI based on current block
  if (path.current !== null) {
    const block = editor.getBlock({ 
      id: Object.keys(editor.children)[path.current] 
    });
    
    if (block) {
      updateBlockTypeUI(block.type);
      updateBlockDepthUI(block.meta.depth);
    }
  }
  
  // Handle multi-block selection
  if (path.selected && path.selected.length > 1) {
    showMultiBlockActions();
  } else {
    hideMultiBlockActions();
  }
});

Block Copy Event

Fires when a block is copied:
editor.on('block:copy', (block) => {
  console.log('Block copied:', block);
  
  // Track analytics
  trackBlockCopy(block.type);
  
  // Custom clipboard handling
  customClipboard.set(block);
});

Decorations Change Event

Fires when decorations (highlights, cursors, etc.) change:
editor.on('decorations:change', () => {
  console.log('Decorations changed');
  
  // Useful for collaboration features
  // or search highlighting updates
});
Decorations are used for features like collaboration cursors, search highlights, and other non-persistent visual overlays.

One-Time Listeners

Use once() to listen to an event only once:
// Listen only for the first change
editor.once('change', ({ value }) => {
  console.log('First change detected:', value);
  initializeTracking();
});

// Listen for the first focus
editor.once('focus', () => {
  console.log('Editor focused for the first time');
  showOnboardingTip();
});

Removing Event Listeners

Remove event listeners when they’re no longer needed:
const handleChange = ({ value }) => {
  console.log('Content changed:', value);
};

// Add listener
editor.on('change', handleChange);

// Remove listener
editor.off('change', handleChange);
Always remove event listeners in cleanup functions (e.g., React’s useEffect cleanup) to prevent memory leaks.

React Example

import { useYooptaEditor } from '@yoopta/editor';
import { useEffect } from 'react';

function MyComponent() {
  const editor = useYooptaEditor();
  
  useEffect(() => {
    const handleChange = ({ value }) => {
      console.log('Changed:', value);
    };
    
    // Add listener
    editor.on('change', handleChange);
    
    // Cleanup: remove listener
    return () => {
      editor.off('change', handleChange);
    };
  }, [editor]);
  
  return <div>My Component</div>;
}

Emitting Custom Events

You can emit events manually:
// Emit change event
editor.emit('change', {
  operations: [],
  value: editor.children,
});

// Emit custom path change
editor.emit('path-change', {
  current: 5,
  selected: [4, 5, 6],
  source: 'keyboard',
});
Manual event emission is useful for triggering event handlers after programmatic changes or in testing scenarios.

Real-World Use Cases

Auto-Save

Implement auto-save with debouncing:
import { debounce } from 'lodash';

const autoSave = debounce(async (content: YooptaContentValue) => {
  try {
    await saveToBackend(content);
    console.log('Content auto-saved');
  } catch (error) {
    console.error('Auto-save failed:', error);
  }
}, 2000);

editor.on('change', ({ value }) => {
  autoSave(value);
});

Analytics Tracking

Track user interactions:
editor.on('change', ({ operations }) => {
  operations.forEach(op => {
    switch (op.type) {
      case 'insert_block':
        analytics.track('block_inserted', {
          blockType: op.block.type,
        });
        break;
      case 'delete_block':
        analytics.track('block_deleted', {
          blockType: op.block.type,
        });
        break;
      // ... more tracking
    }
  });
});

editor.on('focus', () => {
  analytics.track('editor_focused');
});

editor.on('blur', () => {
  analytics.track('editor_blurred');
});

Word Count

Display real-time word count:
import { useState, useEffect } from 'react';

function useWordCount(editor: YooEditor) {
  const [wordCount, setWordCount] = useState(0);
  
  useEffect(() => {
    const updateWordCount = ({ value }) => {
      const text = editor.getPlainText(value);
      const words = text.trim().split(/\s+/).filter(Boolean).length;
      setWordCount(words);
    };
    
    // Initial count
    updateWordCount({ value: editor.children });
    
    // Update on changes
    editor.on('change', updateWordCount);
    
    return () => {
      editor.off('change', updateWordCount);
    };
  }, [editor]);
  
  return wordCount;
}

// Usage
function WordCounter() {
  const editor = useYooptaEditor();
  const wordCount = useWordCount(editor);
  
  return <div>Word Count: {wordCount}</div>;
}

Undo/Redo UI

Show undo/redo button states:
import { useState, useEffect } from 'react';

function useHistoryState(editor: YooEditor) {
  const [canUndo, setCanUndo] = useState(false);
  const [canRedo, setCanRedo] = useState(false);
  
  useEffect(() => {
    const updateHistoryState = () => {
      setCanUndo(editor.historyStack.undos.length > 0);
      setCanRedo(editor.historyStack.redos.length > 0);
    };
    
    // Update on every change
    editor.on('change', updateHistoryState);
    
    return () => {
      editor.off('change', updateHistoryState);
    };
  }, [editor]);
  
  return { canUndo, canRedo };
}

// Usage
function HistoryButtons() {
  const editor = useYooptaEditor();
  const { canUndo, canRedo } = useHistoryState(editor);
  
  return (
    <div>
      <button 
        onClick={() => editor.undo()} 
        disabled={!canUndo}
      >
        Undo
      </button>
      <button 
        onClick={() => editor.redo()} 
        disabled={!canRedo}
      >
        Redo
      </button>
    </div>
  );
}

Multi-Block Selection

Handle multi-block selection:
editor.on('path-change', (path) => {
  if (path.selected && path.selected.length > 1) {
    console.log('Multiple blocks selected:', path.selected);
    
    // Show batch actions toolbar
    showBatchActionsToolbar({
      onDelete: () => {
        path.selected.forEach(index => {
          const blockId = Object.keys(editor.children)[index];
          editor.deleteBlock(blockId);
        });
      },
      onDuplicate: () => {
        path.selected.forEach(index => {
          const blockId = Object.keys(editor.children)[index];
          editor.duplicateBlock(blockId);
        });
      },
    });
  } else {
    hideBatchActionsToolbar();
  }
});

Change Detection

Detect specific types of changes:
editor.on('change', ({ operations }) => {
  const hasTextChanges = operations.some(
    op => op.type === 'set_block_value' || op.type === 'set_slate'
  );
  
  const hasStructureChanges = operations.some(
    op => op.type === 'insert_block' || 
         op.type === 'delete_block' || 
         op.type === 'move_block'
  );
  
  if (hasTextChanges) {
    console.log('Text content changed');
  }
  
  if (hasStructureChanges) {
    console.log('Document structure changed');
  }
});

Event Performance

Debouncing Events

For performance-critical operations, debounce event handlers:
import { debounce } from 'lodash';

const handleChangeDebounced = debounce(({ value }) => {
  // Expensive operation
  performExpensiveAnalysis(value);
}, 500);

editor.on('change', handleChangeDebounced);

Throttling Events

Throttle rapid events:
import { throttle } from 'lodash';

const handlePathChangeThrottled = throttle((path) => {
  // Limit updates to once per 100ms
  updateUI(path);
}, 100);

editor.on('path-change', handlePathChangeThrottled);

Batch Processing

Process multiple operations together:
editor.on('change', ({ operations }) => {
  // Batch process operations
  const blockInserts = operations.filter(op => op.type === 'insert_block');
  const blockDeletes = operations.filter(op => op.type === 'delete_block');
  
  if (blockInserts.length > 0) {
    handleBatchInserts(blockInserts);
  }
  
  if (blockDeletes.length > 0) {
    handleBatchDeletes(blockDeletes);
  }
});

Best Practices

1. Clean Up Listeners

Always remove event listeners when components unmount:
useEffect(() => {
  const handler = (payload) => { /* ... */ };
  editor.on('change', handler);
  
  return () => editor.off('change', handler);
}, [editor]);

2. Use Named Functions

Use named functions instead of inline functions for easier debugging and cleanup:
// Good
const handleChange = ({ value }) => {
  console.log('Changed:', value);
};
editor.on('change', handleChange);
editor.off('change', handleChange);  // Can properly remove

// Bad
editor.on('change', ({ value }) => {
  console.log('Changed:', value);
});
// Can't remove this listener easily

3. Avoid Heavy Operations

Keep event handlers lightweight:
// Good: Debounced heavy operation
const heavyOperation = debounce(async (value) => {
  await processData(value);
}, 1000);

editor.on('change', ({ value }) => {
  heavyOperation(value);
});

// Bad: Heavy operation on every change
editor.on('change', async ({ value }) => {
  await processData(value);  // Blocks UI
});

4. Check Editor State

Verify editor state before operations:
editor.on('path-change', (path) => {
  if (path.current === null) return;
  
  const blocks = Object.values(editor.children);
  if (path.current >= blocks.length) return;
  
  // Safe to proceed
  const currentBlock = blocks[path.current];
});

5. Handle Errors

Wrap event handlers in try-catch:
editor.on('change', ({ value }) => {
  try {
    processChange(value);
  } catch (error) {
    console.error('Error processing change:', error);
    // Handle error gracefully
  }
});

Next Steps

Editor Instance

Back to editor API reference

Blocks

Learn about block operations

Plugins

Build custom plugins with events

Collaboration

Implement real-time collaboration

Build docs developers (and LLMs) love