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

menus-and-ui.mddocs/

0

# Menus and UI

1

2

The menu system provides rich user interface components for editor toolbars and context menus. It includes pre-built menu items, dropdown menus, and customizable menu bars with extensive styling options.

3

4

## Capabilities

5

6

### Menu Items

7

8

Individual menu items with commands and display configuration.

9

10

```typescript { .api }

11

/**

12

* Individual menu item with command binding and display properties

13

*/

14

class MenuItem {

15

/**

16

* Create a menu item

17

*/

18

constructor(spec: MenuItemSpec);

19

20

/**

21

* Render the menu item as a DOM element

22

*/

23

render(view: EditorView): HTMLElement;

24

}

25

26

/**

27

* Menu item specification

28

*/

29

interface MenuItemSpec {

30

/**

31

* Command to run when item is selected

32

*/

33

run?: Command;

34

35

/**

36

* Function to determine if item is enabled

37

*/

38

enable?: (state: EditorState) => boolean;

39

40

/**

41

* Function to determine if item appears selected/active

42

*/

43

select?: (state: EditorState) => boolean;

44

45

/**

46

* Display label for the item

47

*/

48

label?: string;

49

50

/**

51

* Title attribute (tooltip)

52

*/

53

title?: string | ((state: EditorState) => string);

54

55

/**

56

* CSS class name

57

*/

58

class?: string;

59

60

/**

61

* Icon to display

62

*/

63

icon?: IconSpec;

64

65

/**

66

* Custom rendering function

67

*/

68

render?: (view: EditorView) => HTMLElement;

69

}

70

```

71

72

### Dropdown Menus

73

74

Container menus that show sub-items when activated.

75

76

```typescript { .api }

77

/**

78

* Dropdown menu containing multiple menu items

79

*/

80

class Dropdown {

81

/**

82

* Create a dropdown menu

83

*/

84

constructor(content: MenuElement | MenuElement[], options?: DropdownOptions);

85

86

/**

87

* Render the dropdown as a DOM element

88

*/

89

render(view: EditorView): HTMLElement;

90

}

91

92

/**

93

* Submenu within a dropdown

94

*/

95

class DropdownSubmenu {

96

/**

97

* Create a dropdown submenu

98

*/

99

constructor(content: MenuElement | MenuElement[], options?: DropdownOptions);

100

}

101

102

/**

103

* Menu element interface for items and dropdowns

104

*/

105

interface MenuElement {

106

render(view: EditorView): HTMLElement;

107

}

108

109

/**

110

* Dropdown configuration options

111

*/

112

interface DropdownOptions {

113

/**

114

* Label for the dropdown button

115

*/

116

label?: string;

117

118

/**

119

* Title attribute

120

*/

121

title?: string;

122

123

/**

124

* CSS class name

125

*/

126

class?: string;

127

128

/**

129

* Icon for the dropdown

130

*/

131

icon?: IconSpec;

132

}

133

```

134

135

### Menu Bar Plugin

136

137

Plugin for creating editor menu bars.

138

139

```typescript { .api }

140

/**

141

* Create a menu bar plugin

142

*/

143

function menuBar(options: MenuBarOptions): Plugin;

144

145

/**

146

* Menu bar configuration options

147

*/

148

interface MenuBarOptions {

149

/**

150

* Menu content to display

151

*/

152

content: MenuElement[][];

153

154

/**

155

* Whether menu floats or is static

156

*/

157

floating?: boolean;

158

}

159

```

160

161

### Predefined Menu Items

162

163

Common menu items for standard editing operations.

164

165

```typescript { .api }

166

/**

167

* Menu item for joining content up

168

*/

169

const joinUpItem: MenuItem;

170

171

/**

172

* Menu item for lifting content out of parent

173

*/

174

const liftItem: MenuItem;

175

176

/**

177

* Menu item for selecting parent node

178

*/

179

const selectParentNodeItem: MenuItem;

180

181

/**

182

* Menu item for undo operation

183

*/

184

const undoItem: MenuItem;

185

186

/**

187

* Menu item for redo operation

188

*/

189

const redoItem: MenuItem;

190

```

191

192

### Menu Item Builders

193

194

Functions for creating common types of menu items.

195

196

```typescript { .api }

197

/**

198

* Create a menu item for wrapping selection in a node type

199

*/

200

function wrapItem(nodeType: NodeType, options: WrapItemOptions): MenuItem;

201

202

/**

203

* Create a menu item for changing block type

204

*/

205

function blockTypeItem(nodeType: NodeType, options: BlockTypeItemOptions): MenuItem;

206

207

/**

208

* Render grouped menu items with separators

209

*/

210

function renderGrouped(view: EditorView, content: MenuElement[][]): DocumentFragment;

211

```

212

213

### Icon System

214

215

Icon specifications and built-in icon library.

216

217

```typescript { .api }

218

/**

219

* Icon specification

220

*/

221

interface IconSpec {

222

/**

223

* Icon width

224

*/

225

width: number;

226

227

/**

228

* Icon height

229

*/

230

height: number;

231

232

/**

233

* SVG path data

234

*/

235

path: string;

236

}

237

238

/**

239

* Built-in icon library

240

*/

241

const icons: {

242

join: IconSpec;

243

lift: IconSpec;

244

selectParentNode: IconSpec;

245

undo: IconSpec;

246

redo: IconSpec;

247

strong: IconSpec;

248

em: IconSpec;

249

code: IconSpec;

250

link: IconSpec;

251

bulletList: IconSpec;

252

orderedList: IconSpec;

253

blockquote: IconSpec;

254

};

255

```

256

257

**Usage Examples:**

258

259

```typescript

260

import {

261

MenuItem,

262

Dropdown,

263

menuBar,

264

icons,

265

wrapItem,

266

blockTypeItem,

267

joinUpItem,

268

liftItem,

269

undoItem,

270

redoItem,

271

renderGrouped

272

} from "@tiptap/pm/menu";

273

import { toggleMark, setBlockType } from "@tiptap/pm/commands";

274

275

// Create basic menu items

276

const boldItem = new MenuItem({

277

title: "Toggle strong emphasis",

278

label: "Bold",

279

icon: icons.strong,

280

run: toggleMark(schema.marks.strong),

281

enable: state => !state.selection.empty || toggleMark(schema.marks.strong)(state),

282

select: state => {

283

const { from, to } = state.selection;

284

return state.doc.rangeHasMark(from, to, schema.marks.strong);

285

}

286

});

287

288

const italicItem = new MenuItem({

289

title: "Toggle emphasis",

290

label: "Italic",

291

icon: icons.em,

292

run: toggleMark(schema.marks.em),

293

enable: state => toggleMark(schema.marks.em)(state),

294

select: state => {

295

const { from, to } = state.selection;

296

return state.doc.rangeHasMark(from, to, schema.marks.em);

297

}

298

});

299

300

// Block type menu items

301

const paragraphItem = blockTypeItem(schema.nodes.paragraph, {

302

title: "Change to paragraph",

303

label: "Plain"

304

});

305

306

const h1Item = blockTypeItem(schema.nodes.heading, {

307

title: "Change to heading",

308

label: "Heading 1",

309

attrs: { level: 1 }

310

});

311

312

const h2Item = blockTypeItem(schema.nodes.heading, {

313

title: "Change to heading 2",

314

label: "Heading 2",

315

attrs: { level: 2 }

316

});

317

318

// Wrap menu items

319

const blockquoteItem = wrapItem(schema.nodes.blockquote, {

320

title: "Wrap in block quote",

321

label: "Blockquote",

322

icon: icons.blockquote

323

});

324

325

// Custom menu item with dynamic behavior

326

const linkItem = new MenuItem({

327

title: "Add or remove link",

328

icon: icons.link,

329

run(state, dispatch, view) {

330

if (state.selection.empty) return false;

331

332

const { from, to } = state.selection;

333

const existingLink = state.doc.rangeHasMark(from, to, schema.marks.link);

334

335

if (existingLink) {

336

// Remove link

337

if (dispatch) {

338

dispatch(state.tr.removeMark(from, to, schema.marks.link));

339

}

340

} else {

341

// Add link

342

const href = prompt("Enter URL:");

343

if (href && dispatch) {

344

dispatch(state.tr.addMark(from, to, schema.marks.link.create({ href })));

345

}

346

}

347

348

return true;

349

},

350

enable: state => !state.selection.empty,

351

select: state => {

352

const { from, to } = state.selection;

353

return state.doc.rangeHasMark(from, to, schema.marks.link);

354

}

355

});

356

357

// Dropdown menus

358

const headingDropdown = new Dropdown([paragraphItem, h1Item, h2Item], {

359

label: "Heading"

360

});

361

362

const formatDropdown = new Dropdown([boldItem, italicItem, linkItem], {

363

label: "Format",

364

title: "Formatting options"

365

});

366

367

// Create menu bar

368

const menuPlugin = menuBar({

369

floating: false,

370

content: [

371

[undoItem, redoItem],

372

[headingDropdown, formatDropdown],

373

[blockquoteItem],

374

[joinUpItem, liftItem]

375

]

376

});

377

378

// Add to editor

379

const state = EditorState.create({

380

schema: mySchema,

381

plugins: [menuPlugin]

382

});

383

384

// Custom menu rendering

385

class CustomMenuBar {

386

private element: HTMLElement;

387

388

constructor(private view: EditorView, items: MenuElement[][]) {

389

this.element = this.createElement();

390

this.renderMenuItems(items);

391

this.attachToView();

392

}

393

394

private createElement(): HTMLElement {

395

const menuBar = document.createElement("div");

396

menuBar.className = "custom-menu-bar";

397

return menuBar;

398

}

399

400

private renderMenuItems(itemGroups: MenuElement[][]) {

401

itemGroups.forEach((group, index) => {

402

if (index > 0) {

403

const separator = document.createElement("div");

404

separator.className = "menu-separator";

405

this.element.appendChild(separator);

406

}

407

408

const groupElement = document.createElement("div");

409

groupElement.className = "menu-group";

410

411

group.forEach(item => {

412

const itemElement = item.render(this.view);

413

groupElement.appendChild(itemElement);

414

});

415

416

this.element.appendChild(groupElement);

417

});

418

}

419

420

private attachToView() {

421

// Insert menu before editor

422

this.view.dom.parentNode?.insertBefore(this.element, this.view.dom);

423

424

// Update menu on state changes

425

this.view.setProps({

426

dispatchTransaction: (tr) => {

427

this.view.updateState(this.view.state.apply(tr));

428

this.updateMenuItems();

429

}

430

});

431

}

432

433

private updateMenuItems() {

434

// Re-render menu items to reflect current state

435

const items = this.element.querySelectorAll(".menu-item");

436

items.forEach((item: HTMLElement) => {

437

const menuItem = item.menuItemInstance as MenuItem;

438

if (menuItem) {

439

this.updateMenuItem(item, menuItem);

440

}

441

});

442

}

443

444

private updateMenuItem(element: HTMLElement, menuItem: MenuItem) {

445

const spec = menuItem.spec;

446

447

// Update enabled state

448

if (spec.enable) {

449

const enabled = spec.enable(this.view.state);

450

element.classList.toggle("disabled", !enabled);

451

}

452

453

// Update selected state

454

if (spec.select) {

455

const selected = spec.select(this.view.state);

456

element.classList.toggle("selected", selected);

457

}

458

459

// Update title

460

if (typeof spec.title === "function") {

461

element.title = spec.title(this.view.state);

462

}

463

}

464

}

465

```

466

467

## Advanced Menu Features

468

469

### Context Menus

470

471

Create context menus that appear on right-click.

472

473

```typescript

474

class ContextMenuManager {

475

private currentMenu: HTMLElement | null = null;

476

477

constructor(private view: EditorView) {

478

this.setupContextMenu();

479

}

480

481

private setupContextMenu() {

482

this.view.dom.addEventListener("contextmenu", (event) => {

483

event.preventDefault();

484

this.showContextMenu(event.clientX, event.clientY);

485

});

486

487

document.addEventListener("click", () => {

488

this.hideContextMenu();

489

});

490

}

491

492

private showContextMenu(x: number, y: number) {

493

this.hideContextMenu();

494

495

const menu = this.createContextMenu();

496

menu.style.position = "fixed";

497

menu.style.left = x + "px";

498

menu.style.top = y + "px";

499

menu.style.zIndex = "1000";

500

501

document.body.appendChild(menu);

502

this.currentMenu = menu;

503

}

504

505

private createContextMenu(): HTMLElement {

506

const menu = document.createElement("div");

507

menu.className = "context-menu";

508

509

const menuItems = this.getContextMenuItems();

510

menuItems.forEach(item => {

511

const itemElement = item.render(this.view);

512

itemElement.className += " context-menu-item";

513

menu.appendChild(itemElement);

514

});

515

516

return menu;

517

}

518

519

private getContextMenuItems(): MenuItem[] {

520

const items: MenuItem[] = [];

521

const state = this.view.state;

522

523

// Cut/Copy/Paste

524

if (!state.selection.empty) {

525

items.push(

526

new MenuItem({

527

label: "Cut",

528

run: () => document.execCommand("cut"),

529

enable: () => !state.selection.empty

530

}),

531

new MenuItem({

532

label: "Copy",

533

run: () => document.execCommand("copy"),

534

enable: () => !state.selection.empty

535

})

536

);

537

}

538

539

items.push(

540

new MenuItem({

541

label: "Paste",

542

run: () => document.execCommand("paste")

543

})

544

);

545

546

// Selection-specific items

547

if (!state.selection.empty) {

548

items.push(

549

new MenuItem({

550

label: "Bold",

551

run: toggleMark(schema.marks.strong),

552

select: (state) => {

553

const { from, to } = state.selection;

554

return state.doc.rangeHasMark(from, to, schema.marks.strong);

555

}

556

}),

557

new MenuItem({

558

label: "Italic",

559

run: toggleMark(schema.marks.em),

560

select: (state) => {

561

const { from, to } = state.selection;

562

return state.doc.rangeHasMark(from, to, schema.marks.em);

563

}

564

})

565

);

566

}

567

568

return items;

569

}

570

571

private hideContextMenu() {

572

if (this.currentMenu) {

573

this.currentMenu.remove();

574

this.currentMenu = null;

575

}

576

}

577

}

578

```

579

580

### Floating Menus

581

582

Create menus that appear above selected text.

583

584

```typescript

585

class FloatingMenu {

586

private menu: HTMLElement;

587

private isVisible = false;

588

589

constructor(private view: EditorView, private items: MenuItem[]) {

590

this.menu = this.createMenu();

591

this.setupSelectionListener();

592

}

593

594

private createMenu(): HTMLElement {

595

const menu = document.createElement("div");

596

menu.className = "floating-menu";

597

menu.style.position = "absolute";

598

menu.style.display = "none";

599

menu.style.zIndex = "100";

600

601

this.items.forEach(item => {

602

const itemElement = item.render(this.view);

603

menu.appendChild(itemElement);

604

});

605

606

document.body.appendChild(menu);

607

return menu;

608

}

609

610

private setupSelectionListener() {

611

const plugin = new Plugin({

612

view: () => ({

613

update: (view, prevState) => {

614

if (prevState.selection.eq(view.state.selection)) return;

615

this.updateMenuPosition();

616

}

617

})

618

});

619

620

const newState = this.view.state.reconfigure({

621

plugins: this.view.state.plugins.concat(plugin)

622

});

623

this.view.updateState(newState);

624

}

625

626

private updateMenuPosition() {

627

const { selection } = this.view.state;

628

629

if (selection.empty) {

630

this.hideMenu();

631

return;

632

}

633

634

const { from, to } = selection;

635

const start = this.view.coordsAtPos(from);

636

const end = this.view.coordsAtPos(to);

637

638

// Position menu above selection

639

const rect = {

640

left: Math.min(start.left, end.left),

641

right: Math.max(start.right, end.right),

642

top: Math.min(start.top, end.top),

643

bottom: Math.max(start.bottom, end.bottom)

644

};

645

646

this.menu.style.left = rect.left + "px";

647

this.menu.style.top = (rect.top - this.menu.offsetHeight - 10) + "px";

648

649

this.showMenu();

650

}

651

652

private showMenu() {

653

if (!this.isVisible) {

654

this.menu.style.display = "block";

655

this.isVisible = true;

656

657

// Update item states

658

this.items.forEach((item, index) => {

659

const element = this.menu.children[index] as HTMLElement;

660

this.updateMenuItemState(element, item);

661

});

662

}

663

}

664

665

private hideMenu() {

666

if (this.isVisible) {

667

this.menu.style.display = "none";

668

this.isVisible = false;

669

}

670

}

671

672

private updateMenuItemState(element: HTMLElement, item: MenuItem) {

673

const spec = item.spec;

674

675

if (spec.enable) {

676

const enabled = spec.enable(this.view.state);

677

element.classList.toggle("disabled", !enabled);

678

}

679

680

if (spec.select) {

681

const selected = spec.select(this.view.state);

682

element.classList.toggle("active", selected);

683

}

684

}

685

}

686

```

687

688

### Menu Themes

689

690

Create different visual themes for menus.

691

692

```typescript

693

interface MenuTheme {

694

name: string;

695

styles: {

696

menuBar: string;

697

menuItem: string;

698

menuItemActive: string;

699

menuItemDisabled: string;

700

dropdown: string;

701

separator: string;

702

};

703

}

704

705

class MenuThemeManager {

706

private currentTheme: string = "default";

707

708

private themes: { [name: string]: MenuTheme } = {

709

default: {

710

name: "Default",

711

styles: {

712

menuBar: "background: #f0f0f0; border-bottom: 1px solid #ccc; padding: 8px;",

713

menuItem: "padding: 6px 12px; cursor: pointer; border-radius: 3px;",

714

menuItemActive: "background: #007acc; color: white;",

715

menuItemDisabled: "opacity: 0.5; cursor: not-allowed;",

716

dropdown: "position: relative; display: inline-block;",

717

separator: "width: 1px; background: #ccc; margin: 0 4px;"

718

}

719

},

720

721

dark: {

722

name: "Dark",

723

styles: {

724

menuBar: "background: #2d2d2d; border-bottom: 1px solid #555; padding: 8px;",

725

menuItem: "padding: 6px 12px; cursor: pointer; color: #fff; border-radius: 3px;",

726

menuItemActive: "background: #0e639c; color: white;",

727

menuItemDisabled: "opacity: 0.4; cursor: not-allowed;",

728

dropdown: "position: relative; display: inline-block;",

729

separator: "width: 1px; background: #555; margin: 0 4px;"

730

}

731

},

732

733

minimal: {

734

name: "Minimal",

735

styles: {

736

menuBar: "background: transparent; padding: 4px;",

737

menuItem: "padding: 4px 8px; cursor: pointer; border-radius: 2px;",

738

menuItemActive: "background: #f5f5f5;",

739

menuItemDisabled: "opacity: 0.6; cursor: not-allowed;",

740

dropdown: "position: relative; display: inline-block;",

741

separator: "width: 1px; background: #e0e0e0; margin: 0 2px;"

742

}

743

}

744

};

745

746

applyTheme(themeName: string, menuElement: HTMLElement) {

747

const theme = this.themes[themeName];

748

if (!theme) return;

749

750

this.currentTheme = themeName;

751

this.applyStyles(menuElement, theme);

752

}

753

754

private applyStyles(element: HTMLElement, theme: MenuTheme) {

755

// Apply menu bar styles

756

if (element.classList.contains("menu-bar")) {

757

element.style.cssText = theme.styles.menuBar;

758

}

759

760

// Apply to all menu items

761

const items = element.querySelectorAll(".menu-item");

762

items.forEach((item: HTMLElement) => {

763

item.style.cssText = theme.styles.menuItem;

764

765

if (item.classList.contains("active")) {

766

item.style.cssText += theme.styles.menuItemActive;

767

}

768

769

if (item.classList.contains("disabled")) {

770

item.style.cssText += theme.styles.menuItemDisabled;

771

}

772

});

773

774

// Apply separator styles

775

const separators = element.querySelectorAll(".menu-separator");

776

separators.forEach((sep: HTMLElement) => {

777

sep.style.cssText = theme.styles.separator;

778

});

779

}

780

781

getCurrentTheme(): string {

782

return this.currentTheme;

783

}

784

785

getAvailableThemes(): string[] {

786

return Object.keys(this.themes);

787

}

788

}

789

```

790

791

## Types

792

793

```typescript { .api }

794

/**

795

* Menu item specification interface

796

*/

797

interface MenuItemSpec {

798

run?: Command;

799

enable?: (state: EditorState) => boolean;

800

select?: (state: EditorState) => boolean;

801

label?: string;

802

title?: string | ((state: EditorState) => string);

803

class?: string;

804

icon?: IconSpec;

805

render?: (view: EditorView) => HTMLElement;

806

}

807

808

/**

809

* Wrap item options

810

*/

811

interface WrapItemOptions {

812

title?: string;

813

label?: string;

814

icon?: IconSpec;

815

attrs?: Attrs;

816

}

817

818

/**

819

* Block type item options

820

*/

821

interface BlockTypeItemOptions {

822

title?: string;

823

label?: string;

824

attrs?: Attrs;

825

}

826

827

/**

828

* Menu bar options

829

*/

830

interface MenuBarOptions {

831

content: MenuElement[][];

832

floating?: boolean;

833

}

834

```