Comprehensive wrapper around ProseMirror packages providing unified entry point for rich text editing functionality in Tiptap framework
—
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.
Individual menu items with commands and display configuration.
/**
* Individual menu item with command binding and display properties
*/
class MenuItem {
/**
* Create a menu item
*/
constructor(spec: MenuItemSpec);
/**
* Render the menu item as a DOM element
*/
render(view: EditorView): HTMLElement;
}
/**
* Menu item specification
*/
interface MenuItemSpec {
/**
* Command to run when item is selected
*/
run?: Command;
/**
* Function to determine if item is enabled
*/
enable?: (state: EditorState) => boolean;
/**
* Function to determine if item appears selected/active
*/
select?: (state: EditorState) => boolean;
/**
* Display label for the item
*/
label?: string;
/**
* Title attribute (tooltip)
*/
title?: string | ((state: EditorState) => string);
/**
* CSS class name
*/
class?: string;
/**
* Icon to display
*/
icon?: IconSpec;
/**
* Custom rendering function
*/
render?: (view: EditorView) => HTMLElement;
}Container menus that show sub-items when activated.
/**
* Dropdown menu containing multiple menu items
*/
class Dropdown {
/**
* Create a dropdown menu
*/
constructor(content: MenuElement | MenuElement[], options?: DropdownOptions);
/**
* Render the dropdown as a DOM element
*/
render(view: EditorView): HTMLElement;
}
/**
* Submenu within a dropdown
*/
class DropdownSubmenu {
/**
* Create a dropdown submenu
*/
constructor(content: MenuElement | MenuElement[], options?: DropdownOptions);
}
/**
* Menu element interface for items and dropdowns
*/
interface MenuElement {
render(view: EditorView): HTMLElement;
}
/**
* Dropdown configuration options
*/
interface DropdownOptions {
/**
* Label for the dropdown button
*/
label?: string;
/**
* Title attribute
*/
title?: string;
/**
* CSS class name
*/
class?: string;
/**
* Icon for the dropdown
*/
icon?: IconSpec;
}Plugin for creating editor menu bars.
/**
* Create a menu bar plugin
*/
function menuBar(options: MenuBarOptions): Plugin;
/**
* Menu bar configuration options
*/
interface MenuBarOptions {
/**
* Menu content to display
*/
content: MenuElement[][];
/**
* Whether menu floats or is static
*/
floating?: boolean;
}Common menu items for standard editing operations.
/**
* Menu item for joining content up
*/
const joinUpItem: MenuItem;
/**
* Menu item for lifting content out of parent
*/
const liftItem: MenuItem;
/**
* Menu item for selecting parent node
*/
const selectParentNodeItem: MenuItem;
/**
* Menu item for undo operation
*/
const undoItem: MenuItem;
/**
* Menu item for redo operation
*/
const redoItem: MenuItem;Functions for creating common types of menu items.
/**
* Create a menu item for wrapping selection in a node type
*/
function wrapItem(nodeType: NodeType, options: WrapItemOptions): MenuItem;
/**
* Create a menu item for changing block type
*/
function blockTypeItem(nodeType: NodeType, options: BlockTypeItemOptions): MenuItem;
/**
* Render grouped menu items with separators
*/
function renderGrouped(view: EditorView, content: MenuElement[][]): DocumentFragment;Icon specifications and built-in icon library.
/**
* Icon specification
*/
interface IconSpec {
/**
* Icon width
*/
width: number;
/**
* Icon height
*/
height: number;
/**
* SVG path data
*/
path: string;
}
/**
* Built-in icon library
*/
const icons: {
join: IconSpec;
lift: IconSpec;
selectParentNode: IconSpec;
undo: IconSpec;
redo: IconSpec;
strong: IconSpec;
em: IconSpec;
code: IconSpec;
link: IconSpec;
bulletList: IconSpec;
orderedList: IconSpec;
blockquote: IconSpec;
};Usage Examples:
import {
MenuItem,
Dropdown,
menuBar,
icons,
wrapItem,
blockTypeItem,
joinUpItem,
liftItem,
undoItem,
redoItem,
renderGrouped
} from "@tiptap/pm/menu";
import { toggleMark, setBlockType } from "@tiptap/pm/commands";
// Create basic menu items
const boldItem = new MenuItem({
title: "Toggle strong emphasis",
label: "Bold",
icon: icons.strong,
run: toggleMark(schema.marks.strong),
enable: state => !state.selection.empty || toggleMark(schema.marks.strong)(state),
select: state => {
const { from, to } = state.selection;
return state.doc.rangeHasMark(from, to, schema.marks.strong);
}
});
const italicItem = new MenuItem({
title: "Toggle emphasis",
label: "Italic",
icon: icons.em,
run: toggleMark(schema.marks.em),
enable: state => toggleMark(schema.marks.em)(state),
select: state => {
const { from, to } = state.selection;
return state.doc.rangeHasMark(from, to, schema.marks.em);
}
});
// Block type menu items
const paragraphItem = blockTypeItem(schema.nodes.paragraph, {
title: "Change to paragraph",
label: "Plain"
});
const h1Item = blockTypeItem(schema.nodes.heading, {
title: "Change to heading",
label: "Heading 1",
attrs: { level: 1 }
});
const h2Item = blockTypeItem(schema.nodes.heading, {
title: "Change to heading 2",
label: "Heading 2",
attrs: { level: 2 }
});
// Wrap menu items
const blockquoteItem = wrapItem(schema.nodes.blockquote, {
title: "Wrap in block quote",
label: "Blockquote",
icon: icons.blockquote
});
// Custom menu item with dynamic behavior
const linkItem = new MenuItem({
title: "Add or remove link",
icon: icons.link,
run(state, dispatch, view) {
if (state.selection.empty) return false;
const { from, to } = state.selection;
const existingLink = state.doc.rangeHasMark(from, to, schema.marks.link);
if (existingLink) {
// Remove link
if (dispatch) {
dispatch(state.tr.removeMark(from, to, schema.marks.link));
}
} else {
// Add link
const href = prompt("Enter URL:");
if (href && dispatch) {
dispatch(state.tr.addMark(from, to, schema.marks.link.create({ href })));
}
}
return true;
},
enable: state => !state.selection.empty,
select: state => {
const { from, to } = state.selection;
return state.doc.rangeHasMark(from, to, schema.marks.link);
}
});
// Dropdown menus
const headingDropdown = new Dropdown([paragraphItem, h1Item, h2Item], {
label: "Heading"
});
const formatDropdown = new Dropdown([boldItem, italicItem, linkItem], {
label: "Format",
title: "Formatting options"
});
// Create menu bar
const menuPlugin = menuBar({
floating: false,
content: [
[undoItem, redoItem],
[headingDropdown, formatDropdown],
[blockquoteItem],
[joinUpItem, liftItem]
]
});
// Add to editor
const state = EditorState.create({
schema: mySchema,
plugins: [menuPlugin]
});
// Custom menu rendering
class CustomMenuBar {
private element: HTMLElement;
constructor(private view: EditorView, items: MenuElement[][]) {
this.element = this.createElement();
this.renderMenuItems(items);
this.attachToView();
}
private createElement(): HTMLElement {
const menuBar = document.createElement("div");
menuBar.className = "custom-menu-bar";
return menuBar;
}
private renderMenuItems(itemGroups: MenuElement[][]) {
itemGroups.forEach((group, index) => {
if (index > 0) {
const separator = document.createElement("div");
separator.className = "menu-separator";
this.element.appendChild(separator);
}
const groupElement = document.createElement("div");
groupElement.className = "menu-group";
group.forEach(item => {
const itemElement = item.render(this.view);
groupElement.appendChild(itemElement);
});
this.element.appendChild(groupElement);
});
}
private attachToView() {
// Insert menu before editor
this.view.dom.parentNode?.insertBefore(this.element, this.view.dom);
// Update menu on state changes
this.view.setProps({
dispatchTransaction: (tr) => {
this.view.updateState(this.view.state.apply(tr));
this.updateMenuItems();
}
});
}
private updateMenuItems() {
// Re-render menu items to reflect current state
const items = this.element.querySelectorAll(".menu-item");
items.forEach((item: HTMLElement) => {
const menuItem = item.menuItemInstance as MenuItem;
if (menuItem) {
this.updateMenuItem(item, menuItem);
}
});
}
private updateMenuItem(element: HTMLElement, menuItem: MenuItem) {
const spec = menuItem.spec;
// Update enabled state
if (spec.enable) {
const enabled = spec.enable(this.view.state);
element.classList.toggle("disabled", !enabled);
}
// Update selected state
if (spec.select) {
const selected = spec.select(this.view.state);
element.classList.toggle("selected", selected);
}
// Update title
if (typeof spec.title === "function") {
element.title = spec.title(this.view.state);
}
}
}Create context menus that appear on right-click.
class ContextMenuManager {
private currentMenu: HTMLElement | null = null;
constructor(private view: EditorView) {
this.setupContextMenu();
}
private setupContextMenu() {
this.view.dom.addEventListener("contextmenu", (event) => {
event.preventDefault();
this.showContextMenu(event.clientX, event.clientY);
});
document.addEventListener("click", () => {
this.hideContextMenu();
});
}
private showContextMenu(x: number, y: number) {
this.hideContextMenu();
const menu = this.createContextMenu();
menu.style.position = "fixed";
menu.style.left = x + "px";
menu.style.top = y + "px";
menu.style.zIndex = "1000";
document.body.appendChild(menu);
this.currentMenu = menu;
}
private createContextMenu(): HTMLElement {
const menu = document.createElement("div");
menu.className = "context-menu";
const menuItems = this.getContextMenuItems();
menuItems.forEach(item => {
const itemElement = item.render(this.view);
itemElement.className += " context-menu-item";
menu.appendChild(itemElement);
});
return menu;
}
private getContextMenuItems(): MenuItem[] {
const items: MenuItem[] = [];
const state = this.view.state;
// Cut/Copy/Paste
if (!state.selection.empty) {
items.push(
new MenuItem({
label: "Cut",
run: () => document.execCommand("cut"),
enable: () => !state.selection.empty
}),
new MenuItem({
label: "Copy",
run: () => document.execCommand("copy"),
enable: () => !state.selection.empty
})
);
}
items.push(
new MenuItem({
label: "Paste",
run: () => document.execCommand("paste")
})
);
// Selection-specific items
if (!state.selection.empty) {
items.push(
new MenuItem({
label: "Bold",
run: toggleMark(schema.marks.strong),
select: (state) => {
const { from, to } = state.selection;
return state.doc.rangeHasMark(from, to, schema.marks.strong);
}
}),
new MenuItem({
label: "Italic",
run: toggleMark(schema.marks.em),
select: (state) => {
const { from, to } = state.selection;
return state.doc.rangeHasMark(from, to, schema.marks.em);
}
})
);
}
return items;
}
private hideContextMenu() {
if (this.currentMenu) {
this.currentMenu.remove();
this.currentMenu = null;
}
}
}Create menus that appear above selected text.
class FloatingMenu {
private menu: HTMLElement;
private isVisible = false;
constructor(private view: EditorView, private items: MenuItem[]) {
this.menu = this.createMenu();
this.setupSelectionListener();
}
private createMenu(): HTMLElement {
const menu = document.createElement("div");
menu.className = "floating-menu";
menu.style.position = "absolute";
menu.style.display = "none";
menu.style.zIndex = "100";
this.items.forEach(item => {
const itemElement = item.render(this.view);
menu.appendChild(itemElement);
});
document.body.appendChild(menu);
return menu;
}
private setupSelectionListener() {
const plugin = new Plugin({
view: () => ({
update: (view, prevState) => {
if (prevState.selection.eq(view.state.selection)) return;
this.updateMenuPosition();
}
})
});
const newState = this.view.state.reconfigure({
plugins: this.view.state.plugins.concat(plugin)
});
this.view.updateState(newState);
}
private updateMenuPosition() {
const { selection } = this.view.state;
if (selection.empty) {
this.hideMenu();
return;
}
const { from, to } = selection;
const start = this.view.coordsAtPos(from);
const end = this.view.coordsAtPos(to);
// Position menu above selection
const rect = {
left: Math.min(start.left, end.left),
right: Math.max(start.right, end.right),
top: Math.min(start.top, end.top),
bottom: Math.max(start.bottom, end.bottom)
};
this.menu.style.left = rect.left + "px";
this.menu.style.top = (rect.top - this.menu.offsetHeight - 10) + "px";
this.showMenu();
}
private showMenu() {
if (!this.isVisible) {
this.menu.style.display = "block";
this.isVisible = true;
// Update item states
this.items.forEach((item, index) => {
const element = this.menu.children[index] as HTMLElement;
this.updateMenuItemState(element, item);
});
}
}
private hideMenu() {
if (this.isVisible) {
this.menu.style.display = "none";
this.isVisible = false;
}
}
private updateMenuItemState(element: HTMLElement, item: MenuItem) {
const spec = item.spec;
if (spec.enable) {
const enabled = spec.enable(this.view.state);
element.classList.toggle("disabled", !enabled);
}
if (spec.select) {
const selected = spec.select(this.view.state);
element.classList.toggle("active", selected);
}
}
}Create different visual themes for menus.
interface MenuTheme {
name: string;
styles: {
menuBar: string;
menuItem: string;
menuItemActive: string;
menuItemDisabled: string;
dropdown: string;
separator: string;
};
}
class MenuThemeManager {
private currentTheme: string = "default";
private themes: { [name: string]: MenuTheme } = {
default: {
name: "Default",
styles: {
menuBar: "background: #f0f0f0; border-bottom: 1px solid #ccc; padding: 8px;",
menuItem: "padding: 6px 12px; cursor: pointer; border-radius: 3px;",
menuItemActive: "background: #007acc; color: white;",
menuItemDisabled: "opacity: 0.5; cursor: not-allowed;",
dropdown: "position: relative; display: inline-block;",
separator: "width: 1px; background: #ccc; margin: 0 4px;"
}
},
dark: {
name: "Dark",
styles: {
menuBar: "background: #2d2d2d; border-bottom: 1px solid #555; padding: 8px;",
menuItem: "padding: 6px 12px; cursor: pointer; color: #fff; border-radius: 3px;",
menuItemActive: "background: #0e639c; color: white;",
menuItemDisabled: "opacity: 0.4; cursor: not-allowed;",
dropdown: "position: relative; display: inline-block;",
separator: "width: 1px; background: #555; margin: 0 4px;"
}
},
minimal: {
name: "Minimal",
styles: {
menuBar: "background: transparent; padding: 4px;",
menuItem: "padding: 4px 8px; cursor: pointer; border-radius: 2px;",
menuItemActive: "background: #f5f5f5;",
menuItemDisabled: "opacity: 0.6; cursor: not-allowed;",
dropdown: "position: relative; display: inline-block;",
separator: "width: 1px; background: #e0e0e0; margin: 0 2px;"
}
}
};
applyTheme(themeName: string, menuElement: HTMLElement) {
const theme = this.themes[themeName];
if (!theme) return;
this.currentTheme = themeName;
this.applyStyles(menuElement, theme);
}
private applyStyles(element: HTMLElement, theme: MenuTheme) {
// Apply menu bar styles
if (element.classList.contains("menu-bar")) {
element.style.cssText = theme.styles.menuBar;
}
// Apply to all menu items
const items = element.querySelectorAll(".menu-item");
items.forEach((item: HTMLElement) => {
item.style.cssText = theme.styles.menuItem;
if (item.classList.contains("active")) {
item.style.cssText += theme.styles.menuItemActive;
}
if (item.classList.contains("disabled")) {
item.style.cssText += theme.styles.menuItemDisabled;
}
});
// Apply separator styles
const separators = element.querySelectorAll(".menu-separator");
separators.forEach((sep: HTMLElement) => {
sep.style.cssText = theme.styles.separator;
});
}
getCurrentTheme(): string {
return this.currentTheme;
}
getAvailableThemes(): string[] {
return Object.keys(this.themes);
}
}/**
* Menu item specification interface
*/
interface MenuItemSpec {
run?: Command;
enable?: (state: EditorState) => boolean;
select?: (state: EditorState) => boolean;
label?: string;
title?: string | ((state: EditorState) => string);
class?: string;
icon?: IconSpec;
render?: (view: EditorView) => HTMLElement;
}
/**
* Wrap item options
*/
interface WrapItemOptions {
title?: string;
label?: string;
icon?: IconSpec;
attrs?: Attrs;
}
/**
* Block type item options
*/
interface BlockTypeItemOptions {
title?: string;
label?: string;
attrs?: Attrs;
}
/**
* Menu bar options
*/
interface MenuBarOptions {
content: MenuElement[][];
floating?: boolean;
}Install with Tessl CLI
npx tessl i tessl/npm-tiptap--pm