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 insideEditor.withoutNormalizingfor commit atomicity. Without it, Slate's normalization can split text nodes mid-operation and invalidate the cached caret offset beforesetSelectionlands.- 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'schipSelectedreducer state cooperate. - Slate uses immutable
Path + Offsetselection; the adapter'sgetCaretOffsetwalks all text nodes viaNode.texts(editor). For very large documents (10k+ text nodes) cache the walk per editor-state version.