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

position-mapping.mddocs/

Position Mapping

Position mapping provides bidirectional conversion between document positions (abstract numerical positions in the ProseMirror document) and DOM coordinates (pixel positions in the browser viewport). This is essential for handling user interactions, managing selections, and implementing features like tooltips and contextual menus.

Capabilities

Document Position to Coordinates

Convert document positions to viewport coordinates.

class EditorView {
  /**
   * Returns the viewport rectangle at a given document position.
   * `left` and `right` will be the same number, as this returns a
   * flat cursor-ish rectangle. If the position is between two things
   * that aren't directly adjacent, `side` determines which element
   * is used. When < 0, the element before the position is used,
   * otherwise the element after.
   */
  coordsAtPos(
    pos: number, 
    side?: number
  ): {left: number, right: number, top: number, bottom: number};
}

Usage Examples:

import { EditorView } from "prosemirror-view";

// Get coordinates at document position
const coords = view.coordsAtPos(15);
console.log(`Position 15 is at: ${coords.left}px, ${coords.top}px`);

// Get coordinates with side preference
const coordsBefore = view.coordsAtPos(15, -1); // Prefer element before
const coordsAfter = view.coordsAtPos(15, 1);   // Prefer element after

// Position a tooltip at a specific document position
function showTooltipAtPosition(view, pos, content) {
  const coords = view.coordsAtPos(pos);
  const tooltip = document.createElement("div");
  tooltip.className = "tooltip";
  tooltip.textContent = content;
  tooltip.style.position = "absolute";
  tooltip.style.left = coords.left + "px";
  tooltip.style.top = (coords.top - 30) + "px";
  document.body.appendChild(tooltip);
}

Coordinates to Document Position

Convert viewport coordinates to document positions.

class EditorView {
  /**
   * Given a pair of viewport coordinates, return the document
   * position that corresponds to them. May return null if the given
   * coordinates aren't inside of the editor. When an object is
   * returned, its `pos` property is the position nearest to the
   * coordinates, and its `inside` property holds the position of the
   * inner node that the position falls inside of, or -1 if it is at
   * the top level, not in any node.
   */
  posAtCoords(coords: {left: number, top: number}): {
    pos: number, 
    inside: number
  } | null;
}

Usage Examples:

// Handle mouse click to get document position
view.dom.addEventListener("click", (event) => {
  const result = view.posAtCoords({
    left: event.clientX,
    top: event.clientY
  });
  
  if (result) {
    console.log(`Clicked at document position: ${result.pos}`);
    console.log(`Inside node at position: ${result.inside}`);
    
    // Create selection at click position
    const tr = view.state.tr.setSelection(
      TextSelection.create(view.state.doc, result.pos)
    );
    view.dispatch(tr);
  }
});

// Implement drag-to-select functionality
let isDragging = false;
let startPos = null;

view.dom.addEventListener("mousedown", (event) => {
  const result = view.posAtCoords({
    left: event.clientX,
    top: event.clientY
  });
  
  if (result) {
    isDragging = true;
    startPos = result.pos;
  }
});

view.dom.addEventListener("mousemove", (event) => {
  if (!isDragging || startPos === null) return;
  
  const result = view.posAtCoords({
    left: event.clientX,
    top: event.clientY
  });
  
  if (result) {
    const selection = TextSelection.create(
      view.state.doc, 
      Math.min(startPos, result.pos),
      Math.max(startPos, result.pos)
    );
    view.dispatch(view.state.tr.setSelection(selection));
  }
});

DOM Position Mapping

Convert between document positions and DOM node/offset pairs.

class EditorView {
  /**
   * Find the DOM position that corresponds to the given document
   * position. When `side` is negative, find the position as close as
   * possible to the content before the position. When positive,
   * prefer positions close to the content after the position. When
   * zero, prefer as shallow a position as possible.
   *
   * Note that you should **not** mutate the editor's internal DOM,
   * only inspect it.
   */
  domAtPos(pos: number, side?: number): {node: DOMNode, offset: number};
  
  /**
   * Find the document position that corresponds to a given DOM
   * position. The `bias` parameter can be used to influence which
   * side of a DOM node to use when the position is inside a leaf node.
   */
  posAtDOM(node: DOMNode, offset: number, bias?: number): number;
}

Usage Examples:

// Get DOM position for document position
const domPos = view.domAtPos(20);
console.log("DOM node:", domPos.node);
console.log("Offset within node:", domPos.offset);

// Create a DOM range at document position
function createRangeAtPosition(view, pos, length) {
  const startDOM = view.domAtPos(pos);
  const endDOM = view.domAtPos(pos + length);
  
  const range = document.createRange();
  range.setStart(startDOM.node, startDOM.offset);
  range.setEnd(endDOM.node, endDOM.offset);
  
  return range;
}

// Convert DOM selection to document position
function getDOMSelectionPos(view) {
  const selection = window.getSelection();
  if (!selection.rangeCount) return null;
  
  const range = selection.getRangeAt(0);
  const startPos = view.posAtDOM(range.startContainer, range.startOffset);
  const endPos = view.posAtDOM(range.endContainer, range.endOffset);
  
  return { from: startPos, to: endPos };
}

// Handle paste at specific DOM position
view.dom.addEventListener("paste", (event) => {
  const selection = window.getSelection();
  if (!selection.rangeCount) return;
  
  const range = selection.getRangeAt(0);
  const pos = view.posAtDOM(range.startContainer, range.startOffset);
  
  // Handle paste at document position `pos`
  const clipboardData = event.clipboardData.getData("text/plain");
  const tr = view.state.tr.insertText(clipboardData, pos);
  view.dispatch(tr);
  
  event.preventDefault();
});

Node DOM Access

Get DOM nodes that represent specific document nodes.

class EditorView {
  /**
   * Find the DOM node that represents the document node after the
   * given position. May return `null` when the position doesn't point
   * in front of a node or if the node is inside an opaque node view.
   *
   * This is intended to be able to call things like
   * `getBoundingClientRect` on that DOM node. Do **not** mutate the
   * editor DOM directly, or add styling this way, since that will be
   * immediately overridden by the editor as it redraws the node.
   */
  nodeDOM(pos: number): DOMNode | null;
}

Usage Examples:

// Get DOM node at position for measurement
const nodeDOM = view.nodeDOM(25);
if (nodeDOM) {
  const rect = nodeDOM.getBoundingClientRect();
  console.log("Node dimensions:", rect.width, "x", rect.height);
  
  // Check if node is visible in viewport
  const isVisible = rect.top >= 0 && 
                   rect.left >= 0 && 
                   rect.bottom <= window.innerHeight && 
                   rect.right <= window.innerWidth;
  
  console.log("Node is visible:", isVisible);
}

// Highlight a specific node temporarily
function highlightNodeAtPosition(view, pos, duration = 2000) {
  const nodeDOM = view.nodeDOM(pos);
  if (!nodeDOM) return;
  
  const originalStyle = nodeDOM.style.cssText;
  nodeDOM.style.outline = "2px solid #007acc";
  nodeDOM.style.outlineOffset = "2px";
  
  setTimeout(() => {
    nodeDOM.style.cssText = originalStyle;
  }, duration);
}

Text Block Navigation

Determine if cursor is at the edge of text blocks.

class EditorView {
  /**
   * Find out whether the selection is at the end of a textblock when
   * moving in a given direction. When, for example, given `"left"`,
   * it will return true if moving left from the current cursor
   * position would leave that position's parent textblock. Will apply
   * to the view's current state by default, but it is possible to
   * pass a different state.
   */
  endOfTextblock(
    dir: "up" | "down" | "left" | "right" | "forward" | "backward", 
    state?: EditorState
  ): boolean;
}

Usage Examples:

// Check if at text block boundaries
const atLeftEdge = view.endOfTextblock("left");
const atRightEdge = view.endOfTextblock("right");
const atTopEdge = view.endOfTextblock("up");
const atBottomEdge = view.endOfTextblock("down");

console.log("At text block edges:", {
  left: atLeftEdge,
  right: atRightEdge,
  top: atTopEdge,
  bottom: atBottomEdge
});

// Custom key handler using textblock detection
function handleArrowKey(view, event) {
  const { key } = event;
  
  if (key === "ArrowLeft" && view.endOfTextblock("left")) {
    // At left edge of text block - custom behavior
    console.log("At left edge of text block");
    
    // Maybe jump to previous block or show navigation
    const selection = view.state.selection;
    const $pos = selection.$from;
    const prevBlock = $pos.nodeBefore;
    
    if (prevBlock) {
      const newPos = $pos.pos - prevBlock.nodeSize;
      const newSelection = TextSelection.create(view.state.doc, newPos);
      view.dispatch(view.state.tr.setSelection(newSelection));
      event.preventDefault();
    }
  }
  
  // Similar handling for other directions...
}

view.dom.addEventListener("keydown", (event) => {
  if (event.key.startsWith("Arrow")) {
    handleArrowKey(view, event);
  }
});

Complete Usage Example:

import { EditorView } from "prosemirror-view";
import { EditorState, TextSelection } from "prosemirror-state";

class PositionTracker {
  constructor(view) {
    this.view = view;
    this.tooltip = this.createTooltip();
    this.setupEventListeners();
  }
  
  createTooltip() {
    const tooltip = document.createElement("div");
    tooltip.className = "position-tooltip";
    tooltip.style.cssText = `
      position: absolute;
      background: #333;
      color: white;
      padding: 4px 8px;
      border-radius: 4px;
      font-size: 12px;
      pointer-events: none;
      z-index: 1000;
      display: none;
    `;
    document.body.appendChild(tooltip);
    return tooltip;
  }
  
  setupEventListeners() {
    // Track mouse position and show document position
    this.view.dom.addEventListener("mousemove", (event) => {
      const result = this.view.posAtCoords({
        left: event.clientX,
        top: event.clientY
      });
      
      if (result) {
        this.tooltip.textContent = `Pos: ${result.pos}, Inside: ${result.inside}`;
        this.tooltip.style.left = (event.clientX + 10) + "px";
        this.tooltip.style.top = (event.clientY - 30) + "px";
        this.tooltip.style.display = "block";
      } else {
        this.tooltip.style.display = "none";
      }
    });
    
    this.view.dom.addEventListener("mouseleave", () => {
      this.tooltip.style.display = "none";
    });
    
    // Track selection changes
    this.view.dom.addEventListener("selectionchange", () => {
      this.logSelectionInfo();
    });
  }
  
  logSelectionInfo() {
    const selection = this.view.state.selection;
    console.log("Selection changed:");
    console.log(`From: ${selection.from}, To: ${selection.to}`);
    
    // Get coordinates of selection endpoints
    const fromCoords = this.view.coordsAtPos(selection.from);
    const toCoords = this.view.coordsAtPos(selection.to);
    
    console.log("From coordinates:", fromCoords);
    console.log("To coordinates:", toCoords);
    
    // Check text block boundaries
    const boundaries = {
      left: this.view.endOfTextblock("left"),
      right: this.view.endOfTextblock("right"),
      up: this.view.endOfTextblock("up"),
      down: this.view.endOfTextblock("down")
    };
    
    console.log("At text block boundaries:", boundaries);
  }
  
  // Utility method to scroll to a document position
  scrollToPosition(pos) {
    const coords = this.view.coordsAtPos(pos);
    window.scrollTo({
      left: coords.left - window.innerWidth / 2,
      top: coords.top - window.innerHeight / 2,
      behavior: "smooth"
    });
  }
  
  // Create a visual indicator at a document position
  showIndicatorAtPosition(pos, text = "•", duration = 3000) {
    const coords = this.view.coordsAtPos(pos);
    const indicator = document.createElement("div");
    
    indicator.textContent = text;
    indicator.style.cssText = `
      position: absolute;
      left: ${coords.left}px;
      top: ${coords.top}px;
      color: red;
      font-weight: bold;
      font-size: 16px;
      pointer-events: none;
      z-index: 1000;
      animation: pulse 1s infinite;
    `;
    
    document.body.appendChild(indicator);
    
    setTimeout(() => {
      document.body.removeChild(indicator);
    }, duration);
  }
  
  destroy() {
    if (this.tooltip.parentNode) {
      this.tooltip.parentNode.removeChild(this.tooltip);
    }
  }
}

// Usage
const view = new EditorView(document.querySelector("#editor"), {
  state: myEditorState
});

const tracker = new PositionTracker(view);

// Example usage of position mapping
tracker.showIndicatorAtPosition(50, "📍");
tracker.scrollToPosition(100);

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