0
# Custom Views
1
2
Custom views allow you to define how specific nodes and marks are rendered in the editor, providing full control over their DOM representation and behavior. This enables rich interactive elements, custom widgets, and specialized rendering that goes beyond the default toDOM specifications.
3
4
## Capabilities
5
6
### NodeView Interface
7
8
Custom node views provide complete control over how document nodes are rendered and behave.
9
10
```typescript { .api }
11
/**
12
* Objects returned as node views must conform to this interface.
13
* Node views are used to customize the rendering and behavior of
14
* specific node types in the editor.
15
*/
16
interface NodeView {
17
/** The outer DOM node that represents the document node */
18
dom: DOMNode;
19
20
/**
21
* The DOM node that should hold the node's content. Only meaningful
22
* if the node view also defines a `dom` property and if its node
23
* type is not a leaf node type. When this is present, ProseMirror
24
* will take care of rendering the node's children into it.
25
*/
26
contentDOM?: HTMLElement | null;
27
28
/**
29
* By default, `update` will only be called when a node of the same
30
* node type appears in this view's position. When you set this to
31
* true, it will be called for any node, making it possible to have
32
* a node view that represents multiple types of nodes.
33
*/
34
multiType?: boolean;
35
}
36
```
37
38
### NodeView Update Method
39
40
Method called when the node view needs to update to reflect document changes.
41
42
```typescript { .api }
43
interface NodeView {
44
/**
45
* When given, this will be called when the view is updating itself.
46
* It will be given a node, an array of active decorations around the
47
* node, and a decoration source that represents any decorations that
48
* apply to the content of the node. It should return true if it was
49
* able to update to that node, and false otherwise.
50
*/
51
update?(
52
node: Node,
53
decorations: readonly Decoration[],
54
innerDecorations: DecorationSource
55
): boolean;
56
}
57
```
58
59
**Usage Examples:**
60
61
```typescript
62
class ImageNodeView {
63
constructor(node, view, getPos) {
64
this.node = node;
65
this.view = view;
66
this.getPos = getPos;
67
68
// Create DOM structure
69
this.dom = document.createElement("figure");
70
this.img = document.createElement("img");
71
this.img.src = node.attrs.src;
72
this.img.alt = node.attrs.alt || "";
73
this.dom.appendChild(this.img);
74
75
// Add caption if present
76
if (node.attrs.caption) {
77
this.caption = document.createElement("figcaption");
78
this.caption.textContent = node.attrs.caption;
79
this.dom.appendChild(this.caption);
80
}
81
}
82
83
update(node, decorations, innerDecorations) {
84
// Check if we can handle this node type
85
if (node.type.name !== "image") return false;
86
87
// Update image attributes
88
this.img.src = node.attrs.src;
89
this.img.alt = node.attrs.alt || "";
90
91
// Update caption
92
if (node.attrs.caption && !this.caption) {
93
this.caption = document.createElement("figcaption");
94
this.dom.appendChild(this.caption);
95
}
96
97
if (this.caption) {
98
if (node.attrs.caption) {
99
this.caption.textContent = node.attrs.caption;
100
} else {
101
this.dom.removeChild(this.caption);
102
this.caption = null;
103
}
104
}
105
106
this.node = node;
107
return true;
108
}
109
}
110
```
111
112
### NodeView Selection Handling
113
114
Methods for customizing how node selection is displayed and handled.
115
116
```typescript { .api }
117
interface NodeView {
118
/**
119
* Can be used to override the way the node's selected status
120
* (as a node selection) is displayed.
121
*/
122
selectNode?(): void;
123
124
/**
125
* When defining a `selectNode` method, you should also provide a
126
* `deselectNode` method to remove the effect again.
127
*/
128
deselectNode?(): void;
129
130
/**
131
* This will be called to handle setting the selection inside the
132
* node. The `anchor` and `head` positions are relative to the start
133
* of the node. By default, a DOM selection will be created between
134
* the DOM positions corresponding to those positions.
135
*/
136
setSelection?(anchor: number, head: number, root: Document | ShadowRoot): void;
137
}
138
```
139
140
**Usage Examples:**
141
142
```typescript
143
class VideoNodeView {
144
constructor(node, view, getPos) {
145
this.dom = document.createElement("div");
146
this.dom.className = "video-wrapper";
147
148
this.video = document.createElement("video");
149
this.video.src = node.attrs.src;
150
this.video.controls = true;
151
this.dom.appendChild(this.video);
152
153
this.overlay = document.createElement("div");
154
this.overlay.className = "selection-overlay";
155
this.overlay.style.display = "none";
156
this.dom.appendChild(this.overlay);
157
}
158
159
selectNode() {
160
this.dom.classList.add("ProseMirror-selectednode");
161
this.overlay.style.display = "block";
162
}
163
164
deselectNode() {
165
this.dom.classList.remove("ProseMirror-selectednode");
166
this.overlay.style.display = "none";
167
}
168
169
setSelection(anchor, head, root) {
170
// For leaf nodes, we typically don't need custom selection handling
171
// This is more useful for nodes with content
172
console.log(`Selection set in video: ${anchor} to ${head}`);
173
}
174
}
175
```
176
177
### NodeView Event Handling
178
179
Methods for controlling event handling within node views.
180
181
```typescript { .api }
182
interface NodeView {
183
/**
184
* Can be used to prevent the editor view from trying to handle some
185
* or all DOM events that bubble up from the node view. Events for
186
* which this returns true are not handled by the editor.
187
*/
188
stopEvent?(event: Event): boolean;
189
190
/**
191
* Called when a mutation happens within the view. Return false if
192
* the editor should re-read the selection or re-parse the range
193
* around the mutation, true if it can safely be ignored.
194
*/
195
ignoreMutation?(mutation: ViewMutationRecord): boolean;
196
}
197
```
198
199
**Usage Examples:**
200
201
```typescript
202
class InteractiveChartView {
203
constructor(node, view, getPos) {
204
this.dom = document.createElement("div");
205
this.dom.className = "chart-container";
206
207
// Create interactive chart
208
this.chart = this.createChart(node.attrs.data);
209
this.dom.appendChild(this.chart);
210
211
// Add controls
212
this.controls = document.createElement("div");
213
this.controls.className = "chart-controls";
214
this.addControls(this.controls, node.attrs);
215
this.dom.appendChild(this.controls);
216
}
217
218
stopEvent(event) {
219
// Let the chart handle its own mouse and touch events
220
if (event.type.startsWith("mouse") || event.type.startsWith("touch")) {
221
return true;
222
}
223
224
// Let controls handle click events
225
if (event.type === "click" && this.controls.contains(event.target)) {
226
return true;
227
}
228
229
// Let editor handle other events
230
return false;
231
}
232
233
ignoreMutation(mutation) {
234
// Ignore mutations within the chart canvas or controls
235
return this.chart.contains(mutation.target) ||
236
this.controls.contains(mutation.target);
237
}
238
}
239
```
240
241
### NodeView Cleanup
242
243
Method for cleaning up resources when the node view is removed.
244
245
```typescript { .api }
246
interface NodeView {
247
/**
248
* Called when the node view is removed from the editor or the whole
249
* editor is destroyed. Use this to clean up resources.
250
*/
251
destroy?(): void;
252
}
253
```
254
255
**Usage Examples:**
256
257
```typescript
258
class MapNodeView {
259
constructor(node, view, getPos) {
260
this.dom = document.createElement("div");
261
this.mapInstance = new MapLibrary(this.dom, node.attrs);
262
263
// Store timer reference for cleanup
264
this.updateTimer = setInterval(() => {
265
this.mapInstance.refresh();
266
}, 5000);
267
}
268
269
destroy() {
270
// Clean up map instance
271
if (this.mapInstance) {
272
this.mapInstance.destroy();
273
this.mapInstance = null;
274
}
275
276
// Clear timer
277
if (this.updateTimer) {
278
clearInterval(this.updateTimer);
279
this.updateTimer = null;
280
}
281
282
// Remove event listeners
283
this.dom.removeEventListener("click", this.handleClick);
284
}
285
}
286
```
287
288
### MarkView Interface
289
290
Custom mark views provide control over how marks are rendered.
291
292
```typescript { .api }
293
/**
294
* Objects returned as mark views must conform to this interface.
295
* Mark views are used to customize the rendering of specific mark types.
296
*/
297
interface MarkView {
298
/** The outer DOM node that represents the mark */
299
dom: DOMNode;
300
301
/**
302
* The DOM node that should hold the mark's content. When this is
303
* present, ProseMirror will take care of rendering the mark's content.
304
*/
305
contentDOM?: HTMLElement | null;
306
307
/**
308
* Called when a mutation happens within the view. Return false if
309
* the editor should re-read the selection or re-parse the range
310
* around the mutation, true if it can safely be ignored.
311
*/
312
ignoreMutation?(mutation: ViewMutationRecord): boolean;
313
314
/**
315
* Called when the mark view is removed from the editor or the whole
316
* editor is destroyed.
317
*/
318
destroy?(): void;
319
}
320
```
321
322
**Usage Examples:**
323
324
```typescript
325
class CommentMarkView {
326
constructor(mark, view, inline) {
327
this.mark = mark;
328
this.inline = inline;
329
330
// Create wrapper element
331
this.dom = document.createElement("span");
332
this.dom.className = "comment-mark";
333
this.dom.style.backgroundColor = mark.attrs.color || "#ffeb3b";
334
this.dom.style.position = "relative";
335
336
// Create content container
337
this.contentDOM = document.createElement("span");
338
this.dom.appendChild(this.contentDOM);
339
340
// Add comment indicator
341
this.indicator = document.createElement("span");
342
this.indicator.className = "comment-indicator";
343
this.indicator.textContent = "💬";
344
this.indicator.title = mark.attrs.comment;
345
this.dom.appendChild(this.indicator);
346
}
347
348
destroy() {
349
// Clean up any listeners or resources
350
if (this.indicator) {
351
this.indicator.removeEventListener("click", this.handleClick);
352
}
353
}
354
}
355
356
class LinkMarkView {
357
constructor(mark, view, inline) {
358
this.dom = document.createElement("a");
359
this.dom.href = mark.attrs.href;
360
this.dom.title = mark.attrs.title || "";
361
this.dom.target = mark.attrs.target || "_blank";
362
this.dom.rel = "noopener noreferrer";
363
364
// Content goes directly in the link
365
this.contentDOM = this.dom;
366
367
// Add click tracking
368
this.dom.addEventListener("click", this.handleClick.bind(this));
369
}
370
371
handleClick(event) {
372
// Custom link handling
373
console.log("Link clicked:", this.dom.href);
374
// Allow default behavior
375
}
376
377
ignoreMutation(mutation) {
378
// Ignore attribute changes to the link element
379
return mutation.type === "attributes" && mutation.target === this.dom;
380
}
381
382
destroy() {
383
this.dom.removeEventListener("click", this.handleClick);
384
}
385
}
386
```
387
388
### Constructor Types
389
390
Type definitions for view constructors.
391
392
```typescript { .api }
393
/**
394
* The type of function provided to create node views.
395
*/
396
type NodeViewConstructor = (
397
node: Node,
398
view: EditorView,
399
getPos: () => number | undefined,
400
decorations: readonly Decoration[],
401
innerDecorations: DecorationSource
402
) => NodeView;
403
404
/**
405
* The function types used to create mark views.
406
*/
407
type MarkViewConstructor = (
408
mark: Mark,
409
view: EditorView,
410
inline: boolean
411
) => MarkView;
412
```
413
414
### ViewMutationRecord Type
415
416
Type definition for mutation records in views.
417
418
```typescript { .api }
419
/**
420
* A ViewMutationRecord represents a DOM mutation or a selection change
421
* that happens within the view. When the change is a selection change,
422
* the record will have a `type` property of "selection".
423
*/
424
type ViewMutationRecord = MutationRecord | {
425
type: "selection",
426
target: DOMNode
427
};
428
```
429
430
**Complete Usage Example:**
431
432
```typescript
433
import { EditorView, NodeView, MarkView } from "prosemirror-view";
434
import { Schema } from "prosemirror-model";
435
436
// Define schema with custom nodes and marks
437
const schema = new Schema({
438
nodes: {
439
doc: { content: "block+" },
440
paragraph: {
441
content: "inline*",
442
group: "block",
443
toDOM: () => ["p", 0]
444
},
445
text: { group: "inline" },
446
todo_item: {
447
content: "paragraph",
448
group: "block",
449
attrs: { checked: { default: false } },
450
toDOM: (node) => ["div", { class: "todo-item" }, 0]
451
}
452
},
453
marks: {
454
highlight: {
455
attrs: { color: { default: "yellow" } },
456
toDOM: (mark) => ["span", {
457
style: `background-color: ${mark.attrs.color}`
458
}, 0]
459
}
460
}
461
});
462
463
// Custom node view for todo items
464
class TodoItemView {
465
constructor(node, view, getPos) {
466
this.node = node;
467
this.view = view;
468
this.getPos = getPos;
469
470
// Create DOM structure
471
this.dom = document.createElement("div");
472
this.dom.className = "todo-item-wrapper";
473
474
// Checkbox
475
this.checkbox = document.createElement("input");
476
this.checkbox.type = "checkbox";
477
this.checkbox.checked = node.attrs.checked;
478
this.checkbox.addEventListener("change", this.handleChange.bind(this));
479
this.dom.appendChild(this.checkbox);
480
481
// Content container
482
this.contentDOM = document.createElement("div");
483
this.contentDOM.className = "todo-content";
484
this.dom.appendChild(this.contentDOM);
485
486
// Update visual state
487
this.updateCheckedState();
488
}
489
490
handleChange() {
491
const pos = this.getPos();
492
if (pos === undefined) return;
493
494
const tr = this.view.state.tr.setNodeMarkup(pos, null, {
495
...this.node.attrs,
496
checked: this.checkbox.checked
497
});
498
499
this.view.dispatch(tr);
500
}
501
502
update(node) {
503
if (node.type.name !== "todo_item") return false;
504
505
this.node = node;
506
this.checkbox.checked = node.attrs.checked;
507
this.updateCheckedState();
508
return true;
509
}
510
511
updateCheckedState() {
512
if (this.node.attrs.checked) {
513
this.dom.classList.add("checked");
514
this.contentDOM.style.textDecoration = "line-through";
515
this.contentDOM.style.opacity = "0.6";
516
} else {
517
this.dom.classList.remove("checked");
518
this.contentDOM.style.textDecoration = "none";
519
this.contentDOM.style.opacity = "1";
520
}
521
}
522
523
stopEvent(event) {
524
return event.target === this.checkbox;
525
}
526
527
destroy() {
528
this.checkbox.removeEventListener("change", this.handleChange);
529
}
530
}
531
532
// Custom mark view for highlights
533
class HighlightMarkView {
534
constructor(mark, view, inline) {
535
this.dom = document.createElement("span");
536
this.dom.className = "highlight-mark";
537
this.dom.style.backgroundColor = mark.attrs.color;
538
this.dom.style.position = "relative";
539
540
this.contentDOM = document.createElement("span");
541
this.dom.appendChild(this.contentDOM);
542
543
// Add color picker for editing
544
this.colorPicker = document.createElement("input");
545
this.colorPicker.type = "color";
546
this.colorPicker.value = this.colorToHex(mark.attrs.color);
547
this.colorPicker.className = "color-picker";
548
this.colorPicker.style.position = "absolute";
549
this.colorPicker.style.top = "-25px";
550
this.colorPicker.style.display = "none";
551
this.dom.appendChild(this.colorPicker);
552
553
// Show/hide color picker on hover
554
this.dom.addEventListener("mouseenter", () => {
555
this.colorPicker.style.display = "block";
556
});
557
558
this.dom.addEventListener("mouseleave", () => {
559
this.colorPicker.style.display = "none";
560
});
561
}
562
563
colorToHex(color) {
564
// Simple color name to hex conversion
565
const colors = { yellow: "#ffff00", green: "#00ff00", blue: "#0000ff" };
566
return colors[color] || color;
567
}
568
569
destroy() {
570
this.dom.removeEventListener("mouseenter", this.showPicker);
571
this.dom.removeEventListener("mouseleave", this.hidePicker);
572
}
573
}
574
575
// Create editor with custom views
576
const view = new EditorView(document.querySelector("#editor"), {
577
state: EditorState.create({
578
schema,
579
doc: schema.node("doc", null, [
580
schema.node("todo_item", { checked: false }, [
581
schema.node("paragraph", null, [
582
schema.text("Buy groceries")
583
])
584
]),
585
schema.node("todo_item", { checked: true }, [
586
schema.node("paragraph", null, [
587
schema.text("Walk the dog")
588
])
589
])
590
])
591
}),
592
nodeViews: {
593
todo_item: (node, view, getPos, decorations, innerDecorations) =>
594
new TodoItemView(node, view, getPos)
595
},
596
markViews: {
597
highlight: (mark, view, inline) => new HighlightMarkView(mark, view, inline)
598
}
599
});
600
```