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