Core Concepts
Understand the architecture and design decisions that make Scribe Editor reliable and predictable. These concepts explain why certain approaches were chosen over alternatives.
ContentEditable vs Virtual Document
Understanding Scribe's approach: ContentEditable with guardrails
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'],
}
});Why the DOM Cannot Be Trusted
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 cleanupCommand Metadata Philosophy
Commands define truth. Toolbar state is resolved from metadata, not browser output.
Three Pillars of Real-Time Sync
- Reactive Triggers: The
formatChangeevent fires instantly when commands are executed. - Selection Analytics: Document-level
selectionchangetracking with mouse/keyboard fallbacks. - Focus-Based Polling: A 100ms background guard while focused, catching browser edge cases.
Toolbar State Resolution Pipeline
┌──────────────────┐
│ Browser Selection │ User selects text or moves cursor
└────────┬─────────┘
▼
┌──────────────────────────┐
│ Selection Normalization │ SelectionManager walks DOM tree
│ Layer │ + reads computed styles
└────────┬─────────────────┘
▼
┌──────────────────────────┐
│ Editor Selection Model │ SelectionState { formats, rect, text }
└────────┬─────────────────┘
▼
┌──────────────────────────┐
│ Editor State │ FormatState { bold, italic, link, ... }
│ (FormatState) │ Diffed against previous state
└────────┬─────────────────┘
▼
┌──────────────────────────┐
│ Command Metadata │ CommandRegistry resolves:
│ Resolver │ active, supported, attributes,
│ │ exclusiveGroup, category
└────────┬─────────────────┘
▼
┌──────────────────────────┐
│ Toolbar State Output │ CommandMetadata[]
│ │ → Toolbar renders from this
└──────────────────────────┘// Commands define truth. Toolbar state is resolved from command metadata.
// Each command self-describes:
interface CommandRegistration {
name: string; // 'bold'
label: string; // 'Bold'
category: CommandCategory; // 'inline' | 'block' | 'list' | etc.
type: 'toggle' | 'apply' | 'action';
// State resolver — reads FormatState, NOT the DOM
isActive?: (state: FormatState) => boolean;
// Attributes resolver (e.g., link URL, font size)
getAttributes?: (state: FormatState) => Record<string, unknown> | undefined;
// Behavioral metadata
requiresSelection?: boolean;
exclusiveGroup?: string; // 'alignment', 'listType', 'blockType'
shortcut?: string;
// Toolbar visibility
floatingToolbar?: boolean;
fixedToolbar?: boolean;
}
// Toolbar builds itself from metadata:
const commands = editor.getAllCommandStates();
commands.forEach(cmd => {
const button = createButton({
label: cmd.label,
active: cmd.active, // From FormatState, not DOM
disabled: !cmd.supported, // Selection-aware
shortcut: cmd.shortcut,
group: cmd.category,
});
toolbar.appendChild(button);
});
// Mutually exclusive groups auto-resolve:
// If 'alignCenter' is active, 'alignLeft' and 'alignRight' are NOT.
// The metadata system handles this via exclusiveGroup.Selection Snapshot & Lifecycle
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.DOM Normalization Strategy
Post-command cleanup pipeline for consistent, clean HTML output
DOM Normalization Flow
Command Execution (e.g., editor.bold())
│
▼
┌─────────────────────────────────────┐
│ Phase 1: Merge Adjacent Inlines │
│ <b>hi</b><b> there</b> → <b>hi there</b>
└──────────┬──────────────────────────┘
▼
┌─────────────────────────────────────┐
│ Phase 2: Remove Empty Nodes │
│ <strong></strong> → (removed) │
└──────────┬──────────────────────────┘
▼
┌─────────────────────────────────────┐
│ Phase 3: Flatten Nested Inlines │
│ <b><b>text</b></b> → <b>text</b> │
└──────────┬──────────────────────────┘
▼
┌─────────────────────────────────────┐
│ Phase 4: Clean Whitespace │
│ → regular spaces │
│ (preserves <pre>/<code> content) │
└──────────┬──────────────────────────┘
▼
┌─────────────────────────────────────┐
│ Phase 5: Remove Browser Junk │
│ Zero-width spaces, empty <span>s │
└──────────┬──────────────────────────┘
▼
History Push + 'change' Event// DOM Normalization runs AFTER every command execution.
// It cleans up browser-generated artifacts to ensure consistent output.
// Phase 1: MERGE ADJACENT INLINE NODES
// Before: <strong>hello</strong><strong> world</strong>
// After: <strong>hello world</strong>
// Phase 2: REMOVE EMPTY NODES
// Before: <strong></strong><em>text</em>
// After: <em>text</em>
// Phase 3: FLATTEN NESTED INLINES
// Before: <strong><strong>text</strong></strong>
// After: <strong>text</strong>
// Phase 4: CLEAN WHITESPACE
// Before: "hello world"
// After: "hello world" (regular spaces)
// Note: Preserves content inside <pre> and <code>
// Phase 5: REMOVE BROWSER JUNK
// Before: <span>\u200B</span><span></span>text
// After: text
// The normalizer is also available as a public API:
editor.normalize(); // Run normalization manually
// Pipeline:
// Command Execution
// ↓
// DOMNormalizer.normalize()
// ↓ Phase 1: mergeAdjacentInlines()
// ↓ Phase 2: removeEmptyNodes()
// ↓ Phase 3: flattenNestedInlines()
// ↓ Phase 4: cleanWhitespace()
// ↓ Phase 5: removeBrowserJunk()
// ↓
// History Push
// ↓
// Emit 'change' eventSelection & Range API
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();FormatState Object
bold: booleanitalic: booleanunderline: booleanstrike: booleancode: booleansubscript: booleansuperscript: booleanlink: string | nullheading: number | nulllist: 'ordered' | 'unordered' | nullblockquote: booleanalign: 'left' | 'center' | 'right' | 'justify'fontSize: string | nullfontFamily: string | nullcolor: string | nullbackgroundColor: string | nullSelection Methods
getSelection() → SelectionStatesaveSelection(reason?) → voidrestoreSelection() → booleanfocus() → voidblur() → voidnormalize() → voidIframe Editing Architecture
Full document editing in isolated contexts
Iframe Editor Architecture
┌─────────────────────────────────────────────────┐ │ Parent Window │ │ ┌──────────────────────────────────────────┐ │ │ │ Toolbar (React/Vue/Vanilla) │ │ │ │ [B] [I] [U] [Link] [H1] [H2] [•] [1.] │ │ │ │ State from: editor.getCommandState() │ │ │ └──────────────────────────────────────────┘ │ │ │ │ │ formatChange events (cross-frame) │ │ ▼ │ │ ┌──────────────────────────────────────────┐ │ │ │ <iframe src="about:blank"> │ │ │ │ ┌────────────────────────────────────┐ │ │ │ │ │ <body contenteditable="true"> │ │ │ │ │ │ <h1>Document Title</h1> │ │ │ │ │ │ <p>Editable content...</p> │ │ │ │ │ │ </body> │ │ │ │ │ │ ↑ DOMNormalizer runs here │ │ │ │ │ └────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────┘ │ └─────────────────────────────────────────────────┘
// Why edit inside an iframe?
// 1. Style isolation - editor styles don't leak
// 2. Security sandbox - controlled environment
// 3. Full document mode - edit entire HTML documents
// 4. Email templates - WYSIWYG email editing
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
iframe.onload = () => {
const doc = iframe.contentDocument;
const body = doc.body;
body.contentEditable = 'true';
// Initialize Scribe on iframe body
const editor = Scribe.init(body, {
iframe: iframe, // IMPORTANT: Pass iframe reference
onChange: (html) => {
console.log(doc.documentElement.outerHTML);
}
});
// Selection lifecycle works across iframe boundary
// formatChange events fire in the parent context
// DOM normalization runs inside the iframe document
const style = doc.createElement('style');
style.textContent = `
body { font-family: system-ui; padding: 20px; }
p { margin: 1em 0; }
`;
doc.head.appendChild(style);
};
iframe.src = 'about:blank';Clipboard Handling
Paste sanitization and copy/cut events
// Clipboard events: paste, copy, cut
const editor = Scribe.init('#editor');
// Paste handling is automatic, but can be customized:
element.addEventListener('paste', (e) => {
e.preventDefault();
const html = e.clipboardData?.getData('text/html');
const text = e.clipboardData?.getData('text/plain');
const files = e.clipboardData?.files;
if (files?.length > 0) {
for (const file of files) {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = () => {
editor.insertHTML(`<img src="${reader.result}" />`);
};
reader.readAsDataURL(file);
}
}
return;
}
if (html) {
editor.insertHTML(html); // Sanitized automatically
} else if (text) {
editor.insertText(text);
}
});
// Word/Google Docs paste cleanup is automatic:
// - Removes mso-* styles (Word)
// - Strips Google Docs class names
// - Normalizes lists and tables
// - Preserves semantic formattingUndo / Redo Strategies
History management patterns for rich text editors
// 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