Migration

    Migration Guide

    Switching from TinyMCE, Quill, or an older version of Scribe? This guide covers everything you need to migrate your existing editor integration step by step.

    Migration Guide

    Migrating from DOM-driven to editor-state-driven architecture

    Breaking Changes

    The core API (editor.bold(), editor.getHTML(), etc.) is unchanged. The breaking changes are in how toolbar state is detected and how commands expose metadata. Most integrations require minimal changes.

    Architecture Shift

    OLD ARCHITECTURE                    NEW ARCHITECTURE
    ═══════════════                    ═══════════════
    
    Browser DOM                        Browser Selection
        ↓                                   ↓
    queryCommandState()                Selection Normalization
        ↓                                   ↓
    Toolbar State                      Editor State (FormatState)
                                            ↓
    ❌ Inconsistent                    Command Metadata Resolver
    ❌ Browser-dependent                    ↓
    ❌ No normalization                Toolbar State Output
                                       
                                       ✅ Consistent
                                       ✅ Cross-browser
                                       ✅ Normalized DOM
                                       ✅ Self-describing commands

    Old vs New Architecture

    Side-by-side comparison of the two approaches

    // ═══════════════════════════════════════════════
    // OLD ARCHITECTURE (DOM-Driven)
    // ═══════════════════════════════════════════════
    
    // ❌ Toolbar read state from queryCommandState
    const isBold = document.queryCommandState('bold');
    const fontSize = document.queryCommandValue('fontSize');
    
    // ❌ State detection was browser-dependent
    // Chrome, Firefox, Safari returned different values
    
    // ❌ No normalization after commands
    document.execCommand('bold');
    // Left behind <span>, empty nodes, zero-width spaces
    
    // ❌ No selection lifecycle
    // Selection lost when clicking toolbar buttons
    // Manual save/restore with fragile timing
    
    // ═══════════════════════════════════════════════
    // NEW ARCHITECTURE (Editor-State-Driven)
    // ═══════════════════════════════════════════════
    
    // ✅ Toolbar reads from command metadata
    const meta = editor.getCommandState('bold');
    // → { active: true, supported: true, category: 'inline', ... }
    
    // ✅ FormatState computed from DOM walk + computed styles
    const state = editor.getFormatState();
    // → { bold: true, italic: false, link: null, ... }
    
    // ✅ DOM normalization after every command
    editor.bold();
    // → Automatically: merge nodes, remove empties, clean junk
    
    // ✅ Selection lifecycle with snapshot stack
    editor.saveSelection('modal');
    // → Pushes to stack, IME-safe, reason-tagged
    editor.restoreSelection();
    // → Pops from stack, applies to document

    Plugin Author Migration

    How to update custom plugins for the new architecture

    If your plugin uses queryCommandState() or queryCommandValue(), you must migrate to reading from FormatState instead.
    // ═══════════════════════════════════════════════
    // PLUGIN COMMAND MIGRATION
    // ═══════════════════════════════════════════════
    
    // OLD: Plugin commands used exec() and queryCommandState
    const oldPlugin = {
      name: 'highlight',
      commands: {
        highlight: (editor, color) => {
          // ❌ Used execCommand directly
          document.execCommand('hiliteColor', false, color);
        }
      },
      toolbarItems: [{
        name: 'highlight',
        command: 'highlight',
        // ❌ isActive read from DOM
        isActive: (state) => false, // couldn't detect
      }]
    };
    
    // NEW: Plugin commands work with editor state
    const newPlugin = {
      name: 'highlight',
      commands: {
        highlight: (editor, color) => {
          // ✅ Still uses execCommand internally (that's fine)
          // But the DOM is normalized AFTER by the core engine
          editor.getDocument().execCommand('hiliteColor', false, color);
        }
      },
      toolbarItems: [{
        name: 'highlight',
        command: 'highlight',
        // ✅ isActive reads from FormatState
        isActive: (state) => !!state.backgroundColor,
      }]
    };
    
    // KEY CHANGE: Commands still use execCommand for DOM mutations.
    // What changed is HOW STATE IS DETECTED:
    // - Old: queryCommandState / DOM heuristics
    // - New: FormatState from SelectionManager + CommandMetadata

    Toolbar Integration Migration

    Updating custom toolbars to use command metadata

    // ═══════════════════════════════════════════════
    // TOOLBAR INTEGRATION MIGRATION
    // ═══════════════════════════════════════════════
    
    // OLD: Toolbar subscribed to selectionChange
    editor.on('selectionChange', (selection) => {
      // ❌ Read formats directly from selection
      if (selection) {
        boldBtn.classList.toggle('active', selection.formats.bold);
      }
    });
    
    // NEW: Toolbar subscribes to formatChange (diffed, efficient)
    editor.on('formatChange', ({ current, changed, source }) => {
      // ✅ Only fires when format state ACTUALLY changes
      // ✅ 'changed' array tells you exactly what changed
      for (const key of changed) {
        const btn = toolbar.querySelector(`[data-format="${key}"]`);
        if (btn) btn.classList.toggle('active', !!current[key]);
      }
    });
    
    // EVEN BETTER: Use command metadata
    editor.on('formatChange', () => {
      toolbar.querySelectorAll('button').forEach(btn => {
        const cmd = btn.dataset.command;
        const meta = editor.getCommandState(cmd);
        if (meta) {
          btn.classList.toggle('active', meta.active);
          btn.disabled = !meta.supported;
          // Rich attributes (link URL, font size, etc.)
          if (meta.attributes) {
            updateAttributeUI(cmd, meta.attributes);
          }
        }
      });
    });
    
    // METADATA-DRIVEN TOOLBAR (fully dynamic)
    function buildToolbar(editor) {
      const commands = editor.getAllCommandStates();
      const toolbar = document.createElement('div');
      
      // Group by category
      const groups = {};
      commands.forEach(cmd => {
        if (!cmd.fixedToolbar) return;
        const cat = cmd.category;
        if (!groups[cat]) groups[cat] = [];
        groups[cat].push(cmd);
      });
      
      // Render groups with separators
      Object.entries(groups).forEach(([category, cmds]) => {
        const group = document.createElement('div');
        group.className = 'toolbar-group';
        cmds.forEach(cmd => {
          const btn = document.createElement('button');
          btn.dataset.command = cmd.name;
          btn.textContent = cmd.label;
          btn.title = cmd.shortcut || cmd.label;
          group.appendChild(btn);
        });
        toolbar.appendChild(group);
      });
      
      return toolbar;
    }

    Selection Management Migration

    Stack-based selection lifecycle and IME safety

    // ═══════════════════════════════════════════════
    // SELECTION MANAGEMENT MIGRATION
    // ═══════════════════════════════════════════════
    
    // OLD: Single save slot, no reason tracking
    editor.saveSelection();
    // ... do stuff ...
    editor.restoreSelection();
    
    // NEW: Stack-based with reason tags
    editor.saveSelection('modal');
    // Opens a nested dropdown inside the modal:
    editor.saveSelection('dropdown');
    // Restores are LIFO (last-in, first-out):
    editor.restoreSelection(); // restores dropdown selection
    editor.restoreSelection(); // restores modal selection
    
    // NEW: IME-safe
    // During Chinese/Japanese/Korean composition,
    // save/restore are no-ops to prevent corruption.
    // No migration needed — just works automatically.
    
    // NEW: normalize() method
    // After complex DOM manipulations in plugins:
    editor.normalize();
    // Cleans up any browser artifacts

    Migration Checklist

    Quick reference for what needs to change

    Remove

    queryCommandState() calls
    queryCommandValue() calls
    ❌ DOM tree heuristics for toolbar state
    ❌ Manual DOM cleanup after commands

    Use Instead

    editor.getFormatState()
    editor.getCommandState(name)
    editor.on('formatChange', ...)
    editor.normalize() (automatic)
    editor.saveSelection(reason)

    No Change Needed

    editor.bold()
    editor.getHTML()
    editor.setHTML()
    editor.on('change')
    editor.destroy()

    New APIs

    editor.normalize()
    CommandCategory type
    exclusiveGroup
    SelectionSnapshot
    DOMNormalizer class

    Enhanced APIs

    saveSelection(reason?)
    CommandMetadata.category
    CommandMetadata.exclusiveGroup
    CommandMetadata.floatingToolbar
    CommandMetadata.fixedToolbar