@danielivanovz/mention
Recipes

Slate

EditorAdapter implementation for Slate. Substring + atomic-chip arms.

Slate is a contenteditable framework with an immutable document model and Path + Offset selection. The adapter maps Slate's structured selection to the library's flat char-offset model via Editor.string(editor, []).

Install: npm install slate slate-react. The mention library has no Slate dependency — this adapter lives in your app code.

Adapter

// adapters/slate.ts
import type { EditorAdapter, MentionInsertResult, ChipInsertInput } from "@danielivanovz/mention";
import { Editor, Range, Transforms, Node } from "slate";
import type { ReactEditor } from "slate-react";

export function createSlateAdapter(editor: ReactEditor): EditorAdapter {
  // Slate's editor has no `rootElement` field by default —
  // ReactEditor.toDOMNode resolves the Slate root to its DOM mount.
  const rootEl = (): HTMLElement => {
    // biome-ignore lint/suspicious/noExplicitAny: ReactEditor.toDOMNode's import path varies by version
    return (editor as any).toDOMNode(editor) as HTMLElement;
  };

  return {
    get element() { return rootEl(); },
    getValue: () => Editor.string(editor, []),
    getCaretOffset: () => {
      if (editor.selection === null) return 0;
      const start = Range.start(editor.selection);
      // Walk from doc start to `start`, summing string lengths.
      let offset = 0;
      for (const [node, path] of Node.texts(editor)) {
        if (path.join(",") === start.path.join(",")) {
          return offset + start.offset;
        }
        offset += node.text.length;
      }
      return offset;
    },
    getCaretRect: () => {
      const sel = window.getSelection();
      if (!sel || sel.rangeCount === 0) return null;
      const r = sel.getRangeAt(0).cloneRange();
      r.collapse(true);
      return r.getBoundingClientRect();
    },
    applyInsert: (result: MentionInsertResult) => {
      // Substring-shape: brutally replace the document text. Production
      // adapters should compute the Slate Range covering [triggerOffset,
      // caret) and use Transforms.insertText on that range — without
      // normalization for atomicity.
      Editor.withoutNormalizing(editor, () => {
        Transforms.delete(editor, { at: [] });
        Transforms.insertText(editor, result.value);
      });
    },
    applyChipInsert: (input: ChipInsertInput) => {
      Editor.withoutNormalizing(editor, () => {
        // Splice the trigger window. In a real adapter, derive Slate
        // Range from the library char-offsets and Transforms.delete it.
        // Here we assume the selection covers the trigger window.
        if (editor.selection !== null) {
          // Insert a void inline node carrying the chip metadata.
          Transforms.insertNodes(editor, {
            type: "mention",
            id: input.chip.getAttribute("data-mention-id"),
            text: input.chip.getAttribute("data-mention-text"),
            children: [{ text: "" }],
          });
        }
      });
    },
    focus: () => {
      // biome-ignore lint/suspicious/noExplicitAny: ReactEditor.focus
      (editor as any).focus(editor);
    },
  };
}

Void inline schema for chips

// adapters/with-mentions.ts
import type { ReactEditor } from "slate-react";

export function withMentions(editor: ReactEditor): ReactEditor {
  const { isInline, isVoid } = editor;
  editor.isInline = (element) =>
    element.type === "mention" || isInline(element);
  editor.isVoid = (element) =>
    element.type === "mention" || isVoid(element);
  return editor;
}

Wiring

import { useMemo, useEffect } from "react";
import { createEditor, Descendant } from "slate";
import { Slate, Editable, withReact } from "slate-react";
import { Mention, useMentionContext } from "@danielivanovz/mention";
import { createSlateAdapter } from "./adapters/slate";
import { withMentions } from "./adapters/with-mentions";

const initialValue: Descendant[] = [{ type: "paragraph", children: [{ text: "" }] }];

export function MyEditor() {
  const editor = useMemo(() => withMentions(withReact(createEditor())), []);

  return (
    <Mention.Root items={users} getKey={(u) => u.id} getLabel={(u) => u.name} onSelect={() => {}}>
      <Slate editor={editor} initialValue={initialValue}>
        <Editable
          renderElement={({ element, attributes, children }) =>
            element.type === "mention" ? (
              <span
                {...attributes}
                contentEditable={false}
                data-mention-id={element.id}
                data-mention-text={element.text}
              >
                {element.text}
                {children}
              </span>
            ) : (
              <p {...attributes}>{children}</p>
            )
          }
        />
        <MentionBridge editor={editor} />
      </Slate>
      <Mention.Popover>
        <Mention.List>{(u) => <Mention.Item value={u}>{u.name}</Mention.Item>}</Mention.List>
      </Mention.Popover>
    </Mention.Root>
  );
}

function MentionBridge({ editor }: { editor: ReactEditor }) {
  const ctx = useMentionContext();
  useEffect(() => {
    ctx.adapterRef.current = createSlateAdapter(editor);
    return () => { ctx.adapterRef.current = null; };
  }, [editor, ctx.adapterRef]);
  return null;
}

Caveats

  • Transforms.* operations run inside Editor.withoutNormalizing for commit atomicity. Without it, Slate's normalization can split text nodes mid-operation and invalidate the cached caret offset before setSelection lands.
  • The void-inline pattern above keeps chips atomic for selection: clicking inside a chip selects the whole node, and Backspace at its boundary removes it as a unit. The two-step backspace UX layered by <Mention.Chips> (C3) works on top — Slate's atomic node + the library's chipSelected reducer state cooperate.
  • Slate uses immutable Path + Offset selection; the adapter's getCaretOffset walks all text nodes via Node.texts(editor). For very large documents (10k+ text nodes) cache the walk per editor-state version.

On this page