0
# Input Handling
1
2
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.
3
4
## Capabilities
5
6
### Clipboard Operations
7
8
Methods for programmatically handling clipboard content and paste operations.
9
10
```typescript { .api }
11
class EditorView {
12
/**
13
* Run the editor's paste logic with the given HTML string. The
14
* `event`, if given, will be passed to the handlePaste hook.
15
*/
16
pasteHTML(html: string, event?: ClipboardEvent): boolean;
17
18
/**
19
* Run the editor's paste logic with the given plain-text input.
20
*/
21
pasteText(text: string, event?: ClipboardEvent): boolean;
22
23
/**
24
* Serialize the given slice as it would be if it was copied from
25
* this editor. Returns a DOM element that contains a representation
26
* of the slice as its children, a textual representation, and the
27
* transformed slice.
28
*/
29
serializeForClipboard(slice: Slice): {
30
dom: HTMLElement,
31
text: string,
32
slice: Slice
33
};
34
}
35
```
36
37
**Usage Examples:**
38
39
```typescript
40
import { EditorView } from "prosemirror-view";
41
import { Slice } from "prosemirror-model";
42
43
// Programmatic paste operations
44
function pasteFormattedContent(view, htmlContent) {
45
const success = view.pasteHTML(htmlContent);
46
if (success) {
47
console.log("HTML content pasted successfully");
48
}
49
}
50
51
function pasteAsPlainText(view, textContent) {
52
const success = view.pasteText(textContent);
53
if (success) {
54
console.log("Plain text pasted successfully");
55
}
56
}
57
58
// Custom copy functionality
59
function copySelectionWithMetadata(view) {
60
const selection = view.state.selection;
61
const slice = selection.content();
62
63
// Serialize for clipboard
64
const { dom, text, slice: transformedSlice } = view.serializeForClipboard(slice);
65
66
// Add custom metadata
67
const metadata = {
68
source: "my-editor",
69
timestamp: new Date().toISOString(),
70
selection: { from: selection.from, to: selection.to }
71
};
72
73
// Create enhanced clipboard data
74
const clipboardData = new DataTransfer();
75
clipboardData.setData("text/html", dom.innerHTML);
76
clipboardData.setData("text/plain", text);
77
clipboardData.setData("application/json", JSON.stringify(metadata));
78
79
// Trigger clipboard event
80
const clipEvent = new ClipboardEvent("copy", { clipboardData });
81
view.dom.dispatchEvent(clipEvent);
82
}
83
84
// Smart paste handler
85
class SmartPasteHandler {
86
constructor(view) {
87
this.view = view;
88
this.setupPasteHandling();
89
}
90
91
setupPasteHandling() {
92
this.view.dom.addEventListener("paste", (event) => {
93
this.handlePaste(event);
94
});
95
}
96
97
handlePaste(event) {
98
const clipboardData = event.clipboardData;
99
if (!clipboardData) return;
100
101
// Check for custom JSON metadata
102
const jsonData = clipboardData.getData("application/json");
103
if (jsonData) {
104
try {
105
const metadata = JSON.parse(jsonData);
106
if (metadata.source === "my-editor") {
107
this.handleInternalPaste(clipboardData, metadata);
108
event.preventDefault();
109
return;
110
}
111
} catch (e) {
112
// Not valid JSON, continue with normal paste
113
}
114
}
115
116
// Handle file paste
117
const files = Array.from(clipboardData.files);
118
if (files.length > 0) {
119
this.handleFilePaste(files);
120
event.preventDefault();
121
return;
122
}
123
124
// Handle URL paste
125
const text = clipboardData.getData("text/plain");
126
if (this.isURL(text)) {
127
this.handleURLPaste(text);
128
event.preventDefault();
129
return;
130
}
131
}
132
133
handleInternalPaste(clipboardData, metadata) {
134
const html = clipboardData.getData("text/html");
135
console.log("Pasting internal content with metadata:", metadata);
136
this.view.pasteHTML(html);
137
}
138
139
handleFilePaste(files) {
140
files.forEach(file => {
141
if (file.type.startsWith("image/")) {
142
this.insertImageFromFile(file);
143
}
144
});
145
}
146
147
handleURLPaste(url) {
148
// Auto-convert URLs to links
149
const linkHTML = `<a href="${url}">${url}</a>`;
150
this.view.pasteHTML(linkHTML);
151
}
152
153
isURL(text) {
154
try {
155
new URL(text);
156
return true;
157
} catch {
158
return false;
159
}
160
}
161
162
insertImageFromFile(file) {
163
const reader = new FileReader();
164
reader.onload = () => {
165
const imageHTML = `<img src="${reader.result}" alt="${file.name}">`;
166
this.view.pasteHTML(imageHTML);
167
};
168
reader.readAsDataURL(file);
169
}
170
}
171
```
172
173
### Event Dispatching
174
175
Method for testing and custom event handling.
176
177
```typescript { .api }
178
class EditorView {
179
/**
180
* Used for testing. Dispatches a DOM event to the view and returns
181
* whether it was handled by the editor's event handling logic.
182
*/
183
dispatchEvent(event: Event): boolean;
184
}
185
```
186
187
**Usage Examples:**
188
189
```typescript
190
// Testing keyboard shortcuts
191
function testKeyboardShortcut(view, key, modifiers = {}) {
192
const event = new KeyboardEvent("keydown", {
193
key: key,
194
ctrlKey: modifiers.ctrl || false,
195
shiftKey: modifiers.shift || false,
196
altKey: modifiers.alt || false,
197
metaKey: modifiers.meta || false,
198
bubbles: true,
199
cancelable: true
200
});
201
202
const handled = view.dispatchEvent(event);
203
console.log(`${key} shortcut ${handled ? "was" : "was not"} handled`);
204
return handled;
205
}
206
207
// Test suite for editor shortcuts
208
function runShortcutTests(view) {
209
const tests = [
210
{ key: "b", ctrl: true, name: "Bold" },
211
{ key: "i", ctrl: true, name: "Italic" },
212
{ key: "z", ctrl: true, name: "Undo" },
213
{ key: "y", ctrl: true, name: "Redo" },
214
{ key: "s", ctrl: true, name: "Save" }
215
];
216
217
tests.forEach(test => {
218
const handled = testKeyboardShortcut(view, test.key, { ctrl: test.ctrl });
219
console.log(`${test.name}: ${handled ? "PASS" : "FAIL"}`);
220
});
221
}
222
223
// Simulate user input for automation
224
class EditorAutomation {
225
constructor(view) {
226
this.view = view;
227
}
228
229
typeText(text, delay = 50) {
230
const chars = text.split("");
231
let index = 0;
232
233
const typeNext = () => {
234
if (index >= chars.length) return;
235
236
const char = chars[index++];
237
const event = new KeyboardEvent("keydown", {
238
key: char,
239
bubbles: true,
240
cancelable: true
241
});
242
243
this.view.dispatchEvent(event);
244
245
// Simulate actual text input
246
const inputEvent = new InputEvent("input", {
247
data: char,
248
inputType: "insertText",
249
bubbles: true,
250
cancelable: true
251
});
252
253
this.view.dispatchEvent(inputEvent);
254
255
setTimeout(typeNext, delay);
256
};
257
258
typeNext();
259
}
260
261
pressKey(key, modifiers = {}) {
262
const event = new KeyboardEvent("keydown", {
263
key: key,
264
ctrlKey: modifiers.ctrl || false,
265
shiftKey: modifiers.shift || false,
266
altKey: modifiers.alt || false,
267
metaKey: modifiers.meta || false,
268
bubbles: true,
269
cancelable: true
270
});
271
272
return this.view.dispatchEvent(event);
273
}
274
275
clickAt(pos) {
276
const coords = this.view.coordsAtPos(pos);
277
const event = new MouseEvent("click", {
278
clientX: coords.left,
279
clientY: coords.top,
280
bubbles: true,
281
cancelable: true
282
});
283
284
return this.view.dispatchEvent(event);
285
}
286
}
287
288
// Usage
289
const automation = new EditorAutomation(view);
290
automation.typeText("Hello, world!");
291
automation.pressKey("Enter");
292
automation.typeText("This is a new paragraph.");
293
```
294
295
### Scroll and Navigation Props
296
297
Props for customizing scroll behavior and selection navigation.
298
299
```typescript { .api }
300
interface EditorProps<P = any> {
301
/**
302
* Called when the view, after updating its state, tries to scroll
303
* the selection into view. A handler function may return false to
304
* indicate that it did not handle the scrolling and further
305
* handlers or the default behavior should be tried.
306
*/
307
handleScrollToSelection?(this: P, view: EditorView): boolean;
308
309
/**
310
* Determines the distance (in pixels) between the cursor and the
311
* end of the visible viewport at which point, when scrolling the
312
* cursor into view, scrolling takes place. Defaults to 0.
313
*/
314
scrollThreshold?: number | {
315
top: number,
316
right: number,
317
bottom: number,
318
left: number
319
};
320
321
/**
322
* Determines the extra space (in pixels) that is left above or
323
* below the cursor when it is scrolled into view. Defaults to 5.
324
*/
325
scrollMargin?: number | {
326
top: number,
327
right: number,
328
bottom: number,
329
left: number
330
};
331
}
332
```
333
334
**Usage Examples:**
335
336
```typescript
337
// Custom scroll behavior
338
const view = new EditorView(element, {
339
state: myState,
340
341
handleScrollToSelection(view) {
342
const selection = view.state.selection;
343
const coords = view.coordsAtPos(selection.head);
344
345
// Custom smooth scroll with animation
346
const targetY = coords.top - window.innerHeight / 2;
347
348
window.scrollTo({
349
top: targetY,
350
behavior: "smooth"
351
});
352
353
// Show cursor position indicator
354
const indicator = document.createElement("div");
355
indicator.className = "cursor-indicator";
356
indicator.style.cssText = `
357
position: fixed;
358
left: ${coords.left}px;
359
top: 50vh;
360
width: 2px;
361
height: 20px;
362
background: #007acc;
363
animation: blink 1s ease-in-out;
364
pointer-events: none;
365
z-index: 1000;
366
`;
367
368
document.body.appendChild(indicator);
369
setTimeout(() => {
370
document.body.removeChild(indicator);
371
}, 1000);
372
373
return true; // Indicate we handled the scrolling
374
},
375
376
scrollThreshold: {
377
top: 100,
378
bottom: 100,
379
left: 50,
380
right: 50
381
},
382
383
scrollMargin: {
384
top: 80, // Extra space for fixed header
385
bottom: 20,
386
left: 10,
387
right: 10
388
}
389
});
390
```
391
392
### Selection and Cursor Props
393
394
Props for customizing selection behavior and cursor handling.
395
396
```typescript { .api }
397
interface EditorProps<P = any> {
398
/**
399
* Can be used to override the way a selection is created when
400
* reading a DOM selection between the given anchor and head.
401
*/
402
createSelectionBetween?(
403
this: P,
404
view: EditorView,
405
anchor: ResolvedPos,
406
head: ResolvedPos
407
): Selection | null;
408
409
/**
410
* Determines whether an in-editor drag event should copy or move
411
* the selection. When not given, the event's altKey property is
412
* used on macOS, ctrlKey on other platforms.
413
*/
414
dragCopies?(event: DragEvent): boolean;
415
}
416
```
417
418
**Usage Examples:**
419
420
```typescript
421
// Custom selection behavior
422
const view = new EditorView(element, {
423
state: myState,
424
425
createSelectionBetween(view, anchor, head) {
426
// Custom selection logic for specific node types
427
const anchorNode = anchor.parent;
428
const headNode = head.parent;
429
430
// If selecting across code blocks, select entire blocks
431
if (anchorNode.type.name === "code_block" || headNode.type.name === "code_block") {
432
const startPos = Math.min(anchor.start(), head.start());
433
const endPos = Math.max(anchor.end(), head.end());
434
435
return TextSelection.create(view.state.doc, startPos, endPos);
436
}
437
438
// If selecting across different list items, select entire items
439
if (anchorNode.type.name === "list_item" && headNode.type.name === "list_item" &&
440
anchorNode !== headNode) {
441
const startPos = Math.min(anchor.start(), head.start());
442
const endPos = Math.max(anchor.end(), head.end());
443
444
return TextSelection.create(view.state.doc, startPos, endPos);
445
}
446
447
// Use default selection behavior
448
return null;
449
},
450
451
dragCopies(event) {
452
// Always copy when dragging images
453
const selection = view.state.selection;
454
if (selection instanceof NodeSelection &&
455
selection.node.type.name === "image") {
456
return true;
457
}
458
459
// Copy when Alt key is held (cross-platform)
460
if (event.altKey) {
461
return true;
462
}
463
464
// Move by default
465
return false;
466
}
467
});
468
469
// Advanced selection management
470
class SelectionManager {
471
constructor(view) {
472
this.view = view;
473
this.selectionHistory = [];
474
this.setupSelectionTracking();
475
}
476
477
setupSelectionTracking() {
478
// Track selection changes
479
let lastSelection = this.view.state.selection;
480
481
this.view.dom.addEventListener("selectionchange", () => {
482
const currentSelection = this.view.state.selection;
483
484
if (!currentSelection.eq(lastSelection)) {
485
this.addToHistory(lastSelection);
486
lastSelection = currentSelection;
487
this.onSelectionChange(currentSelection);
488
}
489
});
490
}
491
492
addToHistory(selection) {
493
this.selectionHistory.push(selection);
494
495
// Keep only last 50 selections
496
if (this.selectionHistory.length > 50) {
497
this.selectionHistory.shift();
498
}
499
}
500
501
onSelectionChange(selection) {
502
// Custom logic when selection changes
503
console.log(`Selection changed: ${selection.from} to ${selection.to}`);
504
505
// Update UI indicators
506
this.updateSelectionIndicators(selection);
507
508
// Emit custom event
509
this.view.dom.dispatchEvent(new CustomEvent("editor-selection-change", {
510
detail: { selection }
511
}));
512
}
513
514
updateSelectionIndicators(selection) {
515
// Show selection info in status bar
516
const statusBar = document.querySelector("#status-bar");
517
if (statusBar) {
518
const text = selection.empty
519
? `Position: ${selection.head}`
520
: `Selected: ${selection.from} to ${selection.to} (${selection.to - selection.from} chars)`;
521
522
statusBar.textContent = text;
523
}
524
}
525
526
restorePreviousSelection() {
527
if (this.selectionHistory.length > 0) {
528
const previousSelection = this.selectionHistory.pop();
529
const tr = this.view.state.tr.setSelection(previousSelection);
530
this.view.dispatch(tr);
531
}
532
}
533
534
selectWord(pos) {
535
const doc = this.view.state.doc;
536
const $pos = doc.resolve(pos);
537
538
// Find word boundaries
539
let start = pos;
540
let end = pos;
541
542
const textNode = $pos.parent.child($pos.index());
543
if (textNode && textNode.isText) {
544
const text = textNode.text;
545
const offset = pos - $pos.start();
546
547
// Find start of word
548
while (start > $pos.start() && /\w/.test(text[offset - (pos - start) - 1])) {
549
start--;
550
}
551
552
// Find end of word
553
while (end < $pos.end() && /\w/.test(text[offset + (end - pos)])) {
554
end++;
555
}
556
}
557
558
const selection = TextSelection.create(doc, start, end);
559
this.view.dispatch(this.view.state.tr.setSelection(selection));
560
}
561
562
selectLine(pos) {
563
const doc = this.view.state.doc;
564
const $pos = doc.resolve(pos);
565
566
// Select entire line (paragraph)
567
const start = $pos.start();
568
const end = $pos.end();
569
570
const selection = TextSelection.create(doc, start, end);
571
this.view.dispatch(this.view.state.tr.setSelection(selection));
572
}
573
}
574
575
// Usage
576
const selectionManager = new SelectionManager(view);
577
578
// Keyboard shortcuts for selection
579
view.dom.addEventListener("keydown", (event) => {
580
if (event.ctrlKey && event.key === "w") {
581
// Ctrl+W to select word
582
const pos = view.state.selection.head;
583
selectionManager.selectWord(pos);
584
event.preventDefault();
585
} else if (event.ctrlKey && event.key === "l") {
586
// Ctrl+L to select line
587
const pos = view.state.selection.head;
588
selectionManager.selectLine(pos);
589
event.preventDefault();
590
} else if (event.ctrlKey && event.shiftKey && event.key === "z") {
591
// Ctrl+Shift+Z to restore previous selection
592
selectionManager.restorePreviousSelection();
593
event.preventDefault();
594
}
595
});
596
```
597
598
**Complete Input Handling Example:**
599
600
```typescript
601
import { EditorView } from "prosemirror-view";
602
import { EditorState, TextSelection } from "prosemirror-state";
603
604
class AdvancedInputHandler {
605
constructor(view) {
606
this.view = view;
607
this.setupInputHandling();
608
}
609
610
setupInputHandling() {
611
// Comprehensive input handling setup
612
this.view.setProps({
613
...this.view.props,
614
615
handleKeyDown: this.handleKeyDown.bind(this),
616
handleTextInput: this.handleTextInput.bind(this),
617
handlePaste: this.handlePaste.bind(this),
618
handleDrop: this.handleDrop.bind(this),
619
handleDOMEvents: {
620
compositionstart: this.handleCompositionStart.bind(this),
621
compositionend: this.handleCompositionEnd.bind(this),
622
input: this.handleInput.bind(this)
623
}
624
});
625
}
626
627
handleKeyDown(view, event) {
628
// Custom keyboard shortcuts
629
if (event.ctrlKey || event.metaKey) {
630
switch (event.key) {
631
case "s":
632
this.saveDocument();
633
return true;
634
case "d":
635
this.duplicateLine();
636
return true;
637
case "/":
638
this.toggleComment();
639
return true;
640
}
641
}
642
643
// Auto-completion on Tab
644
if (event.key === "Tab" && !event.shiftKey) {
645
if (this.handleAutoCompletion()) {
646
return true;
647
}
648
}
649
650
return false;
651
}
652
653
handleTextInput(view, from, to, text, deflt) {
654
// Smart quotes
655
if (text === '"') {
656
const beforeText = view.state.doc.textBetween(Math.max(0, from - 1), from);
657
const isOpening = beforeText === "" || /\s/.test(beforeText);
658
659
const tr = view.state.tr.insertText(isOpening ? """ : """, from, to);
660
view.dispatch(tr);
661
return true;
662
}
663
664
// Auto-pairing brackets
665
const pairs = { "(": ")", "[": "]", "{": "}" };
666
if (pairs[text]) {
667
const tr = view.state.tr
668
.insertText(text + pairs[text], from, to)
669
.setSelection(TextSelection.create(view.state.doc, from + 1));
670
view.dispatch(tr);
671
return true;
672
}
673
674
// Markdown shortcuts
675
if (text === " ") {
676
const beforeText = view.state.doc.textBetween(Math.max(0, from - 10), from);
677
678
// Headers
679
const headerMatch = beforeText.match(/^(#{1,6})\s*(.*)$/);
680
if (headerMatch) {
681
const level = headerMatch[1].length;
682
const content = headerMatch[2];
683
684
// Convert to heading
685
const tr = view.state.tr
686
.delete(from - beforeText.length, from)
687
.setBlockType(from - beforeText.length, from,
688
view.state.schema.nodes.heading, { level });
689
690
if (content) {
691
tr.insertText(content);
692
}
693
694
view.dispatch(tr);
695
return true;
696
}
697
}
698
699
return false;
700
}
701
702
handlePaste(view, event, slice) {
703
// Handle special paste formats
704
const html = event.clipboardData?.getData("text/html");
705
706
if (html && html.includes("data-table-source")) {
707
return this.handleTablePaste(html);
708
}
709
710
return false;
711
}
712
713
handleDrop(view, event, slice, moved) {
714
// Handle file drops
715
const files = Array.from(event.dataTransfer?.files || []);
716
717
if (files.length > 0) {
718
this.handleFilesDrop(files, event);
719
return true;
720
}
721
722
return false;
723
}
724
725
handleCompositionStart(view, event) {
726
console.log("Composition started");
727
this.composing = true;
728
return false;
729
}
730
731
handleCompositionEnd(view, event) {
732
console.log("Composition ended");
733
this.composing = false;
734
return false;
735
}
736
737
handleInput(view, event) {
738
if (!this.composing) {
739
// Process input when not composing
740
this.processInput(event);
741
}
742
return false;
743
}
744
745
// Helper methods
746
saveDocument() {
747
const content = this.view.state.doc.toJSON();
748
localStorage.setItem("document", JSON.stringify(content));
749
console.log("Document saved");
750
}
751
752
duplicateLine() {
753
const selection = this.view.state.selection;
754
const $pos = this.view.state.doc.resolve(selection.head);
755
const lineStart = $pos.start();
756
const lineEnd = $pos.end();
757
const lineContent = this.view.state.doc.slice(lineStart, lineEnd);
758
759
const tr = this.view.state.tr.insert(lineEnd, lineContent.content);
760
this.view.dispatch(tr);
761
}
762
763
toggleComment() {
764
// Implementation depends on schema and comment system
765
console.log("Toggle comment");
766
}
767
768
handleAutoCompletion() {
769
// Implementation depends on completion system
770
console.log("Auto-completion triggered");
771
return false;
772
}
773
774
handleTablePaste(html) {
775
// Custom table paste handling
776
console.log("Handling table paste");
777
return true;
778
}
779
780
handleFilesDrop(files, event) {
781
const coords = this.view.posAtCoords({
782
left: event.clientX,
783
top: event.clientY
784
});
785
786
if (coords) {
787
files.forEach(file => {
788
if (file.type.startsWith("image/")) {
789
this.insertImage(file, coords.pos);
790
}
791
});
792
}
793
}
794
795
insertImage(file, pos) {
796
const reader = new FileReader();
797
reader.onload = () => {
798
const img = this.view.state.schema.nodes.image.create({
799
src: reader.result,
800
alt: file.name
801
});
802
803
const tr = this.view.state.tr.insert(pos, img);
804
this.view.dispatch(tr);
805
};
806
reader.readAsDataURL(file);
807
}
808
809
processInput(event) {
810
// Additional input processing
811
console.log("Processing input:", event.inputType, event.data);
812
}
813
}
814
815
// Usage
816
const inputHandler = new AdvancedInputHandler(view);
817
```