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 commandsOld 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 documentPlugin 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 + CommandMetadataToolbar 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 artifactsMigration 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 typeexclusiveGroupSelectionSnapshotDOMNormalizer classEnhanced APIs
saveSelection(reason?)CommandMetadata.categoryCommandMetadata.exclusiveGroupCommandMetadata.floatingToolbarCommandMetadata.fixedToolbar