blokhaus

MentionNode

Inline mention chip for @users,

MentionNode is a DecoratorNode that renders as a non-editable inline chip inside the editor. It supports any trigger character (@, #, or custom) and stores the referenced entity's ID, label, and trigger for serialization.

Import

import {
  MentionNode,
  $createMentionNode,
  $isMentionNode,
} from "@blokhaus/core";
import type { MentionPayload } from "@blokhaus/core";

MentionPayload

interface MentionPayload {
  id: string;
  label: string;
  trigger: string;
  key?: NodeKey;
}
FieldTypeRequiredDescription
idstringYesUnique identifier of the mentioned entity (user ID, tag ID, etc.).
labelstringYesDisplay text for the mention chip (e.g., "Alice", "feature-request").
triggerstringYesThe trigger character that created this mention ("@", "#", etc.).
keyNodeKeyNoExplicit Lexical node key.

Functions

$createMentionNode(payload: MentionPayload): MentionNode

Creates a new MentionNode. Must be called inside editor.update(). Typically invoked by a MentionProvider's onSelect callback.

import { $createMentionNode } from "@blokhaus/core";

// Inside a MentionProvider's onSelect:
const onSelect = (item: MentionItem) => {
  return $createMentionNode({
    id: item.id,
    label: item.label,
    trigger: "@",
  });
};

$isMentionNode(node: LexicalNode | null | undefined): node is MentionNode

Type guard that returns true if the given node is a MentionNode.

editor.read(() => {
  const root = $getRoot();
  const mentions: MentionNode[] = [];

  function collectMentions(node: LexicalNode) {
    if ($isMentionNode(node)) {
      mentions.push(node);
    }
    if ($isElementNode(node)) {
      node.getChildren().forEach(collectMentions);
    }
  }

  root.getChildren().forEach(collectMentions);
  console.log(
    "Found mentions:",
    mentions.map((m) => m.getTextContent()),
  );
});

Instance methods

MethodReturnsDescription
isInline()trueAlways returns true. Mention nodes are inline elements.
getTextContent()stringReturns trigger + label (e.g., "@Alice").

Serialized format

The JSON serialization uses mentionId (not id) to avoid collisions with Lexical's internal id field:

type SerializedMentionNode = {
  type: "mention";
  version: 1;
  mentionId: string;
  label: string;
  trigger: string;
};

DOM behavior

  • exportDOM: Produces a <span> with data-mention-id and data-mention-trigger attributes, and text content of trigger + label.
  • importDOM: Converts pasted <span> elements that have a data-mention-id attribute back into MentionNode instances.

Keyboard behavior

The mention chip is focusable but not editable. When the node is selected (via Lexical's node selection):

  • Pressing Backspace removes the mention from the AST.
  • Pressing Delete removes the mention from the AST.

Visual styling

The chip color is determined by the trigger character:

  • # triggers use the --blokhaus-accent token (blue by default).
  • All other triggers (including @) use the --blokhaus-ai-stream token.

The chip renders with a subtle background tint derived from the accent color using CSS color-mix().

Usage with MentionPlugin

MentionNode is typically created by MentionPlugin based on MentionProvider configuration:

app/editor/page.tsx
"use client";

import { EditorRoot, MentionPlugin, $createMentionNode } from "@blokhaus/core";
import type { MentionProvider, MentionItem } from "@blokhaus/core";

const userProvider: MentionProvider = {
  trigger: "@",
  onSearch: async (query) => {
    const res = await fetch(`/api/users?q=${query}`);
    return res.json();
  },
  renderItem: (item) => (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <span>{item.label}</span>
      <span style={{ opacity: 0.5 }}>{item.meta}</span>
    </div>
  ),
  onSelect: (item) =>
    $createMentionNode({ id: item.id, label: item.label, trigger: "@" }),
};

export default function EditorPage() {
  return (
    <EditorRoot namespace="my-editor">
      <MentionPlugin providers={[userProvider]} />
    </EditorRoot>
  );
}