Understanding Scribe's approach: ContentEditable with guardrails
Scribe uses ContentEditable but treats the DOM as a view layer. Editor state is derived from selection normalization and command metadata — never from browser DOM queries like queryCommandState.
// ContentEditable: Browser-native editing
// Pros:
// - Native undo/redo, spell check, IME support
// - Familiar cursor behavior
// - Accessibility built-in
// Cons:
// - Inconsistent across browsers
// - DOM mutations can be unpredictable
// - Harder to enforce schema
<div contenteditable="true">
<p>User can edit this directly</p>
</div>
// Scribe uses ContentEditable with guardrails:
// 1. DOM is the VIEW LAYER — not the source of truth
// 2. Editor state is derived from selection normalization
// 3. Post-command DOM normalization cleans browser artifacts
// 4. Toolbar state comes from Command Metadata, not DOM queries
const editor = Scribe.init('#editor', {
sanitize: {
allowedTags: ['p', 'strong', 'em', 'a'],
}
});
Cross-browser inconsistencies and why toolbar state must come from the editor, not the browser
The Problem: Browser Inconsistency
Same user action → Different DOM output
Chrome: <b>text</b>
Firefox: <span style="font-weight:700">text</span>
Safari: <strong>text</strong>
queryCommandState('bold') → inconsistent across browsers
queryCommandValue('fontSize') → may return stale data
❌ DOM-driven toolbar = unpredictable
✅ Editor-state-driven toolbar = consistent
// The browser DOM cannot be the source of truth for toolbar state.
// Here's why:
// 1. CROSS-BROWSER INCONSISTENCY
// Chrome: <b>bold text</b>
// Firefox: <span style="font-weight: 700">bold text</span>
// Safari: <strong>bold text</strong>
// All represent "bold" — but the DOM differs.
// 2. SELECTION API QUIRKS
// Range.commonAncestorContainer can point to unexpected nodes
// when selection spans across inline formatting boundaries.
//
// <strong>hel|lo</strong> wor|ld
// → commonAncestor is the parent <p>, not <strong>
// 3. queryCommandState IS UNRELIABLE
// document.queryCommandState('bold');
// ❌ Returns inconsistent values across browsers
// ❌ Doesn't work in iframes in some browsers
// ❌ May report stale state after programmatic DOM changes
// ❌ Deprecated and being removed from standards
// 4. BROWSER JUNK NODES
// After execCommand, browsers insert:
// - Zero-width spaces (\u200B)
// - Empty <span> wrappers
// - Unnecessary <font> tags
// - Duplicate formatting nodes
// SCRIBE'S SOLUTION:
// Selection → Normalization → Editor State → Command Metadata → Toolbar
//
// 1. Read the DOM via Selection API (not queryCommandState)
// 2. Walk the DOM tree to detect formatting tags + computed styles
// 3. Build a normalized FormatState object
// 4. Resolve CommandMetadata from FormatState
// 5. Toolbar reads CommandMetadata — never the DOM directly
// 6. After every command, run DOM normalization cleanup
Full lifecycle control for save, restore, and IME-safe selection management
Selection Lifecycle
┌─────────────────────────────────────────────┐
│ Selection Lifecycle │
│ │
│ UI Opens (toolbar/modal/dropdown) │
│ └→ editor.saveSelection('modal') │
│ └→ Pushes snapshot to stack │
│ │
│ User interacts with UI │
│ └→ Selection may be lost (focus change) │
│ │
│ Before command execution │
│ └→ editor.restoreSelection() │
│ └→ Pops snapshot, applies to document │
│ └→ editor.bold() / editor.link(url) │
│ │
│ Stack supports nesting: │
│ toolbar → dropdown → color picker │
│ Each save/restore is paired │
│ │
│ IME Safety: │
│ During composition (CJK input): │
│ └→ save() is no-op │
│ └→ getSelection() returns null │
│ └→ Prevents corruption of in-progress │
│ composition │
└─────────────────────────────────────────────┘
// Selection is first-class state in Scribe.
// The lifecycle ensures selection is never lost during UI interactions.
// SAVE selection when UI opens:
editor.saveSelection('toolbar'); // Toolbar interaction
editor.saveSelection('dropdown'); // Dropdown menu opens
editor.saveSelection('modal'); // Modal dialog opens
editor.saveSelection('floatingUI'); // Floating UI appears
// RESTORE selection before command execution:
editor.restoreSelection();
editor.bold(); // Applies to the saved selection
// The SelectionManager maintains a STACK of snapshots:
// - Each save() pushes a snapshot
// - Each restore() pops and applies
// - Supports nested UI interactions (toolbar → dropdown → color picker)
// IME COMPOSITION SAFETY:
// During IME composition (Chinese, Japanese, Korean input),
// selection operations are deferred to prevent corruption.
// The SelectionManager tracks composition state automatically.
// MULTI-NODE SELECTION:
// When selection spans across formatting boundaries:
// <strong>hel|lo</strong> <em>wor|ld</em>
// Scribe resolves the common ancestor and reports
// the formatting state of the deepest shared container.
// COLLAPSED SELECTION (cursor, no selection):
// FormatState still reports the formatting at the cursor position.
// This enables toolbar buttons to show "bold is active" even
// when no text is selected, if the cursor is inside a <strong> tag.
How Scribe manages cursor position and text selection
The browser's Selection API is powerful but verbose. Scribe provides a simplified interface while handling edge cases like iframe boundaries, IME composition, and complex DOM structures.
// Getting the current selection
const selection = window.getSelection();
const range = selection?.getRangeAt(0);
// Range properties
console.log({
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
collapsed: range.collapsed,
commonAncestor: range.commonAncestorContainer
});
// Scribe abstracts this via SelectionManager
const editor = Scribe.init('#editor');
// Get current selection state
const state = editor.getSelection();
console.log({
text: state.text,
collapsed: state.collapsed,
rect: state.rect,
formats: state.formats
});
// Save & restore selection (lifecycle-managed)
editor.saveSelection('modal');
// ... open modal, do async work ...
editor.restoreSelection();
// Scribe uses a snapshot-based undo system
// Each change saves the full HTML state
editor.undo(); // Go back
editor.redo(); // Go forward
// Keyboard shortcuts (automatic)
// Ctrl/Cmd + Z = Undo
// Ctrl/Cmd + Y = Redo
// How it works:
// 1. Debounced push — groups rapid keystrokes (300ms)
// 2. Immediate push — for command executions
// 3. Stack size limited to 100 entries
// 4. Forward history truncated on new branch
// Link handling
const editor = Scribe.init('#editor');
// Add link to selected text
editor.link('https://example.com');
// Remove link from selection
editor.unlink();
// Check if selection is a link
editor.on('selectionChange', (selection) => {
if (selection?.formats.link) {
console.log('Current link:', selection.formats.link);
}
});
Direct methods for text formatting - no exec() needed
Inline Formatting
Toggle
bold()
Toggle bold on selection
italic()
Toggle italic on selection
underline()
Toggle underline on selection
strike()
Toggle strikethrough
code()
Toggle inline code
subscript()
Toggle subscript
superscript()
Toggle superscript
clearFormat()
Remove all formatting
Block Formatting
heading(level: 1 | 2 | 3 | 4 | 5 | 6)
Set heading level
paragraph()
Remove block formatting
blockquote()
Toggle blockquote
codeBlock()
Insert code block
orderedList()
Toggle numbered list
unorderedList()
Toggle bullet list
Alignment
alignLeft()
Align text left
alignCenter()
Center text
alignRight()
Align text right
alignJustify()
Justify text
indent()
Increase indentation
outdent()
Decrease indentation
Links & Insert
link(url: string)
Wrap selection in link
unlink()
Remove link from selection
insertHR()
Insert horizontal rule
insertHTML(html: string)
Insert sanitized HTML at cursor
insertText(text: string)
Insert plain text at cursor
Usage Example
const editor = Scribe.init('#editor');
// Format selected text
editor.bold(); // Toggle bold
editor.italic(); // Toggle italic
editor.heading(2); // Make it an H2
editor.link('https://example.com');
// These methods work on the current selection
// No need to save/restore selection
// Apply styling to selection
editor.setFontSize(18);
editor.setFontFamily('Georgia');
editor.setColor('#ff0000');
editor.setBackgroundColor('#ffff00');
// Note: These use inline styles
// For better maintainability, consider using classes via plugins
Real-time formatting state introspection for toolbar synchronization
Key Concept
Scribe provides three levels of state introspection: FormatState for raw formatting booleans, CommandMetadata for rich command info, andformatChange events for reactive UI updates. Together they enable toolbars that always reflect the true state of the selection.
// FormatState — raw formatting state of the current selection
interface FormatState {
bold: boolean;
italic: boolean;
underline: boolean;
strike: boolean;
code: boolean;
subscript: boolean;
superscript: boolean;
link: string | null; // URL if cursor is inside a link
heading: number | null; // 1-6 or null
list: 'ordered' | 'unordered' | null;
blockquote: boolean;
align: 'left' | 'center' | 'right' | 'justify';
fontSize: string | null;
fontFamily: string | null;
color: string | null;
backgroundColor: string | null;
}
// Get current format state at any time
const state = editor.getFormatState();
console.log(state.bold); // true if selection is bold
console.log(state.link); // 'https://...' or null
console.log(state.fontSize); // '16px' or null
// Works with partial selections and nested formatting
// e.g., selecting across <strong>bold <em>and italic</em></strong>
// → { bold: true, italic: true, ... }
Real-World Examples
// Vanilla JS toolbar with full state sync
const editor = Scribe.init('#editor');
const toolbar = document.getElementById('toolbar');
// Build toolbar buttons from command metadata
const commands = editor.getAllCommandStates();
commands
.filter(cmd => ['bold','italic','underline','strike','link'].includes(cmd.name))
.forEach(cmd => {
const btn = document.createElement('button');
btn.textContent = cmd.label;
btn.title = cmd.shortcut || cmd.label;
btn.dataset.command = cmd.name;
toolbar.appendChild(btn);
});
// Click handler — use direct methods
toolbar.addEventListener('click', (e) => {
const cmd = e.target.dataset.command;
if (cmd && editor[cmd]) {
editor[cmd]();
}
});
// Sync active state reactively
editor.on('formatChange', ({ current, changed }) => {
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;
}
});
});
Cross-Browser Handling Strategy
Scribe uses a multi-layered approach to ensure toolbars remain in sync across all major browsers (Chrome, Firefox, Safari, Edge), overcoming known Selection API inconsistencies.
Reactive Triggers
Prioritizes formatChange events which fire immediately after any formatting command (bold, link, etc.) modifies the DOM.
Selection Guard
Subscribes to selectionchange on the document, plus mouseupand keyup fallbacks for Firefox/Safari.
Polling Fallback
Uses 100ms polling while the editor is focused to catch programmatic changes that browsers fail to report via standard events.
Troubleshooting & Edge Cases
Toolbar doesn't update
Ensure you're subscribing to formatChange, not selectionChange, for active state. selectionChange fires too frequently and may miss format updates after commands.
// ❌ Don't use selectionChange for active state
editor.on('selectionChange', (sel) => {
// This misses command-triggered changes
});
// ✅ Use formatChange for toolbar state
editor.on('formatChange', ({ current }) => {
updateToolbar(current);
});
Partial selection detection
When selecting across multiple format nodes (e.g., bold + non-bold text), Scribe reports the format of the common ancestor container.
// Selection: "bold and normal"
// in: <strong>bold</strong> and normal
// → formats.bold = false (common ancestor is parent)
// Selection: "bold text"
// in: <strong>bold text</strong>
// → formats.bold = true
Iframe focus issues
Clicking toolbar buttons in the parent steals focus from the iframe. Always use preventDefault on mousedown.
Use formatChange's changed array to only update buttons whose state actually changed.
editor.on('formatChange', ({ changed, current }) => {
// Only update what changed
for (const key of changed) {
const btn = toolbar.querySelector(`[data-format="${key}"]`);
if (btn) btn.classList.toggle('active', !!current[key]);
}
});
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
// ═══════════════════════════════════════════════
// 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