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
```