Headless rich text editor built on ProseMirror with extensible architecture for building custom editors
94
@tiptap/core provides powerful rule systems for transforming input and pasted content. InputRules enable markdown-like shortcuts during typing, while PasteRules transform content when pasted into the editor.
InputRules automatically transform text as you type, enabling markdown-like shortcuts and other input transformations.
/**
* Rule for transforming text input based on patterns
*/
class InputRule {
/**
* Create a new input rule
* @param config - Rule configuration
*/
constructor(config: {
/** Pattern to match against input text */
find: RegExp | ((value: string) => RegExpMatchArray | null);
/** Handler function to process matches */
handler: (props: InputRuleHandlerProps) => void | null;
});
/** Pattern used to match input */
find: RegExp | ((value: string) => RegExpMatchArray | null);
/** Handler function for processing matches */
handler: (props: InputRuleHandlerProps) => void | null;
}
interface InputRuleHandlerProps {
/** Current editor state */
state: EditorState;
/** Range of the matched text */
range: { from: number; to: number };
/** RegExp match result */
match: RegExpMatchArray;
/** Access to single commands */
commands: SingleCommands;
/** Create command chain */
chain: () => ChainedCommands;
/** Check command executability */
can: () => CanCommands;
}
/**
* Create input rules plugin for ProseMirror
* @param config - Plugin configuration
* @returns ProseMirror plugin
*/
function inputRulesPlugin(config: {
editor: Editor;
rules: InputRule[];
}): Plugin;Usage Examples:
import { InputRule } from '@tiptap/core';
// Markdown-style heading rule
const headingRule = new InputRule({
find: /^(#{1,6})\s(.*)$/,
handler: ({ range, match, commands }) => {
const level = match[1].length;
const text = match[2];
commands.deleteRange(range);
commands.setNode('heading', { level });
commands.insertContent(text);
}
});
// Bold text rule
const boldRule = new InputRule({
find: /\*\*([^*]+)\*\*$/,
handler: ({ range, match, commands }) => {
const text = match[1];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text,
marks: [{ type: 'bold' }]
});
}
});
// Horizontal rule
const hrRule = new InputRule({
find: /^---$/,
handler: ({ range, commands }) => {
commands.deleteRange(range);
commands.insertContent({ type: 'horizontalRule' });
}
});
// Code block rule
const codeBlockRule = new InputRule({
find: /^```([a-zA-Z]*)?\s$/,
handler: ({ range, match, commands }) => {
const language = match[1] || null;
commands.deleteRange(range);
commands.setNode('codeBlock', { language });
}
});
// Blockquote rule
const blockquoteRule = new InputRule({
find: /^>\s(.*)$/,
handler: ({ range, match, commands }) => {
const text = match[1];
commands.deleteRange(range);
commands.wrapIn('blockquote');
commands.insertContent(text);
}
});
// List item rule
const listItemRule = new InputRule({
find: /^[*-]\s(.*)$/,
handler: ({ range, match, commands }) => {
const text = match[1];
commands.deleteRange(range);
commands.wrapIn('bulletList');
commands.insertContent(text);
}
});
// Using function-based find pattern
const smartQuoteRule = new InputRule({
find: (value: string) => {
const match = value.match(/"([^"]+)"$/);
return match;
},
handler: ({ range, match, commands }) => {
const text = match[1];
commands.deleteRange(range);
commands.insertContent(`"${text}"`); // Use smart quotes
}
});PasteRules transform content when it's pasted into the editor, allowing custom handling of different content types.
/**
* Rule for transforming pasted content based on patterns
*/
class PasteRule {
/**
* Create a new paste rule
* @param config - Rule configuration
*/
constructor(config: {
/** Pattern to match against pasted content */
find: RegExp | ((value: string) => RegExpMatchArray | null);
/** Handler function to process matches */
handler: (props: PasteRuleHandlerProps) => void | null;
});
/** Pattern used to match pasted content */
find: RegExp | ((value: string) => RegExpMatchArray | null);
/** Handler function for processing matches */
handler: (props: PasteRuleHandlerProps) => void | null;
}
interface PasteRuleHandlerProps {
/** Current editor state */
state: EditorState;
/** Range where content will be pasted */
range: { from: number; to: number };
/** RegExp match result */
match: RegExpMatchArray;
/** Access to single commands */
commands: SingleCommands;
/** Create command chain */
chain: () => ChainedCommands;
/** Check command executability */
can: () => CanCommands;
/** The pasted text content */
pastedText: string;
/** Drop event (if paste was triggered by drag and drop) */
dropEvent?: DragEvent;
}
/**
* Create paste rules plugin for ProseMirror
* @param config - Plugin configuration
* @returns Array of ProseMirror plugins
*/
function pasteRulesPlugin(config: {
editor: Editor;
rules: PasteRule[];
}): Plugin[];Usage Examples:
import { PasteRule } from '@tiptap/core';
// URL to link conversion
const urlTolinkRule = new PasteRule({
find: /https?:\/\/[^\s]+/g,
handler: ({ range, match, commands }) => {
const url = match[0];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text: url,
marks: [{ type: 'link', attrs: { href: url } }]
});
}
});
// YouTube URL to embed
const youtubeRule = new PasteRule({
find: /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)/,
handler: ({ range, match, commands }) => {
const videoId = match[1];
commands.deleteRange(range);
commands.insertContent({
type: 'youtube',
attrs: { videoId }
});
}
});
// Email to mailto link
const emailRule = new PasteRule({
find: /[\w.-]+@[\w.-]+\.\w+/g,
handler: ({ range, match, commands }) => {
const email = match[0];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text: email,
marks: [{ type: 'link', attrs: { href: `mailto:${email}` } }]
});
}
});
// GitHub issue/PR references
const githubRefRule = new PasteRule({
find: /#(\d+)/g,
handler: ({ range, match, commands }) => {
const issueNumber = match[1];
commands.deleteRange(range);
commands.insertContent({
type: 'githubRef',
attrs: {
number: parseInt(issueNumber),
type: 'issue'
}
});
}
});
// Code detection and formatting
const codeRule = new PasteRule({
find: /`([^`]+)`/g,
handler: ({ range, match, commands }) => {
const code = match[1];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text: code,
marks: [{ type: 'code' }]
});
}
});
// Image URL to image node
const imageRule = new PasteRule({
find: /(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/gi,
handler: ({ range, match, commands }) => {
const src = match[0];
commands.deleteRange(range);
commands.insertContent({
type: 'image',
attrs: { src }
});
}
});
// Markdown table detection
const tableRule = new PasteRule({
find: /^\|(.+)\|\s*\n\|[-\s|]+\|\s*\n((?:\|.+\|\s*\n?)*)/m,
handler: ({ range, match, commands, chain }) => {
const headerRow = match[1];
const rows = match[2];
// Parse markdown table and convert to table node
const headers = headerRow.split('|').map(h => h.trim()).filter(Boolean);
const tableRows = rows.split('\n').filter(Boolean).map(row =>
row.split('|').map(cell => cell.trim()).filter(Boolean)
);
commands.deleteRange(range);
chain()
.insertTable({ rows: tableRows.length + 1, cols: headers.length })
.run();
}
});How to integrate input and paste rules into extensions.
/**
* Extension methods for adding rules
*/
interface ExtensionConfig {
/**
* Add input rules to the extension
* @returns Array of input rules
*/
addInputRules?(): InputRule[];
/**
* Add paste rules to the extension
* @returns Array of paste rules
*/
addPasteRules?(): PasteRule[];
}Usage Examples:
import { Extension, InputRule, PasteRule } from '@tiptap/core';
// Extension with input and paste rules
const MarkdownExtension = Extension.create({
name: 'markdown',
addInputRules() {
return [
// Heading rules
new InputRule({
find: /^(#{1,6})\s(.*)$/,
handler: ({ range, match, commands }) => {
const level = match[1].length;
const text = match[2];
commands.deleteRange(range);
commands.setNode('heading', { level });
commands.insertContent(text);
}
}),
// Bold text rule
new InputRule({
find: /\*\*([^*]+)\*\*$/,
handler: ({ range, match, commands }) => {
const text = match[1];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text,
marks: [{ type: 'bold' }]
});
}
}),
// Italic text rule
new InputRule({
find: /\*([^*]+)\*$/,
handler: ({ range, match, commands }) => {
const text = match[1];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text,
marks: [{ type: 'italic' }]
});
}
})
];
},
addPasteRules() {
return [
// Convert URLs to links
new PasteRule({
find: /https?:\/\/[^\s]+/g,
handler: ({ range, match, commands }) => {
const url = match[0];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text: url,
marks: [{ type: 'link', attrs: { href: url } }]
});
}
}),
// Convert email addresses to mailto links
new PasteRule({
find: /[\w.-]+@[\w.-]+\.\w+/g,
handler: ({ range, match, commands }) => {
const email = match[0];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text: email,
marks: [{ type: 'link', attrs: { href: `mailto:${email}` } }]
});
}
})
];
}
});
// Node with specific input rules
const HeadingNode = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
addAttributes() {
return {
level: {
default: 1,
rendered: false,
},
};
},
addInputRules() {
return [
new InputRule({
find: /^(#{1,6})\s(.*)$/,
handler: ({ range, match, commands }) => {
const level = match[1].length;
const text = match[2];
commands.deleteRange(range);
commands.setNode(this.name, { level });
commands.insertContent(text);
}
})
];
}
});
// Mark with input and paste rules
const LinkMark = Mark.create({
name: 'link',
addAttributes() {
return {
href: {
default: null,
},
};
},
addInputRules() {
return [
// Markdown link syntax: [text](url)
new InputRule({
find: /\[([^\]]+)\]\(([^)]+)\)$/,
handler: ({ range, match, commands }) => {
const text = match[1];
const href = match[2];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text,
marks: [{ type: this.name, attrs: { href } }]
});
}
})
];
},
addPasteRules() {
return [
// Auto-link URLs
new PasteRule({
find: /https?:\/\/[^\s]+/g,
handler: ({ range, match, commands }) => {
const url = match[0];
commands.deleteRange(range);
commands.insertContent({
type: 'text',
text: url,
marks: [{ type: this.name, attrs: { href: url } }]
});
}
})
];
}
});Complex rule patterns and techniques for advanced transformations.
// Advanced input rule patterns
// Multi-line rule detection
const codeBlockRule = new InputRule({
find: /^```(\w+)?\s*\n([\s\S]*?)```$/,
handler: ({ range, match, commands }) => {
const language = match[1];
const code = match[2];
commands.deleteRange(range);
commands.insertContent({
type: 'codeBlock',
attrs: { language },
content: [{ type: 'text', text: code }]
});
}
});
// Context-aware rules
const smartListRule = new InputRule({
find: /^(\d+)\.\s(.*)$/,
handler: ({ range, match, commands, state }) => {
const number = parseInt(match[1]);
const text = match[2];
// Check if we're already in a list
const isInList = state.selection.$from.node(-2)?.type.name === 'orderedList';
commands.deleteRange(range);
if (isInList) {
commands.splitListItem('listItem');
} else {
commands.wrapIn('orderedList', { start: number });
}
commands.insertContent(text);
}
});
// Conditional rule application
const conditionalRule = new InputRule({
find: /^@(\w+)\s(.*)$/,
handler: ({ range, match, commands, state, can }) => {
const mentionType = match[1];
const text = match[2];
// Only apply if we can insert mentions
if (!can().insertMention) {
return null; // Don't handle this rule
}
commands.deleteRange(range);
commands.insertMention({ type: mentionType, label: text });
}
});
// Rule with side effects
const trackingRule = new InputRule({
find: /^\$track\s(.*)$/,
handler: ({ range, match, commands }) => {
const eventName = match[1];
// Track the event
analytics?.track(eventName);
commands.deleteRange(range);
commands.insertContent(`Tracked: ${eventName}`);
}
});Utilities for debugging and testing rule behavior.
// Debug rule matching
function debugInputRule(rule: InputRule, text: string): boolean {
if (typeof rule.find === 'function') {
const result = rule.find(text);
console.log('Function match result:', result);
return !!result;
} else {
const match = text.match(rule.find);
console.log('RegExp match result:', match);
return !!match;
}
}
// Test rule handler
function testRuleHandler(
rule: InputRule,
text: string,
mockCommands: Partial<SingleCommands>
): void {
const match = typeof rule.find === 'function'
? rule.find(text)
: text.match(rule.find);
if (match) {
rule.handler({
state: mockState,
range: { from: 0, to: text.length },
match,
commands: mockCommands as SingleCommands,
chain: () => ({} as ChainedCommands),
can: () => ({} as CanCommands)
});
}
}
// Rule performance testing
function benchmarkRule(rule: InputRule, testCases: string[]): number {
const start = performance.now();
testCases.forEach(text => {
if (typeof rule.find === 'function') {
rule.find(text);
} else {
text.match(rule.find);
}
});
return performance.now() - start;
}Usage Examples:
// Debug heading rule
const headingRule = new InputRule({
find: /^(#{1,6})\s(.*)$/,
handler: ({ range, match, commands }) => {
console.log('Heading match:', match);
// ... handler logic
}
});
debugInputRule(headingRule, '## My Heading'); // true
debugInputRule(headingRule, 'Regular text'); // false
// Test rule performance
const testCases = [
'# Heading 1',
'## Heading 2',
'Regular paragraph',
'**Bold text**',
'More normal text'
];
const time = benchmarkRule(headingRule, testCases);
console.log(`Rule processed ${testCases.length} cases in ${time}ms`);
// Test rule in isolation
testRuleHandler(headingRule, '## Test Heading', {
deleteRange: (range) => console.log('Delete range:', range),
setNode: (type, attrs) => console.log('Set node:', type, attrs),
insertContent: (content) => console.log('Insert content:', content)
});Install with Tessl CLI
npx tessl i tessl/npm-tiptap--coredocs
evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
scenario-6
scenario-7
scenario-8
scenario-9
scenario-10