Comprehensive wrapper around ProseMirror packages providing unified entry point for rich text editing functionality in Tiptap framework
—
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.
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>;
}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;
}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;
}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);
}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;
}
}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
}
}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 };
}
}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[];
}/**
* 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