CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-tiptap--pm

Comprehensive wrapper around ProseMirror packages providing unified entry point for rich text editing functionality in Tiptap framework

Pending
Overview
Eval results
Files

menus-and-ui.mddocs/

Menus and UI

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.

Capabilities

Menu Items

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;
}

Dropdown Menus

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;
}

Menu Bar Plugin

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;
}

Predefined Menu Items

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;

Menu Item Builders

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 System

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);
    }
  }
}

Advanced Menu Features

Context Menus

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;
    }
  }
}

Floating Menus

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);
    }
  }
}

Menu Themes

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);
  }
}

Types

/**
 * 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

docs

collaboration.md

commands-and-editing.md

cursors-and-enhancements.md

history.md

index.md

input-and-keymaps.md

markdown.md

menus-and-ui.md

model-and-schema.md

schema-definitions.md

state-management.md

tables.md

transformations.md

view-and-rendering.md

tile.json