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

custom-views.mddocs/

Custom Views

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.

Capabilities

NodeView Interface

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;
}

NodeView Update Method

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;
  }
}

NodeView Selection Handling

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}`);
  }
}

NodeView Event Handling

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);
  }
}

NodeView Cleanup

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);
  }
}

MarkView Interface

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);
  }
}

Constructor Types

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;

ViewMutationRecord Type

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

docs

core-editor-view.md

custom-views.md

decoration-system.md

editor-props.md

index.md

input-handling.md

position-mapping.md

tile.json