CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-tiptap--pm

Comprehensive wrapper around ProseMirror packages providing unified entry point for rich text editing functionality in Tiptap framework

Pending
Overview
Eval results
Files

cursors-and-enhancements.mddocs/

Cursors and Enhancements

Specialized cursor plugins and document enhancements provide improved editing experiences. These include gap cursor for positioning between block nodes, drop cursor for drag operations, trailing node enforcement, and change tracking capabilities.

Capabilities

Gap Cursor

Allows cursor positioning between block elements where normal text selection isn't possible.

/**
 * Gap cursor selection type for positioning between blocks
 */
class GapCursor extends Selection {
  /**
   * Create a gap cursor at the given position
   */
  constructor(pos: ResolvedPos, side: -1 | 1);
  
  /**
   * Position of the gap cursor
   */
  $pos: ResolvedPos;
  
  /**
   * Side of the gap (-1 for before, 1 for after)
   */
  side: -1 | 1;
  
  /**
   * Check if gap cursor is valid at position
   */
  static valid($pos: ResolvedPos): boolean;
  
  /**
   * Find gap cursor near position
   */
  static findGapCursorFrom($pos: ResolvedPos, dir: -1 | 1, mustMove?: boolean): GapCursor | null;
}

/**
 * Create gap cursor plugin
 */
function gapCursor(): Plugin;

Drop Cursor

Visual indicator showing where content will be dropped during drag operations.

/**
 * Create drop cursor plugin
 */
function dropCursor(options?: DropCursorOptions): Plugin;

/**
 * Drop cursor configuration options
 */
interface DropCursorOptions {
  /**
   * Color of the drop cursor (default: black)
   */
  color?: string;
  
  /**
   * Width of the drop cursor line (default: 1px)
   */
  width?: number;
  
  /**
   * CSS class for the drop cursor
   */
  class?: string;
}

Trailing Node

Ensures documents always end with a specific node type, typically a paragraph.

/**
 * Create trailing node plugin
 */
function trailingNode(options: TrailingNodeOptions): Plugin;

/**
 * Trailing node configuration options
 */
interface TrailingNodeOptions {
  /**
   * Node type to ensure at document end
   */
  node: string | NodeType;
  
  /**
   * Node types that should not be at document end
   */
  notAfter?: (string | NodeType)[];
}

Change Tracking

Track and visualize document changes with detailed metadata.

/**
 * Represents a span of changed content
 */
class Span {
  /**
   * Create a change span
   */
  constructor(from: number, to: number, data?: any);
  
  /**
   * Start position
   */
  from: number;
  
  /**
   * End position  
   */
  to: number;
  
  /**
   * Associated metadata
   */
  data: any;
}

/**
 * Represents a specific change with content
 */
class Change {
  /**
   * Create a change
   */
  constructor(from: number, to: number, inserted: Fragment, data?: any);
  
  /**
   * Start position of change
   */
  from: number;
  
  /**
   * End position of change
   */
  to: number;
  
  /**
   * Inserted content
   */
  inserted: Fragment;
  
  /**
   * Change metadata
   */
  data: any;
}

/**
 * Tracks all changes in a document
 */
class ChangeSet {
  /**
   * Create change set from document comparison
   */
  static create(doc: Node, changes?: Change[]): ChangeSet;
  
  /**
   * Array of changes
   */
  changes: Change[];
  
  /**
   * Add a change to the set
   */
  addChange(change: Change): ChangeSet;
  
  /**
   * Map change set through transformation
   */
  map(mapping: Mappable): ChangeSet;
  
  /**
   * Simplify changes for presentation
   */
  simplify(): ChangeSet;
}

/**
 * Simplify changes by merging adjacent changes
 */
function simplifyChanges(changes: Change[], doc: Node): Change[];

Usage Examples:

import { 
  GapCursor, 
  gapCursor,
  dropCursor,
  trailingNode,
  ChangeSet,
  simplifyChanges 
} from "@tiptap/pm/gapcursor";
import "@tiptap/pm/dropcursor";
import "@tiptap/pm/trailing-node";
import "@tiptap/pm/changeset";

// Basic cursor enhancements setup
const enhancementPlugins = [
  // Gap cursor for block navigation
  gapCursor(),
  
  // Drop cursor for drag operations
  dropCursor({
    color: "#3b82f6",
    width: 2,
    class: "custom-drop-cursor"
  }),
  
  // Ensure document ends with paragraph
  trailingNode({
    node: "paragraph",
    notAfter: ["heading", "code_block"]
  })
];

// Create editor with enhancements
const state = EditorState.create({
  schema: mySchema,
  plugins: enhancementPlugins
});

// Custom gap cursor handling
class GapCursorManager {
  constructor(private view: EditorView) {
    this.setupKeyboardNavigation();
  }
  
  private setupKeyboardNavigation() {
    const plugin = keymap({
      "ArrowUp": this.navigateUp.bind(this),
      "ArrowDown": this.navigateDown.bind(this),
      "ArrowLeft": this.navigateLeft.bind(this),
      "ArrowRight": this.navigateRight.bind(this)
    });
    
    const newState = this.view.state.reconfigure({
      plugins: this.view.state.plugins.concat(plugin)
    });
    this.view.updateState(newState);
  }
  
  private navigateUp(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
    return this.navigateVertically(state, dispatch, -1);
  }
  
  private navigateDown(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
    return this.navigateVertically(state, dispatch, 1);
  }
  
  private navigateVertically(
    state: EditorState, 
    dispatch?: (tr: Transaction) => void, 
    dir: -1 | 1
  ): boolean {
    const { selection } = state;
    
    if (selection instanceof GapCursor) {
      // Find next gap cursor position
      const nextGap = GapCursor.findGapCursorFrom(selection.$pos, dir, true);
      if (nextGap && dispatch) {
        dispatch(state.tr.setSelection(nextGap));
        return true;
      }
    }
    
    return false;
  }
  
  private navigateLeft(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
    return this.navigateHorizontally(state, dispatch, -1);
  }
  
  private navigateRight(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
    return this.navigateHorizontally(state, dispatch, 1);
  }
  
  private navigateHorizontally(
    state: EditorState,
    dispatch?: (tr: Transaction) => void,
    dir: -1 | 1
  ): boolean {
    const { selection } = state;
    
    // Try to find gap cursor from current selection
    const $pos = dir === -1 ? selection.$from : selection.$to;
    const gap = GapCursor.findGapCursorFrom($pos, dir, false);
    
    if (gap && dispatch) {
      dispatch(state.tr.setSelection(gap));
      return true;
    }
    
    return false;
  }
}

// Enhanced drop cursor with custom behavior
class EnhancedDropCursor {
  private plugin: Plugin;
  
  constructor(options?: DropCursorOptions & { 
    onDrop?: (pos: number, data: any) => void;
    canDrop?: (pos: number, data: any) => boolean;
  }) {
    this.plugin = new Plugin({
      state: {
        init: () => null,
        apply: (tr, value) => {
          const meta = tr.getMeta("drop-cursor");
          if (meta !== undefined) {
            return meta;
          }
          return value;
        }
      },
      
      props: {
        decorations: (state) => {
          const dropPos = this.plugin.getState(state);
          if (dropPos) {
            return this.createDropDecoration(dropPos, options);
          }
          return null;
        },
        
        handleDrop: (view, event, slice, moved) => {
          if (options?.onDrop) {
            const pos = view.posAtCoords({
              left: event.clientX,
              top: event.clientY
            });
            
            if (pos && (!options.canDrop || options.canDrop(pos.pos, slice))) {
              options.onDrop(pos.pos, slice);
              return true;
            }
          }
          return false;
        },
        
        handleDOMEvents: {
          dragover: (view, event) => {
            const pos = view.posAtCoords({
              left: event.clientX,
              top: event.clientY
            });
            
            if (pos) {
              view.dispatch(
                view.state.tr.setMeta("drop-cursor", pos.pos)
              );
            }
            
            return false;
          },
          
          dragleave: (view) => {
            view.dispatch(
              view.state.tr.setMeta("drop-cursor", null)
            );
            return false;
          }
        }
      }
    });
  }
  
  private createDropDecoration(pos: number, options?: DropCursorOptions): DecorationSet {
    const widget = document.createElement("div");
    widget.className = `drop-cursor ${options?.class || ""}`;
    widget.style.position = "absolute";
    widget.style.width = `${options?.width || 1}px`;
    widget.style.backgroundColor = options?.color || "black";
    widget.style.height = "1.2em";
    widget.style.pointerEvents = "none";
    
    return DecorationSet.create(document, [
      Decoration.widget(pos, widget, { side: 1 })
    ]);
  }
  
  getPlugin(): Plugin {
    return this.plugin;
  }
}

// Advanced trailing node with custom rules
class SmartTrailingNode {
  constructor(options: {
    trailingNode: NodeType;
    rules?: Array<{
      after: NodeType[];
      insert: NodeType;
      attrs?: Attrs;
    }>;
  }) {
    this.setupPlugin(options);
  }
  
  private setupPlugin(options: any) {
    const plugin = new Plugin({
      appendTransaction: (transactions, oldState, newState) => {
        const lastTransaction = transactions[transactions.length - 1];
        if (!lastTransaction?.docChanged) return null;
        
        return this.ensureTrailingNode(newState, options);
      }
    });
    
    // Add plugin to existing state
    // Implementation depends on specific usage
  }
  
  private ensureTrailingNode(state: EditorState, options: any): Transaction | null {
    const { doc } = state;
    const lastChild = doc.lastChild;
    
    if (!lastChild) {
      // Empty document - add trailing node
      const tr = state.tr;
      const trailingNode = options.trailingNode.createAndFill();
      tr.insert(doc.content.size, trailingNode);
      return tr;
    }
    
    // Check custom rules
    if (options.rules) {
      for (const rule of options.rules) {
        if (rule.after.includes(lastChild.type)) {
          const tr = state.tr;
          const insertNode = rule.insert.create(rule.attrs);
          tr.insert(doc.content.size, insertNode);
          return tr;
        }
      }
    }
    
    // Default trailing node check
    if (lastChild.type !== options.trailingNode) {
      const tr = state.tr;
      const trailingNode = options.trailingNode.createAndFill();
      tr.insert(doc.content.size, trailingNode);
      return tr;
    }
    
    return null;
  }
}

Advanced Enhancement Features

Custom Selection Types

Create specialized selection types beyond gap cursor.

class BlockSelection extends Selection {
  constructor($pos: ResolvedPos) {
    super($pos, $pos);
  }
  
  static create(doc: Node, pos: number): BlockSelection {
    const $pos = doc.resolve(pos);
    return new BlockSelection($pos);
  }
  
  map(doc: Node, mapping: Mappable): Selection {
    const newPos = mapping.map(this.from);
    return BlockSelection.create(doc, newPos);
  }
  
  eq(other: Selection): boolean {
    return other instanceof BlockSelection && other.from === this.from;
  }
  
  getBookmark(): SelectionBookmark {
    return new BlockBookmark(this.from);
  }
}

class BlockBookmark implements SelectionBookmark {
  constructor(private pos: number) {}
  
  map(mapping: Mappable): SelectionBookmark {
    return new BlockBookmark(mapping.map(this.pos));
  }
  
  resolve(doc: Node): Selection {
    return BlockSelection.create(doc, this.pos);
  }
}

Visual Enhancement Plugins

Create plugins that add visual improvements without affecting document structure.

class VisualEnhancementPlugin {
  static createReadingGuide(): Plugin {
    return new Plugin({
      state: {
        init: () => null,
        apply: (tr, value) => {
          const meta = tr.getMeta("reading-guide");
          if (meta !== undefined) return meta;
          return value;
        }
      },
      
      props: {
        decorations: (state) => {
          const linePos = this.getState(state);
          if (linePos) {
            return this.createReadingGuide(linePos);
          }
          return null;
        },
        
        handleDOMEvents: {
          mousemove: (view, event) => {
            const pos = view.posAtCoords({
              left: event.clientX,
              top: event.clientY
            });
            
            if (pos) {
              view.dispatch(
                view.state.tr.setMeta("reading-guide", pos.pos)
              );
            }
            
            return false;
          }
        }
      }
    });
  }
  
  private static createReadingGuide(pos: number): DecorationSet {
    // Create horizontal line decoration
    const guide = document.createElement("div");
    guide.className = "reading-guide";
    guide.style.cssText = `
      position: absolute;
      width: 100%;
      height: 1px;
      background: rgba(0, 100, 200, 0.3);
      pointer-events: none;
      z-index: 1;
    `;
    
    return DecorationSet.create(document, [
      Decoration.widget(pos, guide, { side: 0 })
    ]);
  }
  
  static createFocusMode(): Plugin {
    return new Plugin({
      state: {
        init: () => ({ focused: false, paragraph: null }),
        apply: (tr, value) => {
          const selection = tr.selection;
          const $pos = selection.$from;
          const currentParagraph = $pos.node($pos.depth);
          
          return {
            focused: selection.empty,
            paragraph: currentParagraph
          };
        }
      },
      
      props: {
        decorations: (state) => {
          const pluginState = this.getState(state);
          if (pluginState.focused && pluginState.paragraph) {
            return this.createFocusDecorations(state, pluginState.paragraph);
          }
          return null;
        }
      }
    });
  }
  
  private static createFocusDecorations(state: EditorState, focusedNode: Node): DecorationSet {
    const decorations: Decoration[] = [];
    
    // Dim all other paragraphs
    state.doc.descendants((node, pos) => {
      if (node.type.name === "paragraph" && node !== focusedNode) {
        decorations.push(
          Decoration.node(pos, pos + node.nodeSize, {
            class: "dimmed-paragraph",
            style: "opacity: 0.4; transition: opacity 0.2s;"
          })
        );
      }
    });
    
    return DecorationSet.create(state.doc, decorations);
  }
}

Change Tracking Integration

Integrate change tracking with the editor for collaboration features.

class ChangeTracker {
  private changeSet: ChangeSet;
  private baseDoc: Node;
  
  constructor(baseDoc: Node) {
    this.baseDoc = baseDoc;
    this.changeSet = ChangeSet.create(baseDoc);
  }
  
  trackChanges(oldState: EditorState, newState: EditorState): ChangeSet {
    if (!newState.tr.docChanged) {
      return this.changeSet;
    }
    
    const changes: Change[] = [];
    
    // Extract changes from transaction steps
    newState.tr.steps.forEach((step, index) => {
      if (step instanceof ReplaceStep) {
        const change = new Change(
          step.from,
          step.to,
          step.slice.content,
          {
            timestamp: Date.now(),
            user: this.getCurrentUser(),
            type: "replace"
          }
        );
        changes.push(change);
      }
      
      if (step instanceof AddMarkStep) {
        const change = new Change(
          step.from,
          step.to,
          Fragment.empty,
          {
            timestamp: Date.now(),
            user: this.getCurrentUser(),
            type: "add-mark",
            mark: step.mark
          }
        );
        changes.push(change);
      }
    });
    
    // Update change set
    let newChangeSet = this.changeSet;
    for (const change of changes) {
      newChangeSet = newChangeSet.addChange(change);
    }
    
    this.changeSet = newChangeSet.simplify();
    return this.changeSet;
  }
  
  createChangeDecorations(): DecorationSet {
    const decorations: Decoration[] = [];
    
    for (const change of this.changeSet.changes) {
      const className = `change-${change.data.type}`;
      const title = `${change.data.user} at ${new Date(change.data.timestamp).toLocaleString()}`;
      
      decorations.push(
        Decoration.inline(change.from, change.to, {
          class: className,
          title
        })
      );
    }
    
    return DecorationSet.create(this.baseDoc, decorations);
  }
  
  acceptChanges(from?: number, to?: number): ChangeSet {
    const filteredChanges = this.changeSet.changes.filter(change => {
      if (from !== undefined && to !== undefined) {
        return !(change.from >= from && change.to <= to);
      }
      return false;
    });
    
    this.changeSet = ChangeSet.create(this.baseDoc, filteredChanges);
    return this.changeSet;
  }
  
  rejectChanges(from?: number, to?: number): Node {
    // Revert changes in the specified range
    // This would require more complex implementation
    return this.baseDoc;
  }
  
  private getCurrentUser(): string {
    // Get current user identifier
    return "current-user";
  }
}

Accessibility Enhancements

Add accessibility features to cursor and navigation systems.

class AccessibilityEnhancer {
  static createAriaLiveRegion(): Plugin {
    return new Plugin({
      view: () => {
        const liveRegion = document.createElement("div");
        liveRegion.setAttribute("aria-live", "polite");
        liveRegion.setAttribute("aria-atomic", "true");
        liveRegion.style.cssText = `
          position: absolute;
          left: -10000px;
          width: 1px;
          height: 1px;
          overflow: hidden;
        `;
        document.body.appendChild(liveRegion);
        
        return {
          update: (view, prevState) => {
            if (prevState.selection.eq(view.state.selection)) return;
            
            const announcement = this.createSelectionAnnouncement(view.state.selection);
            if (announcement) {
              liveRegion.textContent = announcement;
            }
          },
          
          destroy: () => {
            liveRegion.remove();
          }
        };
      }
    });
  }
  
  private static createSelectionAnnouncement(selection: Selection): string {
    if (selection instanceof GapCursor) {
      return "Gap cursor between blocks";
    }
    
    if (selection.empty) {
      return `Cursor at position ${selection.from}`;
    }
    
    const length = selection.to - selection.from;
    return `Selected ${length} character${length === 1 ? "" : "s"}`;
  }
  
  static createKeyboardNavigation(): Plugin {
    return keymap({
      "Alt-ArrowUp": (state, dispatch) => {
        // Move to previous block
        return this.navigateToBlock(state, dispatch, -1);
      },
      
      "Alt-ArrowDown": (state, dispatch) => {
        // Move to next block
        return this.navigateToBlock(state, dispatch, 1);
      },
      
      "Ctrl-Home": (state, dispatch) => {
        // Move to document start
        if (dispatch) {
          dispatch(state.tr.setSelection(Selection.atStart(state.doc)));
        }
        return true;
      },
      
      "Ctrl-End": (state, dispatch) => {
        // Move to document end
        if (dispatch) {
          dispatch(state.tr.setSelection(Selection.atEnd(state.doc)));
        }
        return true;
      }
    });
  }
  
  private static navigateToBlock(
    state: EditorState,
    dispatch?: (tr: Transaction) => void,
    direction: -1 | 1
  ): boolean {
    const { selection } = state;
    const $pos = selection.$from;
    
    // Find next block element
    let depth = $pos.depth;
    while (depth > 0) {
      const node = $pos.node(depth);
      if (node.isBlock) {
        const nodePos = $pos.start(depth);
        const nextPos = direction === -1 
          ? nodePos - 1 
          : nodePos + node.nodeSize;
        
        try {
          const $nextPos = state.doc.resolve(nextPos);
          const nextBlock = direction === -1 
            ? $nextPos.nodeBefore 
            : $nextPos.nodeAfter;
          
          if (nextBlock?.isBlock && dispatch) {
            const targetPos = direction === -1 
              ? nextPos - nextBlock.nodeSize + 1
              : nextPos + 1;
            
            dispatch(
              state.tr.setSelection(
                Selection.near(state.doc.resolve(targetPos))
              )
            );
            return true;
          }
        } catch (error) {
          // Position out of bounds
          break;
        }
      }
      depth--;
    }
    
    return false;
  }
}

Types

/**
 * Drop cursor configuration options
 */
interface DropCursorOptions {
  color?: string;
  width?: number;
  class?: string;
}

/**
 * Trailing node configuration options
 */
interface TrailingNodeOptions {
  node: string | NodeType;
  notAfter?: (string | NodeType)[];
}

/**
 * Change metadata interface
 */
interface ChangeData {
  timestamp: number;
  user: string;
  type: string;
  [key: string]: any;
}

/**
 * Selection bookmark interface
 */
interface SelectionBookmark {
  map(mapping: Mappable): SelectionBookmark;
  resolve(doc: Node): Selection;
}

Install with Tessl CLI

npx tessl i tessl/npm-tiptap--pm

docs

collaboration.md

commands-and-editing.md

cursors-and-enhancements.md

history.md

index.md

input-and-keymaps.md

markdown.md

menus-and-ui.md

model-and-schema.md

schema-definitions.md

state-management.md

tables.md

transformations.md

view-and-rendering.md

tile.json