Core Concepts

    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

    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'],
      }
    });

    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 cleanup

    Command Metadata Philosophy

    Commands define truth. Toolbar state is resolved from metadata, not browser output.

    Three Pillars of Real-Time Sync

    To ensure toolbars feel immediate across all browsers, Scribe uses a triple-redundancy sync:
    • Reactive Triggers: The formatChange event fires instantly when commands are executed.
    • Selection Analytics: Document-level selectionchange tracking 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          │
      │  &nbsp;&nbsp;&nbsp; → 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&nbsp;&nbsp;&nbsp;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' event

    Selection & 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: boolean
    italic: boolean
    underline: boolean
    strike: boolean
    code: boolean
    subscript: boolean
    superscript: boolean
    link: string | null
    heading: number | null
    list: 'ordered' | 'unordered' | null
    blockquote: boolean
    align: 'left' | 'center' | 'right' | 'justify'
    fontSize: string | null
    fontFamily: string | null
    color: string | null
    backgroundColor: string | null

    Selection Methods

    getSelection() → SelectionState
    saveSelection(reason?) → void
    restoreSelection() → boolean
    focus() → void
    blur() → void
    normalize() → void

    Iframe 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

    Never trust pasted content! Scribe automatically sanitizes all pasted HTML to prevent XSS and ensure consistent formatting.
    // 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 formatting

    Undo / 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