Recipes
TipTap
EditorAdapter implementation for TipTap (ProseMirror). Substring + atomic-chip arms.
TipTap wraps ProseMirror with a React-friendly API. The selection model is ProseMirror's: 1-indexed text offsets across the entire document. The adapter maps PM positions to/from the library's 0-indexed character model.
Install:
npm install @tiptap/react @tiptap/starter-kit. The mention library has no TipTap dependency — this adapter lives in your app code.
Adapter
// adapters/tiptap.ts
import type { EditorAdapter, MentionInsertResult, ChipInsertInput } from "@danielivanovz/mention";
import type { Editor } from "@tiptap/react";
export function createTipTapAdapter(editor: Editor): EditorAdapter {
const rootEl = editor.view.dom as HTMLElement;
return {
element: rootEl,
getValue: () => editor.getText(),
getCaretOffset: () => {
// ProseMirror positions are 1-indexed and count node boundaries
// (each opening/closing tag = 1). For a flat single-paragraph
// document, char-offset = pmPos - 1. For multi-block docs you'd
// walk via doc.textBetween(0, pmPos, "\n").length.
const { from } = editor.state.selection;
return editor.state.doc.textBetween(0, from, "\n").length;
},
getCaretRect: () => {
const { from } = editor.state.selection;
const coords = editor.view.coordsAtPos(from);
// Synthesize a 1×lineHeight rect.
return new DOMRect(coords.left, coords.top, 1, coords.bottom - coords.top);
},
applyInsert: (result: MentionInsertResult) => {
// Substring-shape: replace the doc text. Production adapters
// should splice just the trigger window via insertContentAt with
// PM coordinate conversion; this is the lossy-prototype version.
editor.commands.setContent(result.value);
editor.commands.focus(result.caret + 1); // PM is 1-indexed
},
applyChipInsert: (input: ChipInsertInput) => {
// Convert library char-offsets to PM positions (assumes single
// paragraph for brevity).
const triggerPM = input.triggerOffset + 1;
const caretPM = input.selectionStart + 1;
editor.chain()
.focus()
.deleteRange({ from: triggerPM, to: caretPM })
// Insert a custom node spec that wraps the chip placeholder.
// In a real adapter this is a registered ProseMirror node;
// here we use insertContent with HTML for brevity.
.insertContent({
type: "mention",
attrs: { id: input.chip.getAttribute("data-mention-id") },
})
.run();
},
focus: () => editor.commands.focus(),
};
}ProseMirror node spec for chips
// adapters/mention-extension.ts
import { Node, mergeAttributes } from "@tiptap/core";
export const Mention = Node.create({
name: "mention",
group: "inline",
inline: true,
atom: true, // critical — chips are atomic ProseMirror nodes
selectable: false,
addAttributes() {
return {
id: { default: null, parseHTML: (el) => el.getAttribute("data-mention-id") },
text: { default: null, parseHTML: (el) => el.getAttribute("data-mention-text") },
};
},
parseHTML() {
return [{ tag: "span[data-mention-id]" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, {
"data-mention-id": node.attrs.id,
"data-mention-text": node.attrs.text,
"contenteditable": "false",
}),
node.attrs.text,
];
},
});Wiring
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useRef } from "react";
import { Mention as MentionLib, useMentionContext } from "@danielivanovz/mention";
import { createTipTapAdapter } from "./adapters/tiptap";
import { Mention as MentionExt } from "./adapters/mention-extension";
export function MyEditor() {
const editor = useEditor({ extensions: [StarterKit, MentionExt] });
if (editor === null) return null;
return (
<MentionLib.Root items={users} getKey={(u) => u.id} getLabel={(u) => u.name} onSelect={() => {}}>
<EditorContent editor={editor} />
<MentionBridge editor={editor} />
<MentionLib.Popover>
<MentionLib.List>{(u) => <MentionLib.Item value={u}>{u.name}</MentionLib.Item>}</MentionLib.List>
</MentionLib.Popover>
</MentionLib.Root>
);
}
function MentionBridge({ editor }: { editor: Editor }) {
const ctx = useMentionContext();
useEffect(() => {
ctx.adapterRef.current = createTipTapAdapter(editor);
return () => { ctx.adapterRef.current = null; };
}, [editor, ctx.adapterRef]);
return null;
}Caveats
- ProseMirror positions count node boundaries (paragraphs, lists, etc.). The simple
+1conversion in this recipe assumes a single-paragraph document. For multi-block documents, walk viadoc.descendantsor usedoc.textBetween(0, pmPos, "\n").lengthto get the flat char offset. - Marks (bold, italic, links) inside the trigger window are preserved by
deleteRangeonly when the window is fully inside one mark's span. Splicing across mark boundaries can produce unexpected results — gate the trigger detection oneditor.isActive("paragraph")if your editor allows trigger characters inside link text. - TipTap's
setContent(used by the substringapplyInserthere) replaces the entire doc — fine for prototypes, lossy for real editors. Production adapters splice viaeditor.commands.insertContentAt({ from, to }, content).