0
# Rule Systems
1
2
@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.
3
4
## Capabilities
5
6
### InputRule
7
8
InputRules automatically transform text as you type, enabling markdown-like shortcuts and other input transformations.
9
10
```typescript { .api }
11
/**
12
* Rule for transforming text input based on patterns
13
*/
14
class InputRule {
15
/**
16
* Create a new input rule
17
* @param config - Rule configuration
18
*/
19
constructor(config: {
20
/** Pattern to match against input text */
21
find: RegExp | ((value: string) => RegExpMatchArray | null);
22
23
/** Handler function to process matches */
24
handler: (props: InputRuleHandlerProps) => void | null;
25
});
26
27
/** Pattern used to match input */
28
find: RegExp | ((value: string) => RegExpMatchArray | null);
29
30
/** Handler function for processing matches */
31
handler: (props: InputRuleHandlerProps) => void | null;
32
}
33
34
interface InputRuleHandlerProps {
35
/** Current editor state */
36
state: EditorState;
37
38
/** Range of the matched text */
39
range: { from: number; to: number };
40
41
/** RegExp match result */
42
match: RegExpMatchArray;
43
44
/** Access to single commands */
45
commands: SingleCommands;
46
47
/** Create command chain */
48
chain: () => ChainedCommands;
49
50
/** Check command executability */
51
can: () => CanCommands;
52
}
53
54
/**
55
* Create input rules plugin for ProseMirror
56
* @param config - Plugin configuration
57
* @returns ProseMirror plugin
58
*/
59
function inputRulesPlugin(config: {
60
editor: Editor;
61
rules: InputRule[];
62
}): Plugin;
63
```
64
65
**Usage Examples:**
66
67
```typescript
68
import { InputRule } from '@tiptap/core';
69
70
// Markdown-style heading rule
71
const headingRule = new InputRule({
72
find: /^(#{1,6})\s(.*)$/,
73
handler: ({ range, match, commands }) => {
74
const level = match[1].length;
75
const text = match[2];
76
77
commands.deleteRange(range);
78
commands.setNode('heading', { level });
79
commands.insertContent(text);
80
}
81
});
82
83
// Bold text rule
84
const boldRule = new InputRule({
85
find: /\*\*([^*]+)\*\*$/,
86
handler: ({ range, match, commands }) => {
87
const text = match[1];
88
89
commands.deleteRange(range);
90
commands.insertContent({
91
type: 'text',
92
text,
93
marks: [{ type: 'bold' }]
94
});
95
}
96
});
97
98
// Horizontal rule
99
const hrRule = new InputRule({
100
find: /^---$/,
101
handler: ({ range, commands }) => {
102
commands.deleteRange(range);
103
commands.insertContent({ type: 'horizontalRule' });
104
}
105
});
106
107
// Code block rule
108
const codeBlockRule = new InputRule({
109
find: /^```([a-zA-Z]*)?\s$/,
110
handler: ({ range, match, commands }) => {
111
const language = match[1] || null;
112
113
commands.deleteRange(range);
114
commands.setNode('codeBlock', { language });
115
}
116
});
117
118
// Blockquote rule
119
const blockquoteRule = new InputRule({
120
find: /^>\s(.*)$/,
121
handler: ({ range, match, commands }) => {
122
const text = match[1];
123
124
commands.deleteRange(range);
125
commands.wrapIn('blockquote');
126
commands.insertContent(text);
127
}
128
});
129
130
// List item rule
131
const listItemRule = new InputRule({
132
find: /^[*-]\s(.*)$/,
133
handler: ({ range, match, commands }) => {
134
const text = match[1];
135
136
commands.deleteRange(range);
137
commands.wrapIn('bulletList');
138
commands.insertContent(text);
139
}
140
});
141
142
// Using function-based find pattern
143
const smartQuoteRule = new InputRule({
144
find: (value: string) => {
145
const match = value.match(/"([^"]+)"$/);
146
return match;
147
},
148
handler: ({ range, match, commands }) => {
149
const text = match[1];
150
151
commands.deleteRange(range);
152
commands.insertContent(`"${text}"`); // Use smart quotes
153
}
154
});
155
```
156
157
### PasteRule
158
159
PasteRules transform content when it's pasted into the editor, allowing custom handling of different content types.
160
161
```typescript { .api }
162
/**
163
* Rule for transforming pasted content based on patterns
164
*/
165
class PasteRule {
166
/**
167
* Create a new paste rule
168
* @param config - Rule configuration
169
*/
170
constructor(config: {
171
/** Pattern to match against pasted content */
172
find: RegExp | ((value: string) => RegExpMatchArray | null);
173
174
/** Handler function to process matches */
175
handler: (props: PasteRuleHandlerProps) => void | null;
176
});
177
178
/** Pattern used to match pasted content */
179
find: RegExp | ((value: string) => RegExpMatchArray | null);
180
181
/** Handler function for processing matches */
182
handler: (props: PasteRuleHandlerProps) => void | null;
183
}
184
185
interface PasteRuleHandlerProps {
186
/** Current editor state */
187
state: EditorState;
188
189
/** Range where content will be pasted */
190
range: { from: number; to: number };
191
192
/** RegExp match result */
193
match: RegExpMatchArray;
194
195
/** Access to single commands */
196
commands: SingleCommands;
197
198
/** Create command chain */
199
chain: () => ChainedCommands;
200
201
/** Check command executability */
202
can: () => CanCommands;
203
204
/** The pasted text content */
205
pastedText: string;
206
207
/** Drop event (if paste was triggered by drag and drop) */
208
dropEvent?: DragEvent;
209
}
210
211
/**
212
* Create paste rules plugin for ProseMirror
213
* @param config - Plugin configuration
214
* @returns Array of ProseMirror plugins
215
*/
216
function pasteRulesPlugin(config: {
217
editor: Editor;
218
rules: PasteRule[];
219
}): Plugin[];
220
```
221
222
**Usage Examples:**
223
224
```typescript
225
import { PasteRule } from '@tiptap/core';
226
227
// URL to link conversion
228
const urlTolinkRule = new PasteRule({
229
find: /https?:\/\/[^\s]+/g,
230
handler: ({ range, match, commands }) => {
231
const url = match[0];
232
233
commands.deleteRange(range);
234
commands.insertContent({
235
type: 'text',
236
text: url,
237
marks: [{ type: 'link', attrs: { href: url } }]
238
});
239
}
240
});
241
242
// YouTube URL to embed
243
const youtubeRule = new PasteRule({
244
find: /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)/,
245
handler: ({ range, match, commands }) => {
246
const videoId = match[1];
247
248
commands.deleteRange(range);
249
commands.insertContent({
250
type: 'youtube',
251
attrs: { videoId }
252
});
253
}
254
});
255
256
// Email to mailto link
257
const emailRule = new PasteRule({
258
find: /[\w.-]+@[\w.-]+\.\w+/g,
259
handler: ({ range, match, commands }) => {
260
const email = match[0];
261
262
commands.deleteRange(range);
263
commands.insertContent({
264
type: 'text',
265
text: email,
266
marks: [{ type: 'link', attrs: { href: `mailto:${email}` } }]
267
});
268
}
269
});
270
271
// GitHub issue/PR references
272
const githubRefRule = new PasteRule({
273
find: /#(\d+)/g,
274
handler: ({ range, match, commands }) => {
275
const issueNumber = match[1];
276
277
commands.deleteRange(range);
278
commands.insertContent({
279
type: 'githubRef',
280
attrs: {
281
number: parseInt(issueNumber),
282
type: 'issue'
283
}
284
});
285
}
286
});
287
288
// Code detection and formatting
289
const codeRule = new PasteRule({
290
find: /`([^`]+)`/g,
291
handler: ({ range, match, commands }) => {
292
const code = match[1];
293
294
commands.deleteRange(range);
295
commands.insertContent({
296
type: 'text',
297
text: code,
298
marks: [{ type: 'code' }]
299
});
300
}
301
});
302
303
// Image URL to image node
304
const imageRule = new PasteRule({
305
find: /(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/gi,
306
handler: ({ range, match, commands }) => {
307
const src = match[0];
308
309
commands.deleteRange(range);
310
commands.insertContent({
311
type: 'image',
312
attrs: { src }
313
});
314
}
315
});
316
317
// Markdown table detection
318
const tableRule = new PasteRule({
319
find: /^\|(.+)\|\s*\n\|[-\s|]+\|\s*\n((?:\|.+\|\s*\n?)*)/m,
320
handler: ({ range, match, commands, chain }) => {
321
const headerRow = match[1];
322
const rows = match[2];
323
324
// Parse markdown table and convert to table node
325
const headers = headerRow.split('|').map(h => h.trim()).filter(Boolean);
326
const tableRows = rows.split('\n').filter(Boolean).map(row =>
327
row.split('|').map(cell => cell.trim()).filter(Boolean)
328
);
329
330
commands.deleteRange(range);
331
chain()
332
.insertTable({ rows: tableRows.length + 1, cols: headers.length })
333
.run();
334
}
335
});
336
```
337
338
### Rule Extension Integration
339
340
How to integrate input and paste rules into extensions.
341
342
```typescript { .api }
343
/**
344
* Extension methods for adding rules
345
*/
346
interface ExtensionConfig {
347
/**
348
* Add input rules to the extension
349
* @returns Array of input rules
350
*/
351
addInputRules?(): InputRule[];
352
353
/**
354
* Add paste rules to the extension
355
* @returns Array of paste rules
356
*/
357
addPasteRules?(): PasteRule[];
358
}
359
```
360
361
**Usage Examples:**
362
363
```typescript
364
import { Extension, InputRule, PasteRule } from '@tiptap/core';
365
366
// Extension with input and paste rules
367
const MarkdownExtension = Extension.create({
368
name: 'markdown',
369
370
addInputRules() {
371
return [
372
// Heading rules
373
new InputRule({
374
find: /^(#{1,6})\s(.*)$/,
375
handler: ({ range, match, commands }) => {
376
const level = match[1].length;
377
const text = match[2];
378
379
commands.deleteRange(range);
380
commands.setNode('heading', { level });
381
commands.insertContent(text);
382
}
383
}),
384
385
// Bold text rule
386
new InputRule({
387
find: /\*\*([^*]+)\*\*$/,
388
handler: ({ range, match, commands }) => {
389
const text = match[1];
390
391
commands.deleteRange(range);
392
commands.insertContent({
393
type: 'text',
394
text,
395
marks: [{ type: 'bold' }]
396
});
397
}
398
}),
399
400
// Italic text rule
401
new InputRule({
402
find: /\*([^*]+)\*$/,
403
handler: ({ range, match, commands }) => {
404
const text = match[1];
405
406
commands.deleteRange(range);
407
commands.insertContent({
408
type: 'text',
409
text,
410
marks: [{ type: 'italic' }]
411
});
412
}
413
})
414
];
415
},
416
417
addPasteRules() {
418
return [
419
// Convert URLs to links
420
new PasteRule({
421
find: /https?:\/\/[^\s]+/g,
422
handler: ({ range, match, commands }) => {
423
const url = match[0];
424
425
commands.deleteRange(range);
426
commands.insertContent({
427
type: 'text',
428
text: url,
429
marks: [{ type: 'link', attrs: { href: url } }]
430
});
431
}
432
}),
433
434
// Convert email addresses to mailto links
435
new PasteRule({
436
find: /[\w.-]+@[\w.-]+\.\w+/g,
437
handler: ({ range, match, commands }) => {
438
const email = match[0];
439
440
commands.deleteRange(range);
441
commands.insertContent({
442
type: 'text',
443
text: email,
444
marks: [{ type: 'link', attrs: { href: `mailto:${email}` } }]
445
});
446
}
447
})
448
];
449
}
450
});
451
452
// Node with specific input rules
453
const HeadingNode = Node.create({
454
name: 'heading',
455
group: 'block',
456
content: 'inline*',
457
458
addAttributes() {
459
return {
460
level: {
461
default: 1,
462
rendered: false,
463
},
464
};
465
},
466
467
addInputRules() {
468
return [
469
new InputRule({
470
find: /^(#{1,6})\s(.*)$/,
471
handler: ({ range, match, commands }) => {
472
const level = match[1].length;
473
const text = match[2];
474
475
commands.deleteRange(range);
476
commands.setNode(this.name, { level });
477
commands.insertContent(text);
478
}
479
})
480
];
481
}
482
});
483
484
// Mark with input and paste rules
485
const LinkMark = Mark.create({
486
name: 'link',
487
488
addAttributes() {
489
return {
490
href: {
491
default: null,
492
},
493
};
494
},
495
496
addInputRules() {
497
return [
498
// Markdown link syntax: [text](url)
499
new InputRule({
500
find: /\[([^\]]+)\]\(([^)]+)\)$/,
501
handler: ({ range, match, commands }) => {
502
const text = match[1];
503
const href = match[2];
504
505
commands.deleteRange(range);
506
commands.insertContent({
507
type: 'text',
508
text,
509
marks: [{ type: this.name, attrs: { href } }]
510
});
511
}
512
})
513
];
514
},
515
516
addPasteRules() {
517
return [
518
// Auto-link URLs
519
new PasteRule({
520
find: /https?:\/\/[^\s]+/g,
521
handler: ({ range, match, commands }) => {
522
const url = match[0];
523
524
commands.deleteRange(range);
525
commands.insertContent({
526
type: 'text',
527
text: url,
528
marks: [{ type: this.name, attrs: { href: url } }]
529
});
530
}
531
})
532
];
533
}
534
});
535
```
536
537
### Advanced Rule Patterns
538
539
Complex rule patterns and techniques for advanced transformations.
540
541
```typescript { .api }
542
// Advanced input rule patterns
543
544
// Multi-line rule detection
545
const codeBlockRule = new InputRule({
546
find: /^```(\w+)?\s*\n([\s\S]*?)```$/,
547
handler: ({ range, match, commands }) => {
548
const language = match[1];
549
const code = match[2];
550
551
commands.deleteRange(range);
552
commands.insertContent({
553
type: 'codeBlock',
554
attrs: { language },
555
content: [{ type: 'text', text: code }]
556
});
557
}
558
});
559
560
// Context-aware rules
561
const smartListRule = new InputRule({
562
find: /^(\d+)\.\s(.*)$/,
563
handler: ({ range, match, commands, state }) => {
564
const number = parseInt(match[1]);
565
const text = match[2];
566
567
// Check if we're already in a list
568
const isInList = state.selection.$from.node(-2)?.type.name === 'orderedList';
569
570
commands.deleteRange(range);
571
572
if (isInList) {
573
commands.splitListItem('listItem');
574
} else {
575
commands.wrapIn('orderedList', { start: number });
576
}
577
578
commands.insertContent(text);
579
}
580
});
581
582
// Conditional rule application
583
const conditionalRule = new InputRule({
584
find: /^@(\w+)\s(.*)$/,
585
handler: ({ range, match, commands, state, can }) => {
586
const mentionType = match[1];
587
const text = match[2];
588
589
// Only apply if we can insert mentions
590
if (!can().insertMention) {
591
return null; // Don't handle this rule
592
}
593
594
commands.deleteRange(range);
595
commands.insertMention({ type: mentionType, label: text });
596
}
597
});
598
599
// Rule with side effects
600
const trackingRule = new InputRule({
601
find: /^\$track\s(.*)$/,
602
handler: ({ range, match, commands }) => {
603
const eventName = match[1];
604
605
// Track the event
606
analytics?.track(eventName);
607
608
commands.deleteRange(range);
609
commands.insertContent(`Tracked: ${eventName}`);
610
}
611
});
612
```
613
614
### Rule Debugging and Testing
615
616
Utilities for debugging and testing rule behavior.
617
618
```typescript { .api }
619
// Debug rule matching
620
function debugInputRule(rule: InputRule, text: string): boolean {
621
if (typeof rule.find === 'function') {
622
const result = rule.find(text);
623
console.log('Function match result:', result);
624
return !!result;
625
} else {
626
const match = text.match(rule.find);
627
console.log('RegExp match result:', match);
628
return !!match;
629
}
630
}
631
632
// Test rule handler
633
function testRuleHandler(
634
rule: InputRule,
635
text: string,
636
mockCommands: Partial<SingleCommands>
637
): void {
638
const match = typeof rule.find === 'function'
639
? rule.find(text)
640
: text.match(rule.find);
641
642
if (match) {
643
rule.handler({
644
state: mockState,
645
range: { from: 0, to: text.length },
646
match,
647
commands: mockCommands as SingleCommands,
648
chain: () => ({} as ChainedCommands),
649
can: () => ({} as CanCommands)
650
});
651
}
652
}
653
654
// Rule performance testing
655
function benchmarkRule(rule: InputRule, testCases: string[]): number {
656
const start = performance.now();
657
658
testCases.forEach(text => {
659
if (typeof rule.find === 'function') {
660
rule.find(text);
661
} else {
662
text.match(rule.find);
663
}
664
});
665
666
return performance.now() - start;
667
}
668
```
669
670
**Usage Examples:**
671
672
```typescript
673
// Debug heading rule
674
const headingRule = new InputRule({
675
find: /^(#{1,6})\s(.*)$/,
676
handler: ({ range, match, commands }) => {
677
console.log('Heading match:', match);
678
// ... handler logic
679
}
680
});
681
682
debugInputRule(headingRule, '## My Heading'); // true
683
debugInputRule(headingRule, 'Regular text'); // false
684
685
// Test rule performance
686
const testCases = [
687
'# Heading 1',
688
'## Heading 2',
689
'Regular paragraph',
690
'**Bold text**',
691
'More normal text'
692
];
693
694
const time = benchmarkRule(headingRule, testCases);
695
console.log(`Rule processed ${testCases.length} cases in ${time}ms`);
696
697
// Test rule in isolation
698
testRuleHandler(headingRule, '## Test Heading', {
699
deleteRange: (range) => console.log('Delete range:', range),
700
setNode: (type, attrs) => console.log('Set node:', type, attrs),
701
insertContent: (content) => console.log('Insert content:', content)
702
});
703
```