or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

collaboration.mdcommands-and-editing.mdcursors-and-enhancements.mdhistory.mdindex.mdinput-and-keymaps.mdmarkdown.mdmenus-and-ui.mdmodel-and-schema.mdschema-definitions.mdstate-management.mdtables.mdtransformations.mdview-and-rendering.md

cursors-and-enhancements.mddocs/

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

```