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

transformations.mddocs/

Transformations

The transformation system provides atomic document changes, position tracking, and mapping operations. It ensures consistency when multiple changes occur simultaneously and enables features like collaborative editing and undo/redo.

Capabilities

Transform Class

The main class for tracking and applying document changes.

/**
 * Tracks a series of steps that transform a document
 */
class Transform {
  /**
   * Create a new transform
   */
  constructor(doc: Node);
  
  /**
   * The current document state
   */
  doc: Node;
  
  /**
   * Array of steps applied to reach current state
   */
  steps: Step[];
  
  /**
   * Array of documents after each step
   */
  docs: Node[];
  
  /**
   * Current mapping from original positions
   */
  mapping: Mapping;
  
  /**
   * Apply a step to the transform
   */
  step(step: Step): Transform;
  
  /**
   * Apply a step if possible, return the transform
   */
  maybeStep(step: Step): StepResult<Transform>;
}

Step Class

Abstract base class for atomic document changes.

/**
 * Abstract base class for document transformation steps
 */
abstract class Step {
  /**
   * Apply this step to a document
   */
  apply(doc: Node): StepResult<Node>;
  
  /**
   * Get the position mapping for this step
   */
  getMap(): StepMap;
  
  /**
   * Create the inverse of this step
   */
  invert(doc: Node): Step;
  
  /**
   * Map this step through a mapping
   */
  map(mapping: Mappable): Step | null;
  
  /**
   * Try to merge this step with another step
   */
  merge(other: Step): Step | null;
  
  /**
   * Convert step to JSON
   */
  toJSON(): any;
  
  /**
   * Create step from JSON
   */
  static fromJSON(schema: Schema, json: any): Step;
}

Mapping Classes

Classes for tracking position changes through transformations.

/**
 * Maps positions through document changes
 */
class Mapping {
  /**
   * Create a new mapping
   */
  constructor(maps?: StepMap[]);
  
  /**
   * Array of step maps
   */
  maps: StepMap[];
  
  /**
   * Add a step map to the mapping
   */
  appendMap(map: StepMap, mirrors?: number): void;
  
  /**
   * Append another mapping
   */
  appendMapping(mapping: Mappable): void;
  
  /**
   * Map a position through the mapping
   */
  map(pos: number, assoc?: number): number;
  
  /**
   * Map a position backwards
   */
  mapResult(pos: number, assoc?: number): MapResult;
  
  /**
   * Get the slice of mapping from one index to another
   */
  slice(from?: number, to?: number): Mapping;
}

/**
 * Individual step's position mapping
 */
class StepMap {
  /**
   * Create a step map
   */
  constructor(ranges: number[]);
  
  /**
   * Map a position through this step
   */
  map(pos: number, assoc?: number): number;
  
  /**
   * Map a position with detailed result
   */
  mapResult(pos: number, assoc?: number): MapResult;
  
  /**
   * Invert this step map
   */
  invert(): StepMap;
  
  /**
   * Convert to string representation
   */
  toString(): string;
  
  /**
   * Create empty step map
   */
  static empty: StepMap;
  
  /**
   * Create offset step map
   */
  static offset(n: number): StepMap;
}

Built-in Step Types

Concrete step implementations for common operations.

/**
 * Step that replaces content between two positions
 */
class ReplaceStep extends Step {
  /**
   * Create a replace step
   */
  constructor(from: number, to: number, slice: Slice, structure?: boolean);
  
  /**
   * From position
   */
  from: number;
  
  /**
   * To position
   */
  to: number;
  
  /**
   * Content to insert
   */
  slice: Slice;
}

/**
 * Step that replaces content around a position
 */
class ReplaceAroundStep extends Step {
  /**
   * Create a replace around step
   */
  constructor(from: number, to: number, gapFrom: number, gapTo: number, slice: Slice, insert: number, structure?: boolean);
}

/**
 * Step that adds or removes a mark
 */
class AddMarkStep extends Step {
  /**
   * Create an add mark step
   */
  constructor(from: number, to: number, mark: Mark);
}

/**
 * Step that removes a mark
 */
class RemoveMarkStep extends Step {
  /**
   * Create a remove mark step
   */
  constructor(from: number, to: number, mark: Mark);
}

/**
 * Step that adds node marks
 */
class AddNodeMarkStep extends Step {
  /**
   * Create an add node mark step
   */
  constructor(pos: number, mark: Mark);
}

/**
 * Step that removes node marks
 */
class RemoveNodeMarkStep extends Step {
  /**
   * Create a remove node mark step
   */
  constructor(pos: number, mark: Mark);
}

Transform Helper Functions

Utility functions for common transformation operations.

/**
 * Replace content between positions
 */
function replaceStep(doc: Node, from: number, to?: number, slice?: Slice): Step | null;

/**
 * Lift content out of its parent
 */
function liftTarget(range: NodeRange): number | null;

/**
 * Find wrapping for content
 */
function findWrapping(range: NodeRange, nodeType: NodeType, attrs?: Attrs, innerRange?: NodeRange): Transform | null;

/**
 * Check if content can be split
 */
function canSplit(doc: Node, pos: number, depth?: number, typesAfter?: NodeType[]): boolean;

/**
 * Split content at position
 */
function split(tr: Transform, pos: number, depth?: number, typesAfter?: NodeType[]): Transform;

/**
 * Check if content can be joined
 */
function canJoin(doc: Node, pos: number): boolean;

/**
 * Join content at position
 */
function joinPoint(doc: Node, pos: number, dir?: number): number | null;

/**
 * Insert content at position
 */
function insertPoint(doc: Node, pos: number, nodeType: NodeType): number | null;

/**
 * Drop point for content
 */
function dropPoint(doc: Node, pos: number, slice: Slice): number | null;

Usage Examples:

import { 
  Transform, 
  Step, 
  ReplaceStep,
  AddMarkStep,
  RemoveMarkStep,
  Mapping,
  StepMap,
  replaceStep,
  canSplit,
  canJoin
} from "@tiptap/pm/transform";

// Basic document transformation
function performTransformation(doc: Node, schema: Schema): Node {
  const tr = new Transform(doc);
  
  // Insert text at position 10
  const insertStep = new ReplaceStep(
    10, 10, 
    new Slice(Fragment.from(schema.text("Hello, ")), 0, 0)
  );
  tr.step(insertStep);
  
  // Add bold mark to text from position 10-16
  const boldMark = schema.marks.strong.create();
  const markStep = new AddMarkStep(10, 16, boldMark);
  tr.step(markStep);
  
  // Replace content at position 20-25
  const replaceSlice = new Slice(
    Fragment.from(schema.text("World!")), 
    0, 0
  );
  const replaceStep = new ReplaceStep(20, 25, replaceSlice);
  tr.step(replaceStep);
  
  return tr.doc;
}

// Position mapping through transformations
function trackPositionThroughChanges(
  doc: Node, 
  originalPos: number,
  steps: Step[]
): number {
  const mapping = new Mapping();
  let currentDoc = doc;
  
  for (const step of steps) {
    const result = step.apply(currentDoc);
    if (result.failed) {
      throw new Error(`Step failed: ${result.failed}`);
    }
    
    currentDoc = result.doc;
    mapping.appendMap(step.getMap());
  }
  
  return mapping.map(originalPos);
}

// Complex transformation with validation
class DocumentEditor {
  private doc: Node;
  private schema: Schema;
  
  constructor(doc: Node, schema: Schema) {
    this.doc = doc;
    this.schema = schema;
  }
  
  insertText(pos: number, text: string): boolean {
    const tr = new Transform(this.doc);
    const slice = new Slice(
      Fragment.from(this.schema.text(text)), 
      0, 0
    );
    
    const step = new ReplaceStep(pos, pos, slice);
    const result = tr.maybeStep(step);
    
    if (result.failed) {
      console.error("Insert failed:", result.failed);
      return false;
    }
    
    this.doc = tr.doc;
    return true;
  }
  
  deleteRange(from: number, to: number): boolean {
    const tr = new Transform(this.doc);
    const step = new ReplaceStep(from, to, Slice.empty);
    const result = tr.maybeStep(step);
    
    if (result.failed) {
      console.error("Delete failed:", result.failed);
      return false;
    }
    
    this.doc = tr.doc;
    return true;
  }
  
  toggleMark(from: number, to: number, markType: MarkType, attrs?: Attrs): boolean {
    const tr = new Transform(this.doc);
    const mark = markType.create(attrs);
    
    // Check if mark exists in range
    const hasMark = this.doc.rangeHasMark(from, to, markType);
    
    let step: Step;
    if (hasMark) {
      // Remove mark
      step = new RemoveMarkStep(from, to, mark);
    } else {
      // Add mark
      step = new AddMarkStep(from, to, mark);
    }
    
    const result = tr.maybeStep(step);
    if (result.failed) {
      console.error("Toggle mark failed:", result.failed);
      return false;
    }
    
    this.doc = tr.doc;
    return true;
  }
  
  splitBlock(pos: number): boolean {
    if (!canSplit(this.doc, pos)) {
      return false;
    }
    
    const tr = new Transform(this.doc);
    const $pos = this.doc.resolve(pos);
    const nodeType = $pos.parent.type;
    
    const splitStep = new ReplaceStep(
      pos, pos,
      new Slice(
        Fragment.from([
          nodeType.createAndFill(),
          nodeType.createAndFill()
        ]),
        1, 1
      )
    );
    
    const result = tr.maybeStep(splitStep);
    if (result.failed) {
      console.error("Split failed:", result.failed);
      return false;
    }
    
    this.doc = tr.doc;
    return true;
  }
  
  joinBlocks(pos: number): boolean {
    if (!canJoin(this.doc, pos)) {
      return false;
    }
    
    const tr = new Transform(this.doc);
    const $pos = this.doc.resolve(pos);
    const before = $pos.nodeBefore;
    const after = $pos.nodeAfter;
    
    if (!before || !after) return false;
    
    const joinStep = new ReplaceStep(
      pos - before.nodeSize,
      pos + after.nodeSize,
      new Slice(
        Fragment.from(before.content.append(after.content)),
        0, 0
      )
    );
    
    const result = tr.maybeStep(joinStep);
    if (result.failed) {
      console.error("Join failed:", result.failed);
      return false;
    }
    
    this.doc = tr.doc;
    return true;
  }
  
  getDocument(): Node {
    return this.doc;
  }
}

Advanced Transformation Features

Custom Step Types

Create specialized step types for specific operations.

/**
 * Custom step for atomic table operations
 */
class TableTransformStep extends Step {
  constructor(
    private tablePos: number,
    private operation: "addColumn" | "addRow" | "deleteColumn" | "deleteRow",
    private index: number
  ) {
    super();
  }
  
  apply(doc: Node): StepResult<Node> {
    try {
      const $pos = doc.resolve(this.tablePos);
      const table = $pos.nodeAfter;
      
      if (!table || table.type.name !== "table") {
        return StepResult.fail("No table found at position");
      }
      
      let newTable: Node;
      
      switch (this.operation) {
        case "addColumn":
          newTable = this.addColumn(table);
          break;
        case "addRow":
          newTable = this.addRow(table);
          break;
        case "deleteColumn":
          newTable = this.deleteColumn(table);
          break;
        case "deleteRow":
          newTable = this.deleteRow(table);
          break;
        default:
          return StepResult.fail("Unknown table operation");
      }
      
      const newDoc = doc.copy(
        doc.content.replaceChild(
          this.tablePos,
          newTable
        )
      );
      
      return StepResult.ok(newDoc);
    } catch (error) {
      return StepResult.fail(error.message);
    }
  }
  
  getMap(): StepMap {
    // Calculate position changes based on operation
    switch (this.operation) {
      case "addColumn":
      case "addRow":
        return new StepMap([
          this.tablePos, 0, 1  // Insert at table position
        ]);
      case "deleteColumn":
      case "deleteRow":
        return new StepMap([
          this.tablePos, 1, 0  // Delete at table position
        ]);
      default:
        return StepMap.empty;
    }
  }
  
  invert(doc: Node): Step {
    // Return inverse operation
    const inverseOps = {
      "addColumn": "deleteColumn",
      "addRow": "deleteRow", 
      "deleteColumn": "addColumn",
      "deleteRow": "addRow"
    };
    
    return new TableTransformStep(
      this.tablePos,
      inverseOps[this.operation] as any,
      this.index
    );
  }
  
  map(mapping: Mappable): Step | null {
    const newPos = mapping.map(this.tablePos);
    return new TableTransformStep(newPos, this.operation, this.index);
  }
  
  merge(other: Step): Step | null {
    // Table steps don't merge with other operations
    return null;
  }
  
  toJSON(): any {
    return {
      stepType: "tableTransform",
      tablePos: this.tablePos,
      operation: this.operation,
      index: this.index
    };
  }
  
  static fromJSON(schema: Schema, json: any): TableTransformStep {
    return new TableTransformStep(
      json.tablePos,
      json.operation,
      json.index
    );
  }
  
  private addColumn(table: Node): Node {
    // Implementation for adding column
    return table; // Simplified
  }
  
  private addRow(table: Node): Node {
    // Implementation for adding row
    return table; // Simplified
  }
  
  private deleteColumn(table: Node): Node {
    // Implementation for deleting column
    return table; // Simplified
  }
  
  private deleteRow(table: Node): Node {
    // Implementation for deleting row
    return table; // Simplified
  }
}

Operational Transform

Handle conflicting simultaneous transformations.

class OperationalTransform {
  /**
   * Transform two concurrent operations for conflict resolution
   */
  static transform(
    stepA: Step,
    stepB: Step,
    priority: "left" | "right" = "left"
  ): { stepA: Step | null; stepB: Step | null } {
    // Handle different step type combinations
    if (stepA instanceof ReplaceStep && stepB instanceof ReplaceStep) {
      return this.transformReplaceSteps(stepA, stepB, priority);
    }
    
    if (stepA instanceof AddMarkStep && stepB instanceof AddMarkStep) {
      return this.transformMarkSteps(stepA, stepB, priority);
    }
    
    // Handle mixed types
    if (stepA instanceof ReplaceStep && stepB instanceof AddMarkStep) {
      return this.transformReplaceAndMark(stepA, stepB);
    }
    
    if (stepA instanceof AddMarkStep && stepB instanceof ReplaceStep) {
      const result = this.transformReplaceAndMark(stepB, stepA);
      return { stepA: result.stepB, stepB: result.stepA };
    }
    
    // Default: apply mapping
    const mapA = stepA.getMap();
    const mapB = stepB.getMap();
    
    return {
      stepA: stepA.map(new Mapping([mapB])),
      stepB: stepB.map(new Mapping([mapA]))
    };
  }
  
  private static transformReplaceSteps(
    stepA: ReplaceStep,
    stepB: ReplaceStep,
    priority: "left" | "right"
  ): { stepA: Step | null; stepB: Step | null } {
    const aFrom = stepA.from;
    const aTo = stepA.to;
    const bFrom = stepB.from;
    const bTo = stepB.to;
    
    // No overlap - simple mapping
    if (aTo <= bFrom) {
      const offset = stepA.slice.size - (aTo - aFrom);
      return {
        stepA: stepA,
        stepB: new ReplaceStep(bFrom + offset, bTo + offset, stepB.slice)
      };
    }
    
    if (bTo <= aFrom) {
      const offset = stepB.slice.size - (bTo - bFrom);
      return {
        stepA: new ReplaceStep(aFrom + offset, aTo + offset, stepA.slice),
        stepB: stepB
      };
    }
    
    // Overlapping changes - use priority
    if (priority === "left") {
      return {
        stepA: stepA,
        stepB: null  // Discard conflicting step
      };
    } else {
      return {
        stepA: null,
        stepB: stepB
      };
    }
  }
  
  private static transformMarkSteps(
    stepA: AddMarkStep,
    stepB: AddMarkStep,
    priority: "left" | "right"
  ): { stepA: Step | null; stepB: Step | null } {
    // Mark steps can usually coexist
    if (stepA.mark.type !== stepB.mark.type) {
      return { stepA, stepB };
    }
    
    // Same mark type - check for conflicts
    const aFrom = stepA.from;
    const aTo = stepA.to;
    const bFrom = stepB.from;  
    const bTo = stepB.to;
    
    // No overlap
    if (aTo <= bFrom || bTo <= aFrom) {
      return { stepA, stepB };
    }
    
    // Overlapping same mark - merge or prioritize
    if (stepA.mark.eq(stepB.mark)) {
      // Same mark - create merged step
      const mergedStep = new AddMarkStep(
        Math.min(aFrom, bFrom),
        Math.max(aTo, bTo),
        stepA.mark
      );
      return { stepA: mergedStep, stepB: null };
    }
    
    // Different attributes - use priority
    return priority === "left" 
      ? { stepA, stepB: null }
      : { stepA: null, stepB };
  }
  
  private static transformReplaceAndMark(
    replaceStep: ReplaceStep,
    markStep: AddMarkStep
  ): { stepA: Step | null; stepB: Step | null } {
    const replaceFrom = replaceStep.from;
    const replaceTo = replaceStep.to;
    const markFrom = markStep.from;
    const markTo = markStep.to;
    
    // Mark is completely before replace
    if (markTo <= replaceFrom) {
      return { stepA: replaceStep, stepB: markStep };
    }
    
    // Mark is completely after replace
    if (markFrom >= replaceTo) {
      const offset = replaceStep.slice.size - (replaceTo - replaceFrom);
      return {
        stepA: replaceStep,
        stepB: new AddMarkStep(
          markFrom + offset,
          markTo + offset,
          markStep.mark
        )
      };
    }
    
    // Mark overlaps with replace - complex transformation needed
    // Simplified: apply mark to replacement content if applicable
    return { stepA: replaceStep, stepB: null };
  }
}

Transform Validation

Validate transformations before applying them.

class TransformValidator {
  static validate(transform: Transform, schema: Schema): ValidationResult {
    const errors: string[] = [];
    const warnings: string[] = [];
    
    // Validate each step
    for (let i = 0; i < transform.steps.length; i++) {
      const step = transform.steps[i];
      const doc = i === 0 ? transform.docs[0] : transform.docs[i];
      
      const stepResult = this.validateStep(step, doc, schema);
      errors.push(...stepResult.errors);
      warnings.push(...stepResult.warnings);
    }
    
    // Validate final document
    const finalValidation = this.validateDocument(transform.doc, schema);
    errors.push(...finalValidation.errors);
    warnings.push(...finalValidation.warnings);
    
    return {
      valid: errors.length === 0,
      errors,
      warnings
    };
  }
  
  private static validateStep(step: Step, doc: Node, schema: Schema): ValidationResult {
    const errors: string[] = [];
    const warnings: string[] = [];
    
    try {
      // Test applying the step
      const result = step.apply(doc);
      if (result.failed) {
        errors.push(`Step application failed: ${result.failed}`);
      }
      
      // Validate step-specific constraints
      if (step instanceof ReplaceStep) {
        const validation = this.validateReplaceStep(step, doc, schema);
        errors.push(...validation.errors);
        warnings.push(...validation.warnings);
      }
      
      if (step instanceof AddMarkStep) {
        const validation = this.validateMarkStep(step, doc, schema);
        errors.push(...validation.errors);
        warnings.push(...validation.warnings);
      }
      
    } catch (error) {
      errors.push(`Step validation error: ${error.message}`);
    }
    
    return { valid: errors.length === 0, errors, warnings };
  }
  
  private static validateReplaceStep(
    step: ReplaceStep,
    doc: Node,
    schema: Schema
  ): ValidationResult {
    const errors: string[] = [];
    const warnings: string[] = [];
    
    // Check position bounds
    if (step.from < 0 || step.to > doc.content.size) {
      errors.push(`Replace step positions out of bounds: ${step.from}-${step.to}`);
    }
    
    if (step.from > step.to) {
      errors.push(`Invalid replace range: from(${step.from}) > to(${step.to})`);
    }
    
    // Check content compatibility
    try {
      const $from = doc.resolve(step.from);
      const $to = doc.resolve(step.to);
      
      if (!$from.parent.canReplace($from.index(), $to.index(), step.slice.content)) {
        errors.push("Replacement content not allowed at position");
      }
    } catch (error) {
      errors.push(`Position resolution failed: ${error.message}`);
    }
    
    return { valid: errors.length === 0, errors, warnings };
  }
  
  private static validateMarkStep(
    step: AddMarkStep,
    doc: Node,
    schema: Schema
  ): ValidationResult {
    const errors: string[] = [];
    const warnings: string[] = [];
    
    // Check if mark can be applied to content in range
    try {
      doc.nodesBetween(step.from, step.to, (node, pos) => {
        if (node.isText && !node.type.allowsMarkType(step.mark.type)) {
          errors.push(`Mark ${step.mark.type.name} not allowed on text at ${pos}`);
        }
        return true;
      });
    } catch (error) {
      errors.push(`Mark validation failed: ${error.message}`);
    }
    
    return { valid: errors.length === 0, errors, warnings };
  }
  
  private static validateDocument(doc: Node, schema: Schema): ValidationResult {
    const errors: string[] = [];
    const warnings: string[] = [];
    
    try {
      // Use ProseMirror's built-in validation
      doc.check();
    } catch (error) {
      errors.push(`Document structure invalid: ${error.message}`);
    }
    
    return { valid: errors.length === 0, errors, warnings };
  }
}

interface ValidationResult {
  valid: boolean;
  errors: string[];
  warnings: string[];
}

Types

/**
 * Result of applying a step
 */
interface StepResult<T> {
  /**
   * The resulting document/transform if successful
   */
  doc?: T;
  
  /**
   * Error message if failed
   */
  failed?: string;
}

/**
 * Position mapping result with additional information
 */
interface MapResult {
  /**
   * Mapped position
   */
  pos: number;
  
  /**
   * Whether position was deleted
   */
  deleted: boolean;
  
  /**
   * Recovery information
   */
  recover?: number;
}

/**
 * Interface for mappable objects
 */
interface Mappable {
  /**
   * Map a position
   */
  map(pos: number, assoc?: number): number;
  
  /**
   * Map with detailed result
   */
  mapResult(pos: number, assoc?: number): MapResult;
}

/**
 * Step result helper
 */
class StepResult<T> {
  static ok<T>(value: T): StepResult<T>;
  static fail<T>(message: string): StepResult<T>;
}

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