CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-prosemirror-view

ProseMirror's view component that manages DOM structure and user interactions for rich text editing

Pending

Quality

Pending

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

Overview
Eval results
Files

input-handling.mddocs/

Input Handling

Input handling in ProseMirror View manages all forms of user input including keyboard events, mouse interactions, clipboard operations, composition input for international keyboards, and drag-and-drop functionality. The system provides both low-level event access and high-level content transformation capabilities.

Capabilities

Clipboard Operations

Methods for programmatically handling clipboard content and paste operations.

class EditorView {
  /**
   * Run the editor's paste logic with the given HTML string. The
   * `event`, if given, will be passed to the handlePaste hook.
   */
  pasteHTML(html: string, event?: ClipboardEvent): boolean;
  
  /**
   * Run the editor's paste logic with the given plain-text input.
   */
  pasteText(text: string, event?: ClipboardEvent): boolean;
  
  /**
   * Serialize the given slice as it would be if it was copied from
   * this editor. Returns a DOM element that contains a representation
   * of the slice as its children, a textual representation, and the
   * transformed slice.
   */
  serializeForClipboard(slice: Slice): {
    dom: HTMLElement, 
    text: string, 
    slice: Slice
  };
}

Usage Examples:

import { EditorView } from "prosemirror-view";
import { Slice } from "prosemirror-model";

// Programmatic paste operations
function pasteFormattedContent(view, htmlContent) {
  const success = view.pasteHTML(htmlContent);
  if (success) {
    console.log("HTML content pasted successfully");
  }
}

function pasteAsPlainText(view, textContent) {
  const success = view.pasteText(textContent);
  if (success) {
    console.log("Plain text pasted successfully");
  }
}

// Custom copy functionality
function copySelectionWithMetadata(view) {
  const selection = view.state.selection;
  const slice = selection.content();
  
  // Serialize for clipboard
  const { dom, text, slice: transformedSlice } = view.serializeForClipboard(slice);
  
  // Add custom metadata
  const metadata = {
    source: "my-editor",
    timestamp: new Date().toISOString(),
    selection: { from: selection.from, to: selection.to }
  };
  
  // Create enhanced clipboard data
  const clipboardData = new DataTransfer();
  clipboardData.setData("text/html", dom.innerHTML);
  clipboardData.setData("text/plain", text);
  clipboardData.setData("application/json", JSON.stringify(metadata));
  
  // Trigger clipboard event
  const clipEvent = new ClipboardEvent("copy", { clipboardData });
  view.dom.dispatchEvent(clipEvent);
}

// Smart paste handler
class SmartPasteHandler {
  constructor(view) {
    this.view = view;
    this.setupPasteHandling();
  }
  
  setupPasteHandling() {
    this.view.dom.addEventListener("paste", (event) => {
      this.handlePaste(event);
    });
  }
  
  handlePaste(event) {
    const clipboardData = event.clipboardData;
    if (!clipboardData) return;
    
    // Check for custom JSON metadata
    const jsonData = clipboardData.getData("application/json");
    if (jsonData) {
      try {
        const metadata = JSON.parse(jsonData);
        if (metadata.source === "my-editor") {
          this.handleInternalPaste(clipboardData, metadata);
          event.preventDefault();
          return;
        }
      } catch (e) {
        // Not valid JSON, continue with normal paste
      }
    }
    
    // Handle file paste
    const files = Array.from(clipboardData.files);
    if (files.length > 0) {
      this.handleFilePaste(files);
      event.preventDefault();
      return;
    }
    
    // Handle URL paste
    const text = clipboardData.getData("text/plain");
    if (this.isURL(text)) {
      this.handleURLPaste(text);
      event.preventDefault();
      return;
    }
  }
  
  handleInternalPaste(clipboardData, metadata) {
    const html = clipboardData.getData("text/html");
    console.log("Pasting internal content with metadata:", metadata);
    this.view.pasteHTML(html);
  }
  
  handleFilePaste(files) {
    files.forEach(file => {
      if (file.type.startsWith("image/")) {
        this.insertImageFromFile(file);
      }
    });
  }
  
  handleURLPaste(url) {
    // Auto-convert URLs to links
    const linkHTML = `<a href="${url}">${url}</a>`;
    this.view.pasteHTML(linkHTML);
  }
  
  isURL(text) {
    try {
      new URL(text);
      return true;
    } catch {
      return false;
    }
  }
  
  insertImageFromFile(file) {
    const reader = new FileReader();
    reader.onload = () => {
      const imageHTML = `<img src="${reader.result}" alt="${file.name}">`;
      this.view.pasteHTML(imageHTML);
    };
    reader.readAsDataURL(file);
  }
}

Event Dispatching

Method for testing and custom event handling.

class EditorView {
  /**
   * Used for testing. Dispatches a DOM event to the view and returns
   * whether it was handled by the editor's event handling logic.
   */
  dispatchEvent(event: Event): boolean;
}

Usage Examples:

// Testing keyboard shortcuts
function testKeyboardShortcut(view, key, modifiers = {}) {
  const event = new KeyboardEvent("keydown", {
    key: key,
    ctrlKey: modifiers.ctrl || false,
    shiftKey: modifiers.shift || false,
    altKey: modifiers.alt || false,
    metaKey: modifiers.meta || false,
    bubbles: true,
    cancelable: true
  });
  
  const handled = view.dispatchEvent(event);
  console.log(`${key} shortcut ${handled ? "was" : "was not"} handled`);
  return handled;
}

// Test suite for editor shortcuts
function runShortcutTests(view) {
  const tests = [
    { key: "b", ctrl: true, name: "Bold" },
    { key: "i", ctrl: true, name: "Italic" },
    { key: "z", ctrl: true, name: "Undo" },
    { key: "y", ctrl: true, name: "Redo" },
    { key: "s", ctrl: true, name: "Save" }
  ];
  
  tests.forEach(test => {
    const handled = testKeyboardShortcut(view, test.key, { ctrl: test.ctrl });
    console.log(`${test.name}: ${handled ? "PASS" : "FAIL"}`);
  });
}

// Simulate user input for automation
class EditorAutomation {
  constructor(view) {
    this.view = view;
  }
  
  typeText(text, delay = 50) {
    const chars = text.split("");
    let index = 0;
    
    const typeNext = () => {
      if (index >= chars.length) return;
      
      const char = chars[index++];
      const event = new KeyboardEvent("keydown", {
        key: char,
        bubbles: true,
        cancelable: true
      });
      
      this.view.dispatchEvent(event);
      
      // Simulate actual text input
      const inputEvent = new InputEvent("input", {
        data: char,
        inputType: "insertText",
        bubbles: true,
        cancelable: true
      });
      
      this.view.dispatchEvent(inputEvent);
      
      setTimeout(typeNext, delay);
    };
    
    typeNext();
  }
  
  pressKey(key, modifiers = {}) {
    const event = new KeyboardEvent("keydown", {
      key: key,
      ctrlKey: modifiers.ctrl || false,
      shiftKey: modifiers.shift || false,
      altKey: modifiers.alt || false,
      metaKey: modifiers.meta || false,
      bubbles: true,
      cancelable: true
    });
    
    return this.view.dispatchEvent(event);
  }
  
  clickAt(pos) {
    const coords = this.view.coordsAtPos(pos);
    const event = new MouseEvent("click", {
      clientX: coords.left,
      clientY: coords.top,
      bubbles: true,
      cancelable: true
    });
    
    return this.view.dispatchEvent(event);
  }
}

// Usage
const automation = new EditorAutomation(view);
automation.typeText("Hello, world!");
automation.pressKey("Enter");
automation.typeText("This is a new paragraph.");

Scroll and Navigation Props

Props for customizing scroll behavior and selection navigation.

interface EditorProps<P = any> {
  /**
   * Called when the view, after updating its state, tries to scroll
   * the selection into view. A handler function may return false to
   * indicate that it did not handle the scrolling and further
   * handlers or the default behavior should be tried.
   */
  handleScrollToSelection?(this: P, view: EditorView): boolean;
  
  /**
   * Determines the distance (in pixels) between the cursor and the
   * end of the visible viewport at which point, when scrolling the
   * cursor into view, scrolling takes place. Defaults to 0.
   */
  scrollThreshold?: number | {
    top: number, 
    right: number, 
    bottom: number, 
    left: number
  };
  
  /**
   * Determines the extra space (in pixels) that is left above or
   * below the cursor when it is scrolled into view. Defaults to 5.
   */
  scrollMargin?: number | {
    top: number, 
    right: number, 
    bottom: number, 
    left: number
  };
}

Usage Examples:

// Custom scroll behavior
const view = new EditorView(element, {
  state: myState,
  
  handleScrollToSelection(view) {
    const selection = view.state.selection;
    const coords = view.coordsAtPos(selection.head);
    
    // Custom smooth scroll with animation
    const targetY = coords.top - window.innerHeight / 2;
    
    window.scrollTo({
      top: targetY,
      behavior: "smooth"
    });
    
    // Show cursor position indicator
    const indicator = document.createElement("div");
    indicator.className = "cursor-indicator";
    indicator.style.cssText = `
      position: fixed;
      left: ${coords.left}px;
      top: 50vh;
      width: 2px;
      height: 20px;
      background: #007acc;
      animation: blink 1s ease-in-out;
      pointer-events: none;
      z-index: 1000;
    `;
    
    document.body.appendChild(indicator);
    setTimeout(() => {
      document.body.removeChild(indicator);
    }, 1000);
    
    return true; // Indicate we handled the scrolling
  },
  
  scrollThreshold: {
    top: 100,
    bottom: 100,
    left: 50,
    right: 50
  },
  
  scrollMargin: {
    top: 80,  // Extra space for fixed header
    bottom: 20,
    left: 10,
    right: 10
  }
});

Selection and Cursor Props

Props for customizing selection behavior and cursor handling.

interface EditorProps<P = any> {
  /**
   * Can be used to override the way a selection is created when
   * reading a DOM selection between the given anchor and head.
   */
  createSelectionBetween?(
    this: P, 
    view: EditorView, 
    anchor: ResolvedPos, 
    head: ResolvedPos
  ): Selection | null;
  
  /**
   * Determines whether an in-editor drag event should copy or move
   * the selection. When not given, the event's altKey property is
   * used on macOS, ctrlKey on other platforms.
   */
  dragCopies?(event: DragEvent): boolean;
}

Usage Examples:

// Custom selection behavior
const view = new EditorView(element, {
  state: myState,
  
  createSelectionBetween(view, anchor, head) {
    // Custom selection logic for specific node types
    const anchorNode = anchor.parent;
    const headNode = head.parent;
    
    // If selecting across code blocks, select entire blocks
    if (anchorNode.type.name === "code_block" || headNode.type.name === "code_block") {
      const startPos = Math.min(anchor.start(), head.start());
      const endPos = Math.max(anchor.end(), head.end());
      
      return TextSelection.create(view.state.doc, startPos, endPos);
    }
    
    // If selecting across different list items, select entire items
    if (anchorNode.type.name === "list_item" && headNode.type.name === "list_item" && 
        anchorNode !== headNode) {
      const startPos = Math.min(anchor.start(), head.start());
      const endPos = Math.max(anchor.end(), head.end());
      
      return TextSelection.create(view.state.doc, startPos, endPos);
    }
    
    // Use default selection behavior
    return null;
  },
  
  dragCopies(event) {
    // Always copy when dragging images
    const selection = view.state.selection;
    if (selection instanceof NodeSelection && 
        selection.node.type.name === "image") {
      return true;
    }
    
    // Copy when Alt key is held (cross-platform)
    if (event.altKey) {
      return true;
    }
    
    // Move by default
    return false;
  }
});

// Advanced selection management
class SelectionManager {
  constructor(view) {
    this.view = view;
    this.selectionHistory = [];
    this.setupSelectionTracking();
  }
  
  setupSelectionTracking() {
    // Track selection changes
    let lastSelection = this.view.state.selection;
    
    this.view.dom.addEventListener("selectionchange", () => {
      const currentSelection = this.view.state.selection;
      
      if (!currentSelection.eq(lastSelection)) {
        this.addToHistory(lastSelection);
        lastSelection = currentSelection;
        this.onSelectionChange(currentSelection);
      }
    });
  }
  
  addToHistory(selection) {
    this.selectionHistory.push(selection);
    
    // Keep only last 50 selections
    if (this.selectionHistory.length > 50) {
      this.selectionHistory.shift();
    }
  }
  
  onSelectionChange(selection) {
    // Custom logic when selection changes
    console.log(`Selection changed: ${selection.from} to ${selection.to}`);
    
    // Update UI indicators
    this.updateSelectionIndicators(selection);
    
    // Emit custom event
    this.view.dom.dispatchEvent(new CustomEvent("editor-selection-change", {
      detail: { selection }
    }));
  }
  
  updateSelectionIndicators(selection) {
    // Show selection info in status bar
    const statusBar = document.querySelector("#status-bar");
    if (statusBar) {
      const text = selection.empty 
        ? `Position: ${selection.head}`
        : `Selected: ${selection.from} to ${selection.to} (${selection.to - selection.from} chars)`;
      
      statusBar.textContent = text;
    }
  }
  
  restorePreviousSelection() {
    if (this.selectionHistory.length > 0) {
      const previousSelection = this.selectionHistory.pop();
      const tr = this.view.state.tr.setSelection(previousSelection);
      this.view.dispatch(tr);
    }
  }
  
  selectWord(pos) {
    const doc = this.view.state.doc;
    const $pos = doc.resolve(pos);
    
    // Find word boundaries
    let start = pos;
    let end = pos;
    
    const textNode = $pos.parent.child($pos.index());
    if (textNode && textNode.isText) {
      const text = textNode.text;
      const offset = pos - $pos.start();
      
      // Find start of word
      while (start > $pos.start() && /\w/.test(text[offset - (pos - start) - 1])) {
        start--;
      }
      
      // Find end of word
      while (end < $pos.end() && /\w/.test(text[offset + (end - pos)])) {
        end++;
      }
    }
    
    const selection = TextSelection.create(doc, start, end);
    this.view.dispatch(this.view.state.tr.setSelection(selection));
  }
  
  selectLine(pos) {
    const doc = this.view.state.doc;
    const $pos = doc.resolve(pos);
    
    // Select entire line (paragraph)
    const start = $pos.start();
    const end = $pos.end();
    
    const selection = TextSelection.create(doc, start, end);
    this.view.dispatch(this.view.state.tr.setSelection(selection));
  }
}

// Usage
const selectionManager = new SelectionManager(view);

// Keyboard shortcuts for selection
view.dom.addEventListener("keydown", (event) => {
  if (event.ctrlKey && event.key === "w") {
    // Ctrl+W to select word
    const pos = view.state.selection.head;
    selectionManager.selectWord(pos);
    event.preventDefault();
  } else if (event.ctrlKey && event.key === "l") {
    // Ctrl+L to select line
    const pos = view.state.selection.head;
    selectionManager.selectLine(pos);
    event.preventDefault();
  } else if (event.ctrlKey && event.shiftKey && event.key === "z") {
    // Ctrl+Shift+Z to restore previous selection
    selectionManager.restorePreviousSelection();
    event.preventDefault();
  }
});

Complete Input Handling Example:

import { EditorView } from "prosemirror-view";
import { EditorState, TextSelection } from "prosemirror-state";

class AdvancedInputHandler {
  constructor(view) {
    this.view = view;
    this.setupInputHandling();
  }
  
  setupInputHandling() {
    // Comprehensive input handling setup
    this.view.setProps({
      ...this.view.props,
      
      handleKeyDown: this.handleKeyDown.bind(this),
      handleTextInput: this.handleTextInput.bind(this),
      handlePaste: this.handlePaste.bind(this),
      handleDrop: this.handleDrop.bind(this),
      handleDOMEvents: {
        compositionstart: this.handleCompositionStart.bind(this),
        compositionend: this.handleCompositionEnd.bind(this),
        input: this.handleInput.bind(this)
      }
    });
  }
  
  handleKeyDown(view, event) {
    // Custom keyboard shortcuts
    if (event.ctrlKey || event.metaKey) {
      switch (event.key) {
        case "s":
          this.saveDocument();
          return true;
        case "d":
          this.duplicateLine();
          return true;
        case "/":
          this.toggleComment();
          return true;
      }
    }
    
    // Auto-completion on Tab
    if (event.key === "Tab" && !event.shiftKey) {
      if (this.handleAutoCompletion()) {
        return true;
      }
    }
    
    return false;
  }
  
  handleTextInput(view, from, to, text, deflt) {
    // Smart quotes
    if (text === '"') {
      const beforeText = view.state.doc.textBetween(Math.max(0, from - 1), from);
      const isOpening = beforeText === "" || /\s/.test(beforeText);
      
      const tr = view.state.tr.insertText(isOpening ? """ : """, from, to);
      view.dispatch(tr);
      return true;
    }
    
    // Auto-pairing brackets
    const pairs = { "(": ")", "[": "]", "{": "}" };
    if (pairs[text]) {
      const tr = view.state.tr
        .insertText(text + pairs[text], from, to)
        .setSelection(TextSelection.create(view.state.doc, from + 1));
      view.dispatch(tr);
      return true;
    }
    
    // Markdown shortcuts
    if (text === " ") {
      const beforeText = view.state.doc.textBetween(Math.max(0, from - 10), from);
      
      // Headers
      const headerMatch = beforeText.match(/^(#{1,6})\s*(.*)$/);
      if (headerMatch) {
        const level = headerMatch[1].length;
        const content = headerMatch[2];
        
        // Convert to heading
        const tr = view.state.tr
          .delete(from - beforeText.length, from)
          .setBlockType(from - beforeText.length, from, 
                       view.state.schema.nodes.heading, { level });
        
        if (content) {
          tr.insertText(content);
        }
        
        view.dispatch(tr);
        return true;
      }
    }
    
    return false;
  }
  
  handlePaste(view, event, slice) {
    // Handle special paste formats
    const html = event.clipboardData?.getData("text/html");
    
    if (html && html.includes("data-table-source")) {
      return this.handleTablePaste(html);
    }
    
    return false;
  }
  
  handleDrop(view, event, slice, moved) {
    // Handle file drops
    const files = Array.from(event.dataTransfer?.files || []);
    
    if (files.length > 0) {
      this.handleFilesDrop(files, event);
      return true;
    }
    
    return false;
  }
  
  handleCompositionStart(view, event) {
    console.log("Composition started");
    this.composing = true;
    return false;
  }
  
  handleCompositionEnd(view, event) {
    console.log("Composition ended");
    this.composing = false;
    return false;
  }
  
  handleInput(view, event) {
    if (!this.composing) {
      // Process input when not composing
      this.processInput(event);
    }
    return false;
  }
  
  // Helper methods
  saveDocument() {
    const content = this.view.state.doc.toJSON();
    localStorage.setItem("document", JSON.stringify(content));
    console.log("Document saved");
  }
  
  duplicateLine() {
    const selection = this.view.state.selection;
    const $pos = this.view.state.doc.resolve(selection.head);
    const lineStart = $pos.start();
    const lineEnd = $pos.end();
    const lineContent = this.view.state.doc.slice(lineStart, lineEnd);
    
    const tr = this.view.state.tr.insert(lineEnd, lineContent.content);
    this.view.dispatch(tr);
  }
  
  toggleComment() {
    // Implementation depends on schema and comment system
    console.log("Toggle comment");
  }
  
  handleAutoCompletion() {
    // Implementation depends on completion system
    console.log("Auto-completion triggered");
    return false;
  }
  
  handleTablePaste(html) {
    // Custom table paste handling
    console.log("Handling table paste");
    return true;
  }
  
  handleFilesDrop(files, event) {
    const coords = this.view.posAtCoords({
      left: event.clientX,
      top: event.clientY
    });
    
    if (coords) {
      files.forEach(file => {
        if (file.type.startsWith("image/")) {
          this.insertImage(file, coords.pos);
        }
      });
    }
  }
  
  insertImage(file, pos) {
    const reader = new FileReader();
    reader.onload = () => {
      const img = this.view.state.schema.nodes.image.create({
        src: reader.result,
        alt: file.name
      });
      
      const tr = this.view.state.tr.insert(pos, img);
      this.view.dispatch(tr);
    };
    reader.readAsDataURL(file);
  }
  
  processInput(event) {
    // Additional input processing
    console.log("Processing input:", event.inputType, event.data);
  }
}

// Usage
const inputHandler = new AdvancedInputHandler(view);

Install with Tessl CLI

npx tessl i tessl/npm-prosemirror-view

docs

core-editor-view.md

custom-views.md

decoration-system.md

editor-props.md

index.md

input-handling.md

position-mapping.md

tile.json