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
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.
Custom node views provide complete control over how document nodes are rendered and behave.
/**
* Objects returned as node views must conform to this interface.
* Node views are used to customize the rendering and behavior of
* specific node types in the editor.
*/
interface NodeView {
/** The outer DOM node that represents the document node */
dom: DOMNode;
/**
* The DOM node that should hold the node's content. Only meaningful
* if the node view also defines a `dom` property and if its node
* type is not a leaf node type. When this is present, ProseMirror
* will take care of rendering the node's children into it.
*/
contentDOM?: HTMLElement | null;
/**
* By default, `update` will only be called when a node of the same
* node type appears in this view's position. When you set this to
* true, it will be called for any node, making it possible to have
* a node view that represents multiple types of nodes.
*/
multiType?: boolean;
}Method called when the node view needs to update to reflect document changes.
interface NodeView {
/**
* When given, this will be called when the view is updating itself.
* It will be given a node, an array of active decorations around the
* node, and a decoration source that represents any decorations that
* apply to the content of the node. It should return true if it was
* able to update to that node, and false otherwise.
*/
update?(
node: Node,
decorations: readonly Decoration[],
innerDecorations: DecorationSource
): boolean;
}Usage Examples:
class ImageNodeView {
constructor(node, view, getPos) {
this.node = node;
this.view = view;
this.getPos = getPos;
// Create DOM structure
this.dom = document.createElement("figure");
this.img = document.createElement("img");
this.img.src = node.attrs.src;
this.img.alt = node.attrs.alt || "";
this.dom.appendChild(this.img);
// Add caption if present
if (node.attrs.caption) {
this.caption = document.createElement("figcaption");
this.caption.textContent = node.attrs.caption;
this.dom.appendChild(this.caption);
}
}
update(node, decorations, innerDecorations) {
// Check if we can handle this node type
if (node.type.name !== "image") return false;
// Update image attributes
this.img.src = node.attrs.src;
this.img.alt = node.attrs.alt || "";
// Update caption
if (node.attrs.caption && !this.caption) {
this.caption = document.createElement("figcaption");
this.dom.appendChild(this.caption);
}
if (this.caption) {
if (node.attrs.caption) {
this.caption.textContent = node.attrs.caption;
} else {
this.dom.removeChild(this.caption);
this.caption = null;
}
}
this.node = node;
return true;
}
}Methods for customizing how node selection is displayed and handled.
interface NodeView {
/**
* Can be used to override the way the node's selected status
* (as a node selection) is displayed.
*/
selectNode?(): void;
/**
* When defining a `selectNode` method, you should also provide a
* `deselectNode` method to remove the effect again.
*/
deselectNode?(): void;
/**
* This will be called to handle setting the selection inside the
* node. The `anchor` and `head` positions are relative to the start
* of the node. By default, a DOM selection will be created between
* the DOM positions corresponding to those positions.
*/
setSelection?(anchor: number, head: number, root: Document | ShadowRoot): void;
}Usage Examples:
class VideoNodeView {
constructor(node, view, getPos) {
this.dom = document.createElement("div");
this.dom.className = "video-wrapper";
this.video = document.createElement("video");
this.video.src = node.attrs.src;
this.video.controls = true;
this.dom.appendChild(this.video);
this.overlay = document.createElement("div");
this.overlay.className = "selection-overlay";
this.overlay.style.display = "none";
this.dom.appendChild(this.overlay);
}
selectNode() {
this.dom.classList.add("ProseMirror-selectednode");
this.overlay.style.display = "block";
}
deselectNode() {
this.dom.classList.remove("ProseMirror-selectednode");
this.overlay.style.display = "none";
}
setSelection(anchor, head, root) {
// For leaf nodes, we typically don't need custom selection handling
// This is more useful for nodes with content
console.log(`Selection set in video: ${anchor} to ${head}`);
}
}Methods for controlling event handling within node views.
interface NodeView {
/**
* Can be used to prevent the editor view from trying to handle some
* or all DOM events that bubble up from the node view. Events for
* which this returns true are not handled by the editor.
*/
stopEvent?(event: Event): boolean;
/**
* Called when a mutation happens within the view. Return false if
* the editor should re-read the selection or re-parse the range
* around the mutation, true if it can safely be ignored.
*/
ignoreMutation?(mutation: ViewMutationRecord): boolean;
}Usage Examples:
class InteractiveChartView {
constructor(node, view, getPos) {
this.dom = document.createElement("div");
this.dom.className = "chart-container";
// Create interactive chart
this.chart = this.createChart(node.attrs.data);
this.dom.appendChild(this.chart);
// Add controls
this.controls = document.createElement("div");
this.controls.className = "chart-controls";
this.addControls(this.controls, node.attrs);
this.dom.appendChild(this.controls);
}
stopEvent(event) {
// Let the chart handle its own mouse and touch events
if (event.type.startsWith("mouse") || event.type.startsWith("touch")) {
return true;
}
// Let controls handle click events
if (event.type === "click" && this.controls.contains(event.target)) {
return true;
}
// Let editor handle other events
return false;
}
ignoreMutation(mutation) {
// Ignore mutations within the chart canvas or controls
return this.chart.contains(mutation.target) ||
this.controls.contains(mutation.target);
}
}Method for cleaning up resources when the node view is removed.
interface NodeView {
/**
* Called when the node view is removed from the editor or the whole
* editor is destroyed. Use this to clean up resources.
*/
destroy?(): void;
}Usage Examples:
class MapNodeView {
constructor(node, view, getPos) {
this.dom = document.createElement("div");
this.mapInstance = new MapLibrary(this.dom, node.attrs);
// Store timer reference for cleanup
this.updateTimer = setInterval(() => {
this.mapInstance.refresh();
}, 5000);
}
destroy() {
// Clean up map instance
if (this.mapInstance) {
this.mapInstance.destroy();
this.mapInstance = null;
}
// Clear timer
if (this.updateTimer) {
clearInterval(this.updateTimer);
this.updateTimer = null;
}
// Remove event listeners
this.dom.removeEventListener("click", this.handleClick);
}
}Custom mark views provide control over how marks are rendered.
/**
* Objects returned as mark views must conform to this interface.
* Mark views are used to customize the rendering of specific mark types.
*/
interface MarkView {
/** The outer DOM node that represents the mark */
dom: DOMNode;
/**
* The DOM node that should hold the mark's content. When this is
* present, ProseMirror will take care of rendering the mark's content.
*/
contentDOM?: HTMLElement | null;
/**
* Called when a mutation happens within the view. Return false if
* the editor should re-read the selection or re-parse the range
* around the mutation, true if it can safely be ignored.
*/
ignoreMutation?(mutation: ViewMutationRecord): boolean;
/**
* Called when the mark view is removed from the editor or the whole
* editor is destroyed.
*/
destroy?(): void;
}Usage Examples:
class CommentMarkView {
constructor(mark, view, inline) {
this.mark = mark;
this.inline = inline;
// Create wrapper element
this.dom = document.createElement("span");
this.dom.className = "comment-mark";
this.dom.style.backgroundColor = mark.attrs.color || "#ffeb3b";
this.dom.style.position = "relative";
// Create content container
this.contentDOM = document.createElement("span");
this.dom.appendChild(this.contentDOM);
// Add comment indicator
this.indicator = document.createElement("span");
this.indicator.className = "comment-indicator";
this.indicator.textContent = "💬";
this.indicator.title = mark.attrs.comment;
this.dom.appendChild(this.indicator);
}
destroy() {
// Clean up any listeners or resources
if (this.indicator) {
this.indicator.removeEventListener("click", this.handleClick);
}
}
}
class LinkMarkView {
constructor(mark, view, inline) {
this.dom = document.createElement("a");
this.dom.href = mark.attrs.href;
this.dom.title = mark.attrs.title || "";
this.dom.target = mark.attrs.target || "_blank";
this.dom.rel = "noopener noreferrer";
// Content goes directly in the link
this.contentDOM = this.dom;
// Add click tracking
this.dom.addEventListener("click", this.handleClick.bind(this));
}
handleClick(event) {
// Custom link handling
console.log("Link clicked:", this.dom.href);
// Allow default behavior
}
ignoreMutation(mutation) {
// Ignore attribute changes to the link element
return mutation.type === "attributes" && mutation.target === this.dom;
}
destroy() {
this.dom.removeEventListener("click", this.handleClick);
}
}Type definitions for view constructors.
/**
* The type of function provided to create node views.
*/
type NodeViewConstructor = (
node: Node,
view: EditorView,
getPos: () => number | undefined,
decorations: readonly Decoration[],
innerDecorations: DecorationSource
) => NodeView;
/**
* The function types used to create mark views.
*/
type MarkViewConstructor = (
mark: Mark,
view: EditorView,
inline: boolean
) => MarkView;Type definition for mutation records in views.
/**
* A ViewMutationRecord represents a DOM mutation or a selection change
* that happens within the view. When the change is a selection change,
* the record will have a `type` property of "selection".
*/
type ViewMutationRecord = MutationRecord | {
type: "selection",
target: DOMNode
};Complete Usage Example:
import { EditorView, NodeView, MarkView } from "prosemirror-view";
import { Schema } from "prosemirror-model";
// Define schema with custom nodes and marks
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: {
content: "inline*",
group: "block",
toDOM: () => ["p", 0]
},
text: { group: "inline" },
todo_item: {
content: "paragraph",
group: "block",
attrs: { checked: { default: false } },
toDOM: (node) => ["div", { class: "todo-item" }, 0]
}
},
marks: {
highlight: {
attrs: { color: { default: "yellow" } },
toDOM: (mark) => ["span", {
style: `background-color: ${mark.attrs.color}`
}, 0]
}
}
});
// Custom node view for todo items
class TodoItemView {
constructor(node, view, getPos) {
this.node = node;
this.view = view;
this.getPos = getPos;
// Create DOM structure
this.dom = document.createElement("div");
this.dom.className = "todo-item-wrapper";
// Checkbox
this.checkbox = document.createElement("input");
this.checkbox.type = "checkbox";
this.checkbox.checked = node.attrs.checked;
this.checkbox.addEventListener("change", this.handleChange.bind(this));
this.dom.appendChild(this.checkbox);
// Content container
this.contentDOM = document.createElement("div");
this.contentDOM.className = "todo-content";
this.dom.appendChild(this.contentDOM);
// Update visual state
this.updateCheckedState();
}
handleChange() {
const pos = this.getPos();
if (pos === undefined) return;
const tr = this.view.state.tr.setNodeMarkup(pos, null, {
...this.node.attrs,
checked: this.checkbox.checked
});
this.view.dispatch(tr);
}
update(node) {
if (node.type.name !== "todo_item") return false;
this.node = node;
this.checkbox.checked = node.attrs.checked;
this.updateCheckedState();
return true;
}
updateCheckedState() {
if (this.node.attrs.checked) {
this.dom.classList.add("checked");
this.contentDOM.style.textDecoration = "line-through";
this.contentDOM.style.opacity = "0.6";
} else {
this.dom.classList.remove("checked");
this.contentDOM.style.textDecoration = "none";
this.contentDOM.style.opacity = "1";
}
}
stopEvent(event) {
return event.target === this.checkbox;
}
destroy() {
this.checkbox.removeEventListener("change", this.handleChange);
}
}
// Custom mark view for highlights
class HighlightMarkView {
constructor(mark, view, inline) {
this.dom = document.createElement("span");
this.dom.className = "highlight-mark";
this.dom.style.backgroundColor = mark.attrs.color;
this.dom.style.position = "relative";
this.contentDOM = document.createElement("span");
this.dom.appendChild(this.contentDOM);
// Add color picker for editing
this.colorPicker = document.createElement("input");
this.colorPicker.type = "color";
this.colorPicker.value = this.colorToHex(mark.attrs.color);
this.colorPicker.className = "color-picker";
this.colorPicker.style.position = "absolute";
this.colorPicker.style.top = "-25px";
this.colorPicker.style.display = "none";
this.dom.appendChild(this.colorPicker);
// Show/hide color picker on hover
this.dom.addEventListener("mouseenter", () => {
this.colorPicker.style.display = "block";
});
this.dom.addEventListener("mouseleave", () => {
this.colorPicker.style.display = "none";
});
}
colorToHex(color) {
// Simple color name to hex conversion
const colors = { yellow: "#ffff00", green: "#00ff00", blue: "#0000ff" };
return colors[color] || color;
}
destroy() {
this.dom.removeEventListener("mouseenter", this.showPicker);
this.dom.removeEventListener("mouseleave", this.hidePicker);
}
}
// Create editor with custom views
const view = new EditorView(document.querySelector("#editor"), {
state: EditorState.create({
schema,
doc: schema.node("doc", null, [
schema.node("todo_item", { checked: false }, [
schema.node("paragraph", null, [
schema.text("Buy groceries")
])
]),
schema.node("todo_item", { checked: true }, [
schema.node("paragraph", null, [
schema.text("Walk the dog")
])
])
])
}),
nodeViews: {
todo_item: (node, view, getPos, decorations, innerDecorations) =>
new TodoItemView(node, view, getPos)
},
markViews: {
highlight: (mark, view, inline) => new HighlightMarkView(mark, view, inline)
}
});Install with Tessl CLI
npx tessl i tessl/npm-prosemirror-view