0
# Cursors and Enhancements
1
2
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.
3
4
## Capabilities
5
6
### Gap Cursor
7
8
Allows cursor positioning between block elements where normal text selection isn't possible.
9
10
```typescript { .api }
11
/**
12
* Gap cursor selection type for positioning between blocks
13
*/
14
class GapCursor extends Selection {
15
/**
16
* Create a gap cursor at the given position
17
*/
18
constructor(pos: ResolvedPos, side: -1 | 1);
19
20
/**
21
* Position of the gap cursor
22
*/
23
$pos: ResolvedPos;
24
25
/**
26
* Side of the gap (-1 for before, 1 for after)
27
*/
28
side: -1 | 1;
29
30
/**
31
* Check if gap cursor is valid at position
32
*/
33
static valid($pos: ResolvedPos): boolean;
34
35
/**
36
* Find gap cursor near position
37
*/
38
static findGapCursorFrom($pos: ResolvedPos, dir: -1 | 1, mustMove?: boolean): GapCursor | null;
39
}
40
41
/**
42
* Create gap cursor plugin
43
*/
44
function gapCursor(): Plugin;
45
```
46
47
### Drop Cursor
48
49
Visual indicator showing where content will be dropped during drag operations.
50
51
```typescript { .api }
52
/**
53
* Create drop cursor plugin
54
*/
55
function dropCursor(options?: DropCursorOptions): Plugin;
56
57
/**
58
* Drop cursor configuration options
59
*/
60
interface DropCursorOptions {
61
/**
62
* Color of the drop cursor (default: black)
63
*/
64
color?: string;
65
66
/**
67
* Width of the drop cursor line (default: 1px)
68
*/
69
width?: number;
70
71
/**
72
* CSS class for the drop cursor
73
*/
74
class?: string;
75
}
76
```
77
78
### Trailing Node
79
80
Ensures documents always end with a specific node type, typically a paragraph.
81
82
```typescript { .api }
83
/**
84
* Create trailing node plugin
85
*/
86
function trailingNode(options: TrailingNodeOptions): Plugin;
87
88
/**
89
* Trailing node configuration options
90
*/
91
interface TrailingNodeOptions {
92
/**
93
* Node type to ensure at document end
94
*/
95
node: string | NodeType;
96
97
/**
98
* Node types that should not be at document end
99
*/
100
notAfter?: (string | NodeType)[];
101
}
102
```
103
104
### Change Tracking
105
106
Track and visualize document changes with detailed metadata.
107
108
```typescript { .api }
109
/**
110
* Represents a span of changed content
111
*/
112
class Span {
113
/**
114
* Create a change span
115
*/
116
constructor(from: number, to: number, data?: any);
117
118
/**
119
* Start position
120
*/
121
from: number;
122
123
/**
124
* End position
125
*/
126
to: number;
127
128
/**
129
* Associated metadata
130
*/
131
data: any;
132
}
133
134
/**
135
* Represents a specific change with content
136
*/
137
class Change {
138
/**
139
* Create a change
140
*/
141
constructor(from: number, to: number, inserted: Fragment, data?: any);
142
143
/**
144
* Start position of change
145
*/
146
from: number;
147
148
/**
149
* End position of change
150
*/
151
to: number;
152
153
/**
154
* Inserted content
155
*/
156
inserted: Fragment;
157
158
/**
159
* Change metadata
160
*/
161
data: any;
162
}
163
164
/**
165
* Tracks all changes in a document
166
*/
167
class ChangeSet {
168
/**
169
* Create change set from document comparison
170
*/
171
static create(doc: Node, changes?: Change[]): ChangeSet;
172
173
/**
174
* Array of changes
175
*/
176
changes: Change[];
177
178
/**
179
* Add a change to the set
180
*/
181
addChange(change: Change): ChangeSet;
182
183
/**
184
* Map change set through transformation
185
*/
186
map(mapping: Mappable): ChangeSet;
187
188
/**
189
* Simplify changes for presentation
190
*/
191
simplify(): ChangeSet;
192
}
193
194
/**
195
* Simplify changes by merging adjacent changes
196
*/
197
function simplifyChanges(changes: Change[], doc: Node): Change[];
198
```
199
200
**Usage Examples:**
201
202
```typescript
203
import {
204
GapCursor,
205
gapCursor,
206
dropCursor,
207
trailingNode,
208
ChangeSet,
209
simplifyChanges
210
} from "@tiptap/pm/gapcursor";
211
import "@tiptap/pm/dropcursor";
212
import "@tiptap/pm/trailing-node";
213
import "@tiptap/pm/changeset";
214
215
// Basic cursor enhancements setup
216
const enhancementPlugins = [
217
// Gap cursor for block navigation
218
gapCursor(),
219
220
// Drop cursor for drag operations
221
dropCursor({
222
color: "#3b82f6",
223
width: 2,
224
class: "custom-drop-cursor"
225
}),
226
227
// Ensure document ends with paragraph
228
trailingNode({
229
node: "paragraph",
230
notAfter: ["heading", "code_block"]
231
})
232
];
233
234
// Create editor with enhancements
235
const state = EditorState.create({
236
schema: mySchema,
237
plugins: enhancementPlugins
238
});
239
240
// Custom gap cursor handling
241
class GapCursorManager {
242
constructor(private view: EditorView) {
243
this.setupKeyboardNavigation();
244
}
245
246
private setupKeyboardNavigation() {
247
const plugin = keymap({
248
"ArrowUp": this.navigateUp.bind(this),
249
"ArrowDown": this.navigateDown.bind(this),
250
"ArrowLeft": this.navigateLeft.bind(this),
251
"ArrowRight": this.navigateRight.bind(this)
252
});
253
254
const newState = this.view.state.reconfigure({
255
plugins: this.view.state.plugins.concat(plugin)
256
});
257
this.view.updateState(newState);
258
}
259
260
private navigateUp(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
261
return this.navigateVertically(state, dispatch, -1);
262
}
263
264
private navigateDown(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
265
return this.navigateVertically(state, dispatch, 1);
266
}
267
268
private navigateVertically(
269
state: EditorState,
270
dispatch?: (tr: Transaction) => void,
271
dir: -1 | 1
272
): boolean {
273
const { selection } = state;
274
275
if (selection instanceof GapCursor) {
276
// Find next gap cursor position
277
const nextGap = GapCursor.findGapCursorFrom(selection.$pos, dir, true);
278
if (nextGap && dispatch) {
279
dispatch(state.tr.setSelection(nextGap));
280
return true;
281
}
282
}
283
284
return false;
285
}
286
287
private navigateLeft(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
288
return this.navigateHorizontally(state, dispatch, -1);
289
}
290
291
private navigateRight(state: EditorState, dispatch?: (tr: Transaction) => void): boolean {
292
return this.navigateHorizontally(state, dispatch, 1);
293
}
294
295
private navigateHorizontally(
296
state: EditorState,
297
dispatch?: (tr: Transaction) => void,
298
dir: -1 | 1
299
): boolean {
300
const { selection } = state;
301
302
// Try to find gap cursor from current selection
303
const $pos = dir === -1 ? selection.$from : selection.$to;
304
const gap = GapCursor.findGapCursorFrom($pos, dir, false);
305
306
if (gap && dispatch) {
307
dispatch(state.tr.setSelection(gap));
308
return true;
309
}
310
311
return false;
312
}
313
}
314
315
// Enhanced drop cursor with custom behavior
316
class EnhancedDropCursor {
317
private plugin: Plugin;
318
319
constructor(options?: DropCursorOptions & {
320
onDrop?: (pos: number, data: any) => void;
321
canDrop?: (pos: number, data: any) => boolean;
322
}) {
323
this.plugin = new Plugin({
324
state: {
325
init: () => null,
326
apply: (tr, value) => {
327
const meta = tr.getMeta("drop-cursor");
328
if (meta !== undefined) {
329
return meta;
330
}
331
return value;
332
}
333
},
334
335
props: {
336
decorations: (state) => {
337
const dropPos = this.plugin.getState(state);
338
if (dropPos) {
339
return this.createDropDecoration(dropPos, options);
340
}
341
return null;
342
},
343
344
handleDrop: (view, event, slice, moved) => {
345
if (options?.onDrop) {
346
const pos = view.posAtCoords({
347
left: event.clientX,
348
top: event.clientY
349
});
350
351
if (pos && (!options.canDrop || options.canDrop(pos.pos, slice))) {
352
options.onDrop(pos.pos, slice);
353
return true;
354
}
355
}
356
return false;
357
},
358
359
handleDOMEvents: {
360
dragover: (view, event) => {
361
const pos = view.posAtCoords({
362
left: event.clientX,
363
top: event.clientY
364
});
365
366
if (pos) {
367
view.dispatch(
368
view.state.tr.setMeta("drop-cursor", pos.pos)
369
);
370
}
371
372
return false;
373
},
374
375
dragleave: (view) => {
376
view.dispatch(
377
view.state.tr.setMeta("drop-cursor", null)
378
);
379
return false;
380
}
381
}
382
}
383
});
384
}
385
386
private createDropDecoration(pos: number, options?: DropCursorOptions): DecorationSet {
387
const widget = document.createElement("div");
388
widget.className = `drop-cursor ${options?.class || ""}`;
389
widget.style.position = "absolute";
390
widget.style.width = `${options?.width || 1}px`;
391
widget.style.backgroundColor = options?.color || "black";
392
widget.style.height = "1.2em";
393
widget.style.pointerEvents = "none";
394
395
return DecorationSet.create(document, [
396
Decoration.widget(pos, widget, { side: 1 })
397
]);
398
}
399
400
getPlugin(): Plugin {
401
return this.plugin;
402
}
403
}
404
405
// Advanced trailing node with custom rules
406
class SmartTrailingNode {
407
constructor(options: {
408
trailingNode: NodeType;
409
rules?: Array<{
410
after: NodeType[];
411
insert: NodeType;
412
attrs?: Attrs;
413
}>;
414
}) {
415
this.setupPlugin(options);
416
}
417
418
private setupPlugin(options: any) {
419
const plugin = new Plugin({
420
appendTransaction: (transactions, oldState, newState) => {
421
const lastTransaction = transactions[transactions.length - 1];
422
if (!lastTransaction?.docChanged) return null;
423
424
return this.ensureTrailingNode(newState, options);
425
}
426
});
427
428
// Add plugin to existing state
429
// Implementation depends on specific usage
430
}
431
432
private ensureTrailingNode(state: EditorState, options: any): Transaction | null {
433
const { doc } = state;
434
const lastChild = doc.lastChild;
435
436
if (!lastChild) {
437
// Empty document - add trailing node
438
const tr = state.tr;
439
const trailingNode = options.trailingNode.createAndFill();
440
tr.insert(doc.content.size, trailingNode);
441
return tr;
442
}
443
444
// Check custom rules
445
if (options.rules) {
446
for (const rule of options.rules) {
447
if (rule.after.includes(lastChild.type)) {
448
const tr = state.tr;
449
const insertNode = rule.insert.create(rule.attrs);
450
tr.insert(doc.content.size, insertNode);
451
return tr;
452
}
453
}
454
}
455
456
// Default trailing node check
457
if (lastChild.type !== options.trailingNode) {
458
const tr = state.tr;
459
const trailingNode = options.trailingNode.createAndFill();
460
tr.insert(doc.content.size, trailingNode);
461
return tr;
462
}
463
464
return null;
465
}
466
}
467
```
468
469
## Advanced Enhancement Features
470
471
### Custom Selection Types
472
473
Create specialized selection types beyond gap cursor.
474
475
```typescript
476
class BlockSelection extends Selection {
477
constructor($pos: ResolvedPos) {
478
super($pos, $pos);
479
}
480
481
static create(doc: Node, pos: number): BlockSelection {
482
const $pos = doc.resolve(pos);
483
return new BlockSelection($pos);
484
}
485
486
map(doc: Node, mapping: Mappable): Selection {
487
const newPos = mapping.map(this.from);
488
return BlockSelection.create(doc, newPos);
489
}
490
491
eq(other: Selection): boolean {
492
return other instanceof BlockSelection && other.from === this.from;
493
}
494
495
getBookmark(): SelectionBookmark {
496
return new BlockBookmark(this.from);
497
}
498
}
499
500
class BlockBookmark implements SelectionBookmark {
501
constructor(private pos: number) {}
502
503
map(mapping: Mappable): SelectionBookmark {
504
return new BlockBookmark(mapping.map(this.pos));
505
}
506
507
resolve(doc: Node): Selection {
508
return BlockSelection.create(doc, this.pos);
509
}
510
}
511
```
512
513
### Visual Enhancement Plugins
514
515
Create plugins that add visual improvements without affecting document structure.
516
517
```typescript
518
class VisualEnhancementPlugin {
519
static createReadingGuide(): Plugin {
520
return new Plugin({
521
state: {
522
init: () => null,
523
apply: (tr, value) => {
524
const meta = tr.getMeta("reading-guide");
525
if (meta !== undefined) return meta;
526
return value;
527
}
528
},
529
530
props: {
531
decorations: (state) => {
532
const linePos = this.getState(state);
533
if (linePos) {
534
return this.createReadingGuide(linePos);
535
}
536
return null;
537
},
538
539
handleDOMEvents: {
540
mousemove: (view, event) => {
541
const pos = view.posAtCoords({
542
left: event.clientX,
543
top: event.clientY
544
});
545
546
if (pos) {
547
view.dispatch(
548
view.state.tr.setMeta("reading-guide", pos.pos)
549
);
550
}
551
552
return false;
553
}
554
}
555
}
556
});
557
}
558
559
private static createReadingGuide(pos: number): DecorationSet {
560
// Create horizontal line decoration
561
const guide = document.createElement("div");
562
guide.className = "reading-guide";
563
guide.style.cssText = `
564
position: absolute;
565
width: 100%;
566
height: 1px;
567
background: rgba(0, 100, 200, 0.3);
568
pointer-events: none;
569
z-index: 1;
570
`;
571
572
return DecorationSet.create(document, [
573
Decoration.widget(pos, guide, { side: 0 })
574
]);
575
}
576
577
static createFocusMode(): Plugin {
578
return new Plugin({
579
state: {
580
init: () => ({ focused: false, paragraph: null }),
581
apply: (tr, value) => {
582
const selection = tr.selection;
583
const $pos = selection.$from;
584
const currentParagraph = $pos.node($pos.depth);
585
586
return {
587
focused: selection.empty,
588
paragraph: currentParagraph
589
};
590
}
591
},
592
593
props: {
594
decorations: (state) => {
595
const pluginState = this.getState(state);
596
if (pluginState.focused && pluginState.paragraph) {
597
return this.createFocusDecorations(state, pluginState.paragraph);
598
}
599
return null;
600
}
601
}
602
});
603
}
604
605
private static createFocusDecorations(state: EditorState, focusedNode: Node): DecorationSet {
606
const decorations: Decoration[] = [];
607
608
// Dim all other paragraphs
609
state.doc.descendants((node, pos) => {
610
if (node.type.name === "paragraph" && node !== focusedNode) {
611
decorations.push(
612
Decoration.node(pos, pos + node.nodeSize, {
613
class: "dimmed-paragraph",
614
style: "opacity: 0.4; transition: opacity 0.2s;"
615
})
616
);
617
}
618
});
619
620
return DecorationSet.create(state.doc, decorations);
621
}
622
}
623
```
624
625
### Change Tracking Integration
626
627
Integrate change tracking with the editor for collaboration features.
628
629
```typescript
630
class ChangeTracker {
631
private changeSet: ChangeSet;
632
private baseDoc: Node;
633
634
constructor(baseDoc: Node) {
635
this.baseDoc = baseDoc;
636
this.changeSet = ChangeSet.create(baseDoc);
637
}
638
639
trackChanges(oldState: EditorState, newState: EditorState): ChangeSet {
640
if (!newState.tr.docChanged) {
641
return this.changeSet;
642
}
643
644
const changes: Change[] = [];
645
646
// Extract changes from transaction steps
647
newState.tr.steps.forEach((step, index) => {
648
if (step instanceof ReplaceStep) {
649
const change = new Change(
650
step.from,
651
step.to,
652
step.slice.content,
653
{
654
timestamp: Date.now(),
655
user: this.getCurrentUser(),
656
type: "replace"
657
}
658
);
659
changes.push(change);
660
}
661
662
if (step instanceof AddMarkStep) {
663
const change = new Change(
664
step.from,
665
step.to,
666
Fragment.empty,
667
{
668
timestamp: Date.now(),
669
user: this.getCurrentUser(),
670
type: "add-mark",
671
mark: step.mark
672
}
673
);
674
changes.push(change);
675
}
676
});
677
678
// Update change set
679
let newChangeSet = this.changeSet;
680
for (const change of changes) {
681
newChangeSet = newChangeSet.addChange(change);
682
}
683
684
this.changeSet = newChangeSet.simplify();
685
return this.changeSet;
686
}
687
688
createChangeDecorations(): DecorationSet {
689
const decorations: Decoration[] = [];
690
691
for (const change of this.changeSet.changes) {
692
const className = `change-${change.data.type}`;
693
const title = `${change.data.user} at ${new Date(change.data.timestamp).toLocaleString()}`;
694
695
decorations.push(
696
Decoration.inline(change.from, change.to, {
697
class: className,
698
title
699
})
700
);
701
}
702
703
return DecorationSet.create(this.baseDoc, decorations);
704
}
705
706
acceptChanges(from?: number, to?: number): ChangeSet {
707
const filteredChanges = this.changeSet.changes.filter(change => {
708
if (from !== undefined && to !== undefined) {
709
return !(change.from >= from && change.to <= to);
710
}
711
return false;
712
});
713
714
this.changeSet = ChangeSet.create(this.baseDoc, filteredChanges);
715
return this.changeSet;
716
}
717
718
rejectChanges(from?: number, to?: number): Node {
719
// Revert changes in the specified range
720
// This would require more complex implementation
721
return this.baseDoc;
722
}
723
724
private getCurrentUser(): string {
725
// Get current user identifier
726
return "current-user";
727
}
728
}
729
```
730
731
### Accessibility Enhancements
732
733
Add accessibility features to cursor and navigation systems.
734
735
```typescript
736
class AccessibilityEnhancer {
737
static createAriaLiveRegion(): Plugin {
738
return new Plugin({
739
view: () => {
740
const liveRegion = document.createElement("div");
741
liveRegion.setAttribute("aria-live", "polite");
742
liveRegion.setAttribute("aria-atomic", "true");
743
liveRegion.style.cssText = `
744
position: absolute;
745
left: -10000px;
746
width: 1px;
747
height: 1px;
748
overflow: hidden;
749
`;
750
document.body.appendChild(liveRegion);
751
752
return {
753
update: (view, prevState) => {
754
if (prevState.selection.eq(view.state.selection)) return;
755
756
const announcement = this.createSelectionAnnouncement(view.state.selection);
757
if (announcement) {
758
liveRegion.textContent = announcement;
759
}
760
},
761
762
destroy: () => {
763
liveRegion.remove();
764
}
765
};
766
}
767
});
768
}
769
770
private static createSelectionAnnouncement(selection: Selection): string {
771
if (selection instanceof GapCursor) {
772
return "Gap cursor between blocks";
773
}
774
775
if (selection.empty) {
776
return `Cursor at position ${selection.from}`;
777
}
778
779
const length = selection.to - selection.from;
780
return `Selected ${length} character${length === 1 ? "" : "s"}`;
781
}
782
783
static createKeyboardNavigation(): Plugin {
784
return keymap({
785
"Alt-ArrowUp": (state, dispatch) => {
786
// Move to previous block
787
return this.navigateToBlock(state, dispatch, -1);
788
},
789
790
"Alt-ArrowDown": (state, dispatch) => {
791
// Move to next block
792
return this.navigateToBlock(state, dispatch, 1);
793
},
794
795
"Ctrl-Home": (state, dispatch) => {
796
// Move to document start
797
if (dispatch) {
798
dispatch(state.tr.setSelection(Selection.atStart(state.doc)));
799
}
800
return true;
801
},
802
803
"Ctrl-End": (state, dispatch) => {
804
// Move to document end
805
if (dispatch) {
806
dispatch(state.tr.setSelection(Selection.atEnd(state.doc)));
807
}
808
return true;
809
}
810
});
811
}
812
813
private static navigateToBlock(
814
state: EditorState,
815
dispatch?: (tr: Transaction) => void,
816
direction: -1 | 1
817
): boolean {
818
const { selection } = state;
819
const $pos = selection.$from;
820
821
// Find next block element
822
let depth = $pos.depth;
823
while (depth > 0) {
824
const node = $pos.node(depth);
825
if (node.isBlock) {
826
const nodePos = $pos.start(depth);
827
const nextPos = direction === -1
828
? nodePos - 1
829
: nodePos + node.nodeSize;
830
831
try {
832
const $nextPos = state.doc.resolve(nextPos);
833
const nextBlock = direction === -1
834
? $nextPos.nodeBefore
835
: $nextPos.nodeAfter;
836
837
if (nextBlock?.isBlock && dispatch) {
838
const targetPos = direction === -1
839
? nextPos - nextBlock.nodeSize + 1
840
: nextPos + 1;
841
842
dispatch(
843
state.tr.setSelection(
844
Selection.near(state.doc.resolve(targetPos))
845
)
846
);
847
return true;
848
}
849
} catch (error) {
850
// Position out of bounds
851
break;
852
}
853
}
854
depth--;
855
}
856
857
return false;
858
}
859
}
860
```
861
862
## Types
863
864
```typescript { .api }
865
/**
866
* Drop cursor configuration options
867
*/
868
interface DropCursorOptions {
869
color?: string;
870
width?: number;
871
class?: string;
872
}
873
874
/**
875
* Trailing node configuration options
876
*/
877
interface TrailingNodeOptions {
878
node: string | NodeType;
879
notAfter?: (string | NodeType)[];
880
}
881
882
/**
883
* Change metadata interface
884
*/
885
interface ChangeData {
886
timestamp: number;
887
user: string;
888
type: string;
889
[key: string]: any;
890
}
891
892
/**
893
* Selection bookmark interface
894
*/
895
interface SelectionBookmark {
896
map(mapping: Mappable): SelectionBookmark;
897
resolve(doc: Node): Selection;
898
}
899
```