ProseMirror's view component that manages DOM structure and user interactions for rich text editing
—
Quality
Pending
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
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.
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);
}
}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.");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
}
});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