Documentation

    Scribe Editor

    A modern, inline-first rich text editor with a simple API. Use direct methods like editor.bold() — no complex commands needed.

    Simple API

    Direct methods like .bold(), .italic(), .link()

    Lightweight

    < 50KB gzipped, zero dependencies

    Universal

    Works with React, Vue, Svelte, or Vanilla JS

    Secure

    Built-in XSS protection & sanitization

    Iframe Ready

    Edit content inside iframes

    Extensible

    Plugin architecture for custom features

    Quick Start

    Get started with Scribe in seconds. Choose your framework:

    <!-- One-line initialization -->
    <div id="editor">
      <p>Start editing...</p>
    </div>
    
    <script type="module">
      import { Scribe } from './scribe.js';
      
      const editor = Scribe.init('#editor');
      
      // Direct methods - no exec() needed
      editor.bold();
      editor.link('https://example.com');
    </script>

    Installation

    NPM / Yarn / PNPM

    npm install scribejs-editor
    # or
    yarn add scribejs-editor
    # or
    pnpm add scribejs-editor

    CDN (Script Tag)

    <script src="https://unpkg.com/scribejs-editor"></script>
    
    <script>
      const editor = Scribe.init('#editor');
    </script>

    Live Demo

    Select text to see the floating toolbar. Try formatting, links, and more.

    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

    Framework Guides

    Vanilla JavaScript

    The reference implementation - works everywhere

    Recommended Starting Point
    <!DOCTYPE html>
    <html>
    <head>
      <title>Scribe Editor</title>
      <link rel="stylesheet" href="scribe.css">
    </head>
    <body>
      <div id="editor">
        <p>Start editing...</p>
      </div>
      
      <script type="module">
        import { Scribe } from './scribe.js';
        
        // One-line initialization
        const editor = Scribe.init('#editor', {
          placeholder: 'Write something amazing...',
          autofocus: true
        });
        
        // Direct formatting methods
        document.querySelector('#bold-btn').onclick = () => editor.bold();
        document.querySelector('#italic-btn').onclick = () => editor.italic();
        
        // Save content
        document.querySelector('#save-btn').onclick = () => {
          const html = editor.getHTML();
          console.log('Content:', html);
        };
      </script>
    </body>
    </html>

    React

    Component wrapper with hooks and controlled state

    import { ScribeEditor, ScribeEditorRef } from '@/components/scribe';
    import { useRef, useState } from 'react';
    
    function BasicEditor() {
      const [content, setContent] = useState('<p>Hello world!</p>');
      
      return (
        <ScribeEditor
          defaultValue={content}
          placeholder="Start writing..."
          toolbar="floating"
          onChange={(html) => setContent(html)}
        />
      );
    }

    Vue 3

    Composition API with composables

    <script setup lang="ts">
    import { ref, onMounted, onBeforeUnmount } from 'vue'
    import { Scribe, type EditorInstance } from '@/lib/scribe'
    
    const editorEl = ref<HTMLElement | null>(null)
    const editor = ref<EditorInstance | null>(null)
    const content = ref('<p>Hello from Vue!</p>')
    
    onMounted(() => {
      if (editorEl.value) {
        editor.value = Scribe.init(editorEl.value, {
          onChange: (html) => {
            content.value = html
          }
        })
      }
    })
    
    onBeforeUnmount(() => {
      editor.value?.destroy()
    })
    
    // Expose methods
    const bold = () => editor.value?.bold()
    const italic = () => editor.value?.italic()
    const getHTML = () => editor.value?.getHTML() || ''
    </script>
    
    <template>
      <div class="editor-wrapper">
        <div class="toolbar">
          <button @click="bold">Bold</button>
          <button @click="italic">Italic</button>
        </div>
        <div 
          ref="editorEl" 
          class="scribe-content"
          v-html="content"
        />
      </div>
    </template>

    Svelte

    Reactive bindings with minimal boilerplate

    <!-- ScribeEditor.svelte -->
    <script lang="ts">
      import { onMount, onDestroy, createEventDispatcher } from 'svelte';
      import { Scribe, type EditorInstance, type FormatState } from '@/lib/scribe';
      
      export let value = '';
      export let placeholder = 'Start writing...';
      export let readonly = false;
      
      let editorEl: HTMLElement;
      let editor: EditorInstance | null = null;
      
      const dispatch = createEventDispatcher<{
        change: string;
        selectionChange: FormatState | null;
      }>();
      
      onMount(() => {
        editorEl.innerHTML = value;
        
        editor = Scribe.init(editorEl, {
          placeholder,
          readOnly: readonly,
          onChange: (html) => {
            value = html;
            dispatch('change', html);
          },
          onFormatChange: (event) => {
            dispatch('selectionChange', event.current);
          }
        });
      });
      
      onDestroy(() => {
        editor?.destroy();
      });
      
      // Exported methods
      export function bold() { editor?.bold(); }
      export function italic() { editor?.italic(); }
      export function heading(level: 1 | 2 | 3 | 4 | 5 | 6) { editor?.heading(level); }
      export function getHTML() { return editor?.getHTML() || ''; }
    </script>
    
    <div 
      bind:this={editorEl}
      class="scribe-content prose"
    />
    
    <!-- Usage -->
    <!--
    <script>
      import ScribeEditor from './ScribeEditor.svelte';
      
      let editorRef;
      let formats;
    </script>
    
    <button on:click={() => editorRef.bold()}>Bold</button>
    
    <ScribeEditor 
      bind:this={editorRef}
      on:selectionChange={(e) => formats = e.detail}
    />
    -->

    Web Components

    Framework-agnostic custom element

    Web Components work in any framework or vanilla HTML. Perfect for design systems and micro-frontends.
    // scribe-editor.js - Custom Element
    class ScribeEditor extends HTMLElement {
      static get observedAttributes() {
        return ['placeholder', 'readonly'];
      }
      
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.editor = null;
      }
      
      connectedCallback() {
        // Create shadow DOM structure
        this.shadowRoot.innerHTML = `
          <style>
            :host {
              display: block;
            }
            .content {
              min-height: 200px;
              padding: 1rem;
              outline: none;
            }
            .content:empty::before {
              content: attr(data-placeholder);
              color: #999;
            }
          </style>
          <div class="content" data-placeholder="${this.placeholder}"></div>
        `;
        
        const content = this.shadowRoot.querySelector('.content');
        
        // Initialize Scribe
        import('./scribe.js').then(({ Scribe }) => {
          this.editor = Scribe.init(content, {
            placeholder: this.placeholder,
            readOnly: this.readonly,
            onChange: (html) => {
              this.dispatchEvent(new CustomEvent('change', { 
                detail: { html },
                bubbles: true 
              }));
            }
          });
        });
      }
      
      disconnectedCallback() {
        this.editor?.destroy();
      }
      
      attributeChangedCallback(name, oldVal, newVal) {
        if (name === 'readonly') {
          this.editor?.setReadOnly(newVal !== null);
        }
      }
      
      get placeholder() {
        return this.getAttribute('placeholder') || 'Start writing...';
      }
      
      get readonly() {
        return this.hasAttribute('readonly');
      }
      
      // Public API
      bold() { this.editor?.bold(); }
      italic() { this.editor?.italic(); }
      getHTML() { return this.editor?.getHTML() || ''; }
      setHTML(html) { this.editor?.setHTML(html); }
    }
    
    customElements.define('scribe-editor', ScribeEditor);
    
    // Usage:
    // <scribe-editor placeholder="Write here..."></scribe-editor>
    // 
    // const editor = document.querySelector('scribe-editor');
    // editor.bold();
    // editor.addEventListener('change', (e) => console.log(e.detail.html));

    SSR Considerations

    Server-side rendering compatibility

    Hydration Strategy

    Rich text editors require the DOM and cannot run on the server. Use these patterns:

    React/Next.js

    // Dynamic import with ssr: false
    const ScribeEditor = dynamic(
      () => import('@/components/scribe'),
      { ssr: false }
    );

    Vue/Nuxt

    <ClientOnly>
      <ScribeEditor v-model="content" />
    </ClientOnly>

    Features

    Feature Matrix

    Complete list of supported formatting features

    Inline

    Bold
    .bold()
    Italic
    .italic()
    Underline
    .underline()
    Strikethrough
    .strike()
    Inline Code
    .code()
    Subscript
    .subscript()
    Superscript
    .superscript()

    Block

    Headings (H1-H6)
    .heading(1-6)
    Paragraph
    .paragraph()
    Blockquote
    .blockquote()
    Code Block
    .codeBlock()
    Horizontal Rule
    .insertHR()

    Lists

    Ordered List
    .orderedList()
    Unordered List
    .unorderedList()
    Nested Lists
    Tab/Shift+Tab

    Alignment

    Left
    .alignLeft()
    Center
    .alignCenter()
    Right
    .alignRight()
    Justify
    .alignJustify()
    Indent
    .indent()
    Outdent
    .outdent()

    Links & Media

    Links
    .link(url)
    Unlink
    .unlink()
    Images
    .insertHTML()
    Embeds
    Plugin

    Images & Embeds

    Inserting and handling media content

    Images are inserted via insertHTML(). For file uploads, handle the upload separately and insert the resulting URL.
    // Image insertion
    const editor = Scribe.init('#editor');
    
    // Insert image HTML
    editor.insertHTML('<img src="image.jpg" alt="Description" />');
    
    // With full attributes
    editor.insertHTML(`
      <figure>
        <img src="photo.jpg" alt="A beautiful sunset" width="800" height="600" />
        <figcaption>Photo by John Doe</figcaption>
      </figure>
    `);
    
    // Handle image upload
    async function handleImageUpload(file: File) {
      const formData = new FormData();
      formData.append('image', file);
      
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData
      });
      
      const { url } = await response.json();
      editor.insertHTML(`<img src="${url}" alt="" />`);
    }
    
    // Drag and drop
    const content = editor.getContentElement();
    content.addEventListener('drop', async (e) => {
      e.preventDefault();
      const files = Array.from(e.dataTransfer?.files || []);
      
      for (const file of files) {
        if (file.type.startsWith('image/')) {
          await handleImageUpload(file);
        }
      }
    });

    Variables / Tokens

    Insertable placeholders for mail merge and templates

    // Variable/Token insertion (mail merge, templates)
    const editor = Scribe.init('#editor');
    
    // Insert a non-editable variable token
    function insertVariable(name: string, value: string) {
      const token = `<span 
        class="variable-token" 
        contenteditable="false" 
        data-variable="${name}"
      >${value}</span>`;
      
      editor.insertHTML(token);
      editor.focus();
    }
    
    // Usage
    insertVariable('first_name', '{{first_name}}');
    insertVariable('company', '{{company}}');
    
    // Style in CSS
    // .variable-token {
    //   background: #e3f2fd;
    //   border-radius: 3px;
    //   padding: 0 4px;
    //   color: #1565c0;
    //   cursor: default;
    // }
    
    // Extract variables from content
    function extractVariables(html: string): string[] {
      const matches = html.match(/data-variable="([^"]+)"/g) || [];
      return matches.map(m => m.replace(/data-variable="|"/g, ''));
    }

    Inline Widgets

    Custom non-editable elements (mentions, hashtags, etc.)

    Widget Structure

    <span class="widget widget-mention" 
          contenteditable="false" 
          data-type="mention" 
          data-payload='{"id":"123","name":"John"}'>
      <span class="mention">@John</span>
    </span>
    // Inline widgets (mentions, hashtags, custom blocks)
    class InlineWidget {
      constructor(private editor: EditorInstance) {}
      
      insert(type: string, data: Record<string, unknown>) {
        const widget = document.createElement('span');
        widget.className = `widget widget-${type}`;
        widget.contentEditable = 'false';
        widget.dataset.type = type;
        widget.dataset.payload = JSON.stringify(data);
        
        // Render based on type
        switch (type) {
          case 'mention':
            widget.innerHTML = `<span class="mention">@${data.name}</span>`;
            break;
          case 'hashtag':
            widget.innerHTML = `<span class="hashtag">#${data.tag}</span>`;
            break;
          case 'emoji':
            widget.innerHTML = data.emoji as string;
            break;
        }
        
        this.editor.insertHTML(widget.outerHTML);
      }
      
      // Parse widgets from HTML
      static parse(html: string): Array<{ type: string; data: unknown }> {
        const doc = new DOMParser().parseFromString(html, 'text/html');
        const widgets = doc.querySelectorAll('.widget');
        
        return Array.from(widgets).map(w => ({
          type: w.dataset.type!,
          data: JSON.parse(w.dataset.payload || '{}')
        }));
      }
    }
    
    // Usage
    const widgets = new InlineWidget(editor);
    widgets.insert('mention', { id: '123', name: 'John' });
    widgets.insert('hashtag', { tag: 'important' });

    Toolbar Positioning

    Floating toolbar placement logic

    Positioning Algorithm

    ┌─────────────────────────────────────────────────┐
    │  Viewport                                       │
    │                                                 │
    │  ┌─ Toolbar ─┐  ← Centered on selection         │
    │  │  B I U L  │                                  │
    │  └───────────┘                                  │
    │       │                                         │
    │       ▼  8px gap                                │
    │  ┌─────────────────────────────────────────┐   │
    │  │  Selected text in the editor content    │   │
    │  └─────────────────────────────────────────┘   │
    │                                                 │
    │  If selection near top:                         │
    │                                                 │
    │  ┌─────────────────────────────────────────┐   │
    │  │  Selected text near top of viewport     │   │
    │  └─────────────────────────────────────────┘   │
    │       ▼  8px gap                                │
    │  ┌─ Toolbar ─┐  ← Flips below selection         │
    │  │  B I U L  │                                  │
    │  └───────────┘                                  │
    └─────────────────────────────────────────────────┘
    // Toolbar positioning logic
    function positionFloatingToolbar(
      toolbar: HTMLElement,
      selectionRect: DOMRect
    ) {
      const toolbarHeight = toolbar.offsetHeight;
      const toolbarWidth = toolbar.offsetWidth;
      const padding = 8;
      
      // Calculate position above selection
      let x = selectionRect.left + selectionRect.width / 2;
      let y = selectionRect.top - toolbarHeight - padding;
      
      // Flip below if too close to top
      if (y < padding) {
        y = selectionRect.bottom + padding;
      }
      
      // Keep within viewport horizontally
      const maxX = window.innerWidth - toolbarWidth / 2 - padding;
      const minX = toolbarWidth / 2 + padding;
      x = Math.max(minX, Math.min(maxX, x));
      
      // Apply position
      toolbar.style.left = `${x}px`;
      toolbar.style.top = `${y}px`;
      toolbar.style.transform = 'translateX(-50%)';
    }
    
    // Usage with selection events
    editor.on('selectionChange', (selection) => {
      if (selection && !selection.collapsed && selection.rect) {
        positionFloatingToolbar(toolbar, selection.rect);
        toolbar.classList.add('visible');
      } else {
        toolbar.classList.remove('visible');
      }
    });

    Mobile Support

    Touch devices and virtual keyboard handling

    Touch Targets

    Minimum 44×44px buttons for reliable touch interaction

    Virtual Keyboard

    Auto-scroll selection into view when keyboard opens

    iOS Quirks

    Delayed selection detection for reliable toolbar display
    // Mobile support considerations
    const editor = Scribe.init('#editor', {
      // Consider touch events
    });
    
    // 1. Touch-friendly toolbar sizing
    // CSS:
    // .toolbar button { min-width: 44px; min-height: 44px; }
    
    // 2. Virtual keyboard handling
    const content = editor.getContentElement();
    
    // Scroll selection into view when keyboard appears
    content.addEventListener('focus', () => {
      setTimeout(() => {
        const selection = editor.getSelection();
        if (selection?.rect) {
          // Scroll into viewport center
          window.scrollTo({
            top: selection.rect.top - window.innerHeight / 3,
            behavior: 'smooth'
          });
        }
      }, 300); // Wait for keyboard
    });
    
    // 3. Handle iOS quirks
    // iOS doesn't fire selectionchange reliably
    let lastTouch: Touch | null = null;
    
    content.addEventListener('touchend', (e) => {
      lastTouch = e.changedTouches[0];
      
      // Delayed selection check
      setTimeout(() => {
        const selection = editor.getSelection();
        if (selection && !selection.collapsed) {
          showToolbar(selection);
        }
      }, 10);
    });
    
    // 4. Responsive toolbar
    function getToolbarMode(): 'full' | 'compact' {
      return window.innerWidth < 768 ? 'compact' : 'full';
    }
    
    // Compact mode shows fewer buttons with overflow menu

    Paste Sanitization

    Automatic cleanup of pasted content

    Preserved

    Bold, italic, links, lists, headings, paragraphs, semantic markup

    Removed

    Scripts, iframes, event handlers, Word styles, external fonts, tracking pixels
    // Paste sanitization
    const editor = Scribe.init('#editor', {
      sanitize: {
        allowedTags: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li'],
        allowedSchemes: ['https']
      }
    });
    
    // Scribe automatically sanitizes pasted content
    // Additional custom handling:
    
    const content = editor.getContentElement();
    
    content.addEventListener('paste', (e) => {
      // Access raw clipboard data
      const html = e.clipboardData?.getData('text/html');
      const text = e.clipboardData?.getData('text/plain');
      
      // Log what was pasted (for debugging)
      console.log('Pasted HTML:', html);
      console.log('Pasted text:', text);
      
      // Note: Scribe handles sanitization automatically
      // This event fires BEFORE Scribe processes the paste
    });
    
    // Word/Google Docs cleanup is automatic:
    // - Removes mso-* styles (Microsoft Word)
    // - Strips Google Docs wrapper classes
    // - Normalizes nested lists
    // - Preserves semantic formatting (bold, italic, links)
    // - Removes font-family, font-size unless allowed

    Keyboard Shortcuts

    Built-in shortcuts for power users

    Formatting

    BoldCtrl/Cmd + B
    ItalicCtrl/Cmd + I
    UnderlineCtrl/Cmd + U
    LinkCtrl/Cmd + K
    Clear FormatCtrl/Cmd + \

    History & Navigation

    UndoCtrl/Cmd + Z
    RedoCtrl/Cmd + Y
    Redo (Alt)Ctrl/Cmd + Shift + Z
    IndentTab
    OutdentShift + Tab

    Advanced Topics

    Performance Optimization

    Handling large documents and memory efficiency

    For documents over 10,000 words, consider virtual scrolling or chunked editing.
    // Optimizing for large documents (10k+ words)
    
    // 1. Virtual scrolling for very long documents
    class VirtualDocumentEditor {
      constructor(container, content) {
        this.container = container;
        this.chunks = this.splitIntoChunks(content);
        this.visibleRange = { start: 0, end: 10 };
        this.editors = new Map();
        
        this.setupIntersectionObserver();
      }
      
      splitIntoChunks(html) {
        // Split by paragraphs, keeping 50-100 paragraphs per chunk
        const doc = new DOMParser().parseFromString(html, 'text/html');
        const paragraphs = Array.from(doc.body.children);
        const chunks = [];
        
        for (let i = 0; i < paragraphs.length; i += 50) {
          chunks.push(paragraphs.slice(i, i + 50).map(p => p.outerHTML).join(''));
        }
        
        return chunks;
      }
      
      setupIntersectionObserver() {
        this.observer = new IntersectionObserver((entries) => {
          entries.forEach(entry => {
            const chunkIndex = parseInt(entry.target.dataset.chunk);
            
            if (entry.isIntersecting) {
              this.activateChunk(chunkIndex);
            } else {
              this.deactivateChunk(chunkIndex);
            }
          });
        }, { rootMargin: '200px' });
      }
      
      activateChunk(index) {
        if (this.editors.has(index)) return;
        
        const chunkEl = this.container.querySelector(`[data-chunk="${index}"]`);
        const editor = Scribe.init(chunkEl, {
          // Minimal features for inactive chunks
          onChange: (html) => {
            this.chunks[index] = html;
          }
        });
        
        this.editors.set(index, editor);
      }
      
      deactivateChunk(index) {
        const editor = this.editors.get(index);
        if (editor) {
          // Save content before destroying
          this.chunks[index] = editor.getHTML();
          editor.destroy();
          this.editors.delete(index);
        }
      }
    }
    
    // 2. Debounced change handlers
    const editor = Scribe.init('#editor', {
      // Don't use onChange for expensive operations
    });
    
    // Debounce expensive operations
    let saveTimer;
    editor.on('change', (html) => {
      clearTimeout(saveTimer);
      saveTimer = setTimeout(() => {
        saveToServer(html);
        updateWordCount(html);
      }, 500);
    });
    
    // 3. Use requestAnimationFrame for DOM measurements
    editor.on('selectionChange', (selection) => {
      requestAnimationFrame(() => {
        positionToolbar(selection?.rect);
      });
    });

    Plugin Architecture

    Extend Scribe with custom functionality

    Plugin Lifecycle

    ┌─────────────────────────────────────────────────┐
    │  Plugin Registration                            │
    │                                                 │
    │  createEditor({ plugins: [myPlugin] })          │
    │                    │                            │
    │                    ▼                            │
    │  ┌──────────────────────────────────────────┐  │
    │  │  For each plugin:                         │  │
    │  │  1. Register commands → commands.set()    │  │
    │  │  2. Register shortcuts → shortcuts.push() │  │
    │  │  3. Call init(editor)                     │  │
    │  └──────────────────────────────────────────┘  │
    │                    │                            │
    │                    ▼                            │
    │  ┌──────────────────────────────────────────┐  │
    │  │  Editor ready - plugins active            │  │
    │  │  Commands available via editor.exec()     │  │
    │  └──────────────────────────────────────────┘  │
    │                    │                            │
    │                    ▼                            │
    │  ┌──────────────────────────────────────────┐  │
    │  │  editor.destroy()                         │  │
    │  │  → Calls plugin.destroy() for cleanup     │  │
    │  └──────────────────────────────────────────┘  │
    └─────────────────────────────────────────────────┘
    // Scribe Plugin Interface
    interface Plugin {
      name: string;
      
      // Lifecycle hooks
      init?(editor: EditorInstance): void;
      destroy?(): void;
      
      // Extend functionality
      commands?: Record<string, CommandHandler>;
      shortcuts?: ShortcutConfig[];
      toolbarItems?: ToolbarItem[];
    }
    
    // Example: Custom highlight plugin
    const highlightPlugin: Plugin = {
      name: 'highlight',
      
      commands: {
        highlight: (editor, color: string = '#ffff00') => {
          const selection = window.getSelection();
          if (!selection || selection.isCollapsed) return;
          
          const range = selection.getRangeAt(0);
          const mark = document.createElement('mark');
          mark.style.backgroundColor = color;
          
          range.surroundContents(mark);
        },
        
        removeHighlight: (editor) => {
          const selection = window.getSelection();
          if (!selection) return;
          
          const mark = selection.anchorNode?.parentElement;
          if (mark?.tagName === 'MARK') {
            const parent = mark.parentNode;
            while (mark.firstChild) {
              parent?.insertBefore(mark.firstChild, mark);
            }
            parent?.removeChild(mark);
          }
        }
      },
      
      shortcuts: [
        { key: 'h', ctrl: true, shift: true, command: 'highlight' }
      ],
      
      toolbarItems: [
        {
          name: 'highlight',
          icon: 'highlighter',
          title: 'Highlight (Ctrl+Shift+H)',
          command: 'highlight',
          isActive: (state) => false // Would need custom state detection
        }
      ],
      
      init(editor) {
        console.log('Highlight plugin initialized');
        
        // Add custom styles
        const style = document.createElement('style');
        style.textContent = `
          mark { padding: 0.1em 0.2em; border-radius: 0.2em; }
        `;
        editor.getDocument().head.appendChild(style);
      },
      
      destroy() {
        console.log('Highlight plugin destroyed');
      }
    };
    
    // Register plugin
    const editor = createEditor({
      target: '#editor',
      plugins: [highlightPlugin]
    });
    
    // Use plugin command
    editor.exec('highlight', '#ff0');

    Real-time Collaboration

    Multi-user editing with conflict resolution

    Collaboration requires a WebSocket server. For production, use Yjs with a provider like Liveblocks, Hocuspocus, or y-websocket.
    // Collaboration architecture overview
    // 
    // Two main approaches:
    // 1. Operational Transformation (OT) - Google Docs style
    // 2. CRDT - Yjs, Automerge style
    //
    // Scribe is designed to be "collaboration-ready" - you can integrate
    // either approach without modifying the core editor.
    
    // Basic WebSocket sync (last-write-wins)
    class BasicCollaboration {
      constructor(editor, roomId) {
        this.editor = editor;
        this.ws = new WebSocket(`wss://collab.example.com/${roomId}`);
        this.isRemoteChange = false;
        
        this.ws.onmessage = this.handleRemoteChange.bind(this);
        this.editor.on('change', this.handleLocalChange.bind(this));
      }
      
      handleLocalChange(html) {
        if (this.isRemoteChange) return;
        
        this.ws.send(JSON.stringify({
          type: 'update',
          html,
          timestamp: Date.now()
        }));
      }
      
      handleRemoteChange(event) {
        const { type, html } = JSON.parse(event.data);
        if (type !== 'update') return;
        
        // Prevent echo
        this.isRemoteChange = true;
        this.editor.setHTML(html);
        this.isRemoteChange = false;
      }
    }

    Security

    XSS prevention and content sandboxing

    Never Allow

    <script>, <iframe>,onclick, javascript:

    Careful With

    <style>, style attr,data: URLs

    Always Sanitize

    Pasted content, user uploads, external HTML
    // XSS Prevention in Rich Text Editors
    //
    // The #1 security concern: user-generated HTML can contain malicious scripts
    
    // Scribe's built-in sanitizer
    const editor = Scribe.init('#editor', {
      sanitize: {
        // Whitelist approach - only allow specific tags
        allowedTags: [
          'p', 'br', 'span', 'div',
          'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins',
          'a', 'img',
          'ul', 'ol', 'li',
          'blockquote', 'pre', 'code',
          'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
          'table', 'tr', 'td', 'th', 'thead', 'tbody',
          'hr'
        ],
        
        // Whitelist attributes per tag
        allowedAttributes: {
          'a': ['href', 'target', 'rel'],
          'img': ['src', 'alt', 'width', 'height'],
          'td': ['colspan', 'rowspan'],
          '*': ['class', 'id'] // All tags can have class/id
        },
        
        // URL schemes - blocks javascript:, data:, etc.
        allowedSchemes: ['http', 'https', 'mailto', 'tel'],
        
        // Remove empty elements
        stripEmpty: true
      }
    });
    
    // What the sanitizer removes:
    // - <script> tags
    // - <iframe> tags
    // - onclick, onerror, etc. event handlers
    // - javascript: URLs
    // - data: URLs (except for allowed images)
    // - <style> tags and style attributes (optional)
    // - Unknown tags

    Accessibility

    ARIA, keyboard navigation, and screen reader support

    Required ARIA

    role="textbox"
    aria-multiline="true"
    aria-label="..."
    aria-describedby="..."

    Toolbar ARIA

    role="toolbar"
    aria-label="Formatting"
    aria-pressed on toggle buttons
    Arrow key navigation
    // Accessibility best practices for RTEs
    
    // 1. ARIA attributes
    const editorEl = document.getElementById('editor');
    editorEl.setAttribute('role', 'textbox');
    editorEl.setAttribute('aria-multiline', 'true');
    editorEl.setAttribute('aria-label', 'Rich text editor');
    editorEl.setAttribute('aria-describedby', 'editor-help');
    
    // Help text for screen readers
    const helpText = document.createElement('div');
    helpText.id = 'editor-help';
    helpText.className = 'sr-only'; // Visually hidden
    helpText.textContent = 'Use Ctrl+B for bold, Ctrl+I for italic, Ctrl+K for link';
    
    // 2. Toolbar accessibility
    function createAccessibleToolbar(editor) {
      const toolbar = document.createElement('div');
      toolbar.setAttribute('role', 'toolbar');
      toolbar.setAttribute('aria-label', 'Formatting options');
      toolbar.setAttribute('aria-controls', 'editor');
      
      // Button with proper ARIA
      const boldBtn = document.createElement('button');
      boldBtn.setAttribute('aria-label', 'Bold');
      boldBtn.setAttribute('aria-pressed', 'false');
      boldBtn.innerHTML = '<strong>B</strong>';
      
      boldBtn.onclick = () => {
        editor.bold();
        boldBtn.setAttribute('aria-pressed', 
          boldBtn.getAttribute('aria-pressed') === 'true' ? 'false' : 'true'
        );
      };
      
      toolbar.appendChild(boldBtn);
      
      // Keyboard navigation within toolbar
      toolbar.addEventListener('keydown', (e) => {
        const buttons = toolbar.querySelectorAll('button');
        const current = document.activeElement;
        const index = Array.from(buttons).indexOf(current);
        
        if (e.key === 'ArrowRight') {
          e.preventDefault();
          buttons[(index + 1) % buttons.length].focus();
        } else if (e.key === 'ArrowLeft') {
          e.preventDefault();
          buttons[(index - 1 + buttons.length) % buttons.length].focus();
        }
      });
      
      return toolbar;
    }
    
    // 3. Announce formatting changes
    function announceFormat(format, applied) {
      const announcement = document.createElement('div');
      announcement.setAttribute('role', 'status');
      announcement.setAttribute('aria-live', 'polite');
      announcement.className = 'sr-only';
      announcement.textContent = `${format} ${applied ? 'applied' : 'removed'}`;
      
      document.body.appendChild(announcement);
      setTimeout(() => announcement.remove(), 1000);
    }
    
    // 4. Focus management
    editor.on('focus', () => {
      // Ensure toolbar buttons show focus state
      toolbar.classList.add('editor-focused');
    });
    
    editor.on('blur', () => {
      toolbar.classList.remove('editor-focused');
    });

    API Reference

    Initialization

    Creating an editor instance

    // Simple one-line initialization
    import { Scribe } from './scribe.js';
    
    // Pass a CSS selector
    const editor = Scribe.init('#editor');
    
    // Or pass an element
    const element = document.getElementById('editor');
    const editor = Scribe.init(element);
    
    // With options
    const editor = Scribe.init('#editor', {
      placeholder: 'Start writing...',
      autofocus: true,
      onChange: (html) => console.log(html)
    });

    Formatting Methods

    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

    Content Methods

    Get and set editor content

    getHTML(): string

    Returns the current HTML content

    setHTML(html: string)

    Sets content (sanitized automatically)

    getText(): string

    Returns plain text content

    isEmpty(): boolean

    Returns true if editor has no content

    // Get content
    const html = editor.getHTML();  // '<p><strong>Hello</strong> world</p>'
    const text = editor.getText();  // 'Hello world'
    
    // Set content
    editor.setHTML('<p>New content</p>');
    
    // Check if empty
    if (editor.isEmpty()) {
      showPlaceholder();
    }

    Selection Methods

    Manage cursor position and selection

    getSelection(): SelectionState | null

    Get current selection with formats

    saveSelection()

    Save current selection (for dialogs)

    restoreSelection()

    Restore saved selection

    focus()

    Focus the editor and restore selection

    blur()

    Remove focus from editor

    // SelectionState interface
    interface SelectionState {
      range: Range | null;      // Native Range object
      collapsed: boolean;       // True = cursor, False = selection
      text: string;            // Selected text content
      rect: DOMRect | null;    // Position for toolbar
      formats: FormatState;    // Current formatting
    }
    
    interface FormatState {
      bold: boolean;
      italic: boolean;
      underline: boolean;
      strike: boolean;
      code: boolean;
      link: string | null;     // URL if in link
      heading: number | null;  // 1-6 or null
      list: 'ordered' | 'unordered' | null;
      blockquote: boolean;
      align: 'left' | 'center' | 'right' | 'justify';
    }
    
    // Usage: Update toolbar based on selection
    editor.on('selectionChange', (selection) => {
      if (selection) {
        boldButton.classList.toggle('active', selection.formats.bold);
        italicButton.classList.toggle('active', selection.formats.italic);
      }
    });

    Events

    Subscribe to editor events

    on(event: string, handler: (...args) => void)

    Subscribe to an event

    off(event: string, handler: (...args) => void)

    Unsubscribe from an event

    emit(event: string, ...args: unknown[])

    Emit an event (for plugins)

    Available Events

    change → (html: string)
    selectionChange → (selection: SelectionState)
    focus → ()
    blur → ()
    ready → ()
    destroy → ()
    readOnlyChange → (readOnly: boolean)

    Best Practices

    • Use onChange config for simple callbacks

    • Use .on() for multiple handlers

    • Always .off() in cleanup

    • Debounce expensive change handlers

    // Event handling
    const editor = Scribe.init('#editor');
    
    // Subscribe
    const handleChange = (html) => {
      console.log('Content changed:', html);
      autoSave(html);
    };
    editor.on('change', handleChange);
    
    // Subscribe to selection changes for toolbar
    editor.on('selectionChange', (selection) => {
      if (selection && !selection.collapsed) {
        showFloatingToolbar(selection.rect, selection.formats);
      } else {
        hideFloatingToolbar();
      }
    });
    
    // Cleanup (important!)
    function cleanup() {
      editor.off('change', handleChange);
      editor.destroy();
    }

    State Methods

    Control editor state

    isReadOnly(): boolean

    Check if editor is read-only

    setReadOnly(readOnly: boolean)

    Enable/disable editing

    undo()

    Undo last action

    redo()

    Redo last undone action

    destroy()

    Clean up and remove editor

    // Toggle read-only mode
    function togglePreview() {
      const isPreview = editor.isReadOnly();
      editor.setReadOnly(!isPreview);
    }
    
    // History
    document.querySelector('#undo').onclick = () => editor.undo();
    document.querySelector('#redo').onclick = () => editor.redo();
    
    // Cleanup when done
    window.addEventListener('beforeunload', () => {
      editor.destroy();
    });

    Keyboard Shortcuts

    Built-in shortcuts (customizable via plugins)

    Formatting

    BoldCtrl/Cmd + B
    ItalicCtrl/Cmd + I
    UnderlineCtrl/Cmd + U
    LinkCtrl/Cmd + K
    Clear FormatCtrl/Cmd + \

    History

    UndoCtrl/Cmd + Z
    RedoCtrl/Cmd + Y
    Redo (Alt)Ctrl/Cmd + Shift + Z

    Styling Methods

    Apply custom styles to text

    setFontSize(size: string | number)

    Set font size (e.g., '18px' or 18)

    setFontFamily(font: string)

    Set font family

    setColor(color: string)

    Set text color (hex, rgb, etc.)

    setBackgroundColor(color: string)

    Set text background/highlight color

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

    Sanitization Config

    Configure HTML sanitization rules

    // SanitizeConfig interface
    interface SanitizeConfig {
      // Tags allowed in output
      allowedTags?: string[];
      
      // Attributes allowed per tag ('*' = all tags)
      allowedAttributes?: Record<string, string[]>;
      
      // URL schemes allowed in href/src
      allowedSchemes?: string[];
      
      // Remove empty tags like <p></p>
      stripEmpty?: boolean;
    }
    
    // Default configuration
    const defaultSanitize: SanitizeConfig = {
      allowedTags: [
        'p', 'br', 'div', 'span',
        'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins',
        'sub', 'sup', 'mark', 'code', 'pre',
        'a', 'img',
        'ul', 'ol', 'li',
        'blockquote', 'hr',
        'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'table', 'thead', 'tbody', 'tr', 'td', 'th'
      ],
      allowedAttributes: {
        'a': ['href', 'target', 'rel', 'title'],
        'img': ['src', 'alt', 'width', 'height', 'title'],
        'td': ['colspan', 'rowspan'],
        'th': ['colspan', 'rowspan'],
        '*': ['class', 'id', 'style']
      },
      allowedSchemes: ['http', 'https', 'mailto', 'tel'],
      stripEmpty: true
    };
    
    // Strict configuration (minimal formatting)
    const strictSanitize: SanitizeConfig = {
      allowedTags: ['p', 'br', 'strong', 'em', 'a'],
      allowedAttributes: {
        'a': ['href']
      },
      allowedSchemes: ['https'],
      stripEmpty: true
    };

    Toolbar State & Command Metadata

    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.

    toolbar.addEventListener('mousedown', (e) => {
      e.preventDefault(); // Keep focus in iframe
    });

    Performance with many buttons

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

    Demo Gallery

    Inline Editor

    Floating toolbar

    Document Mode

    Long-form writing

    Iframe Editor

    Isolated document
    Loading toolbar...

    Textarea + Fixed Toolbar

    Always-on controls

    Migration Guide

    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

    Editor Comparison

    FeatureScribeTinyMCEQuillTiptap
    Bundle size< 50KB~400KB~100KB~150KB
    Direct API methods
    Iframe editing
    Floating toolbar
    Plugin system
    TypeScriptPartial
    Framework agnosticReact focus
    Zero dependencies
    Built-in sanitization
    CRDT readyPreparedVia Yjs