The fastest way to add suggestion functionality is with the SuggestionKit, which includes pre-configured SuggestionPlugin and related components along with their Plate UI components.
'use client';
import type { Element } from '@platejs/plite';
import { type SuggestionData, BaseSuggestionPlugin } from '@platejs/suggestion';
import { SuggestionPlugin } from '@platejs/suggestion/react';
import { cva } from 'class-variance-authority';
import { CornerDownLeftIcon } from 'lucide-react';
import {
PLUGINS,
type BasePluginOverride,
type TrailingBlockDefinition,
TextApi,
} from 'platejs';
import {
type PlateEditor,
type PlateLeafProps,
type RenderNodeWrapper,
PlateLeaf,
useEditorPlugin,
usePluginStore,
} from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
discussionPlugin,
getDiscussionBlockClickTarget,
getDiscussionClickTarget,
} from './discussion';
const getSuggestionApi = (editor: PlateEditor) =>
editor.plugin(SuggestionPlugin).api;
export const suggestionVariants = cva(
cn(
'bg-emerald-100 text-emerald-700 no-underline transition-colors duration-200'
),
{
defaultVariants: {
insertActive: false,
remove: false,
removeActive: false,
},
variants: {
insertActive: {
false: '',
true: 'bg-emerald-200/80',
},
remove: {
false: '',
true: 'bg-red-100 text-red-700',
},
removeActive: {
false: '',
true: 'bg-red-200/80 no-underline',
},
},
}
);
const voidRemoveSuggestionOverlayVariants = cva(
'pointer-events-none absolute inset-0 z-20 overflow-hidden rounded-[inherit]',
{
defaultVariants: {
active: false,
},
variants: {
active: {
false: 'hidden',
true: 'before:-translate-x-1/2 before:-translate-y-1/2 before:pointer-events-none before:absolute before:top-1/2 before:left-1/2 before:z-20 before:flex before:size-10 before:items-center before:justify-center before:rounded-full before:bg-red-500/90 before:font-semibold before:text-2xl before:text-white before:shadow-lg before:content-["X"] after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-[inherit] after:border after:border-red-300/80 after:bg-zinc-950/35 after:content-[""]',
},
},
}
);
export function getBlockSuggestionWrapperClassName({
isActive,
isColumnGroup,
isHover,
isInsert,
isRemove,
}: {
isActive: boolean;
isColumnGroup: boolean;
isHover: boolean;
isInsert: boolean;
isRemove: boolean;
}) {
return cn(
isColumnGroup && 'flex size-full rounded',
suggestionVariants({
insertActive: isInsert && (isActive || isHover),
remove: isRemove,
removeActive: (isActive || isHover) && isRemove,
})
);
}
export function isVoidRemoveSuggestion(editor: PlateEditor, element: Element) {
return getSuggestionApi(editor).suggestionData(element)?.type === 'remove';
}
export function VoidRemoveSuggestionOverlay({
editor,
element,
}: {
editor: PlateEditor;
element: Element;
}) {
const active =
editor.read.schema.isVoid(element) &&
!editor.read.schema.isInline(element) &&
isVoidRemoveSuggestion(editor, element);
if (!active) return null;
return (
<div
className={voidRemoveSuggestionOverlayVariants({ active })}
contentEditable={false}
data-slot="void-remove-suggestion"
/>
);
}
export function SuggestionLineBreakAnchor({
badgeProps,
children,
className,
}: {
badgeProps?: React.ComponentProps<'span'>;
children: React.ReactNode;
className?: string;
}) {
const badge = (
<span
{...badgeProps}
className={cn(
'inline-flex h-[calc(1lh+2px)] w-[1lh] shrink-0 items-center justify-center leading-none',
badgeProps?.className,
className
)}
contentEditable={false}
>
<CornerDownLeftIcon className="relative top-px size-4" />
</span>
);
return (
<>
{children}
{badge}
</>
);
}
function SuggestionLineBreakElementAnchor({
badgeProps,
children,
className,
}: {
badgeProps?: React.ComponentProps<'span'>;
children: React.ReactElement;
className?: string;
}) {
if (!React.isValidElement(children)) return children;
const badge = (
<span
{...badgeProps}
className={cn(
'inline-flex h-[calc(1lh+2px)] w-[1lh] shrink-0 items-center justify-center leading-none',
badgeProps?.className,
className
)}
contentEditable={false}
>
<CornerDownLeftIcon className="relative top-px size-4" />
</span>
);
if (children.type === 'ol' || children.type === 'ul') {
const childNodes = React.Children.toArray(
(children.props as { children?: React.ReactNode }).children
);
const lastIndex = childNodes.length - 1;
const lastChild = childNodes[lastIndex];
if (!React.isValidElement(lastChild) || lastChild.type !== 'li') {
return children;
}
const nextLastChild = React.cloneElement(
lastChild as React.ReactElement<{ children?: React.ReactNode }>,
{
children: (
<>
{(lastChild.props as { children?: React.ReactNode }).children}
{badge}
</>
),
}
);
return React.cloneElement(
children as React.ReactElement<{ children?: React.ReactNode }>,
{
children: [...childNodes.slice(0, lastIndex), nextLastChild],
}
);
}
if (typeof children.type === 'string') {
return (
<>
{children}
{badge}
</>
);
}
return React.cloneElement(
children as React.ReactElement<{ lineBreakBadge?: React.ReactNode }>,
{ lineBreakBadge: badge }
);
}
export function SuggestionLeaf(props: PlateLeafProps<typeof SuggestionPlugin>) {
const { api, store } = useEditorPlugin(SuggestionPlugin);
const { leaf } = props;
const leafId: string = api.id(leaf) ?? '';
const activeSuggestionId = usePluginStore(SuggestionPlugin, 'activeId');
const hoverSuggestionId = usePluginStore(SuggestionPlugin, 'hoverId');
const dataList = api.dataList(leaf);
const hasRemove = dataList.some((data) => data.type === 'remove');
const hasActive = dataList.some((data) => data.id === activeSuggestionId);
const hasHover = dataList.some((data) => data.id === hoverSuggestionId);
const diffOperation = { type: hasRemove ? 'delete' : 'insert' } as const;
const Component = ({ delete: 'del', insert: 'ins', update: 'span' } as const)[
diffOperation.type
];
return (
<PlateLeaf
{...props}
as={Component}
className={cn(
suggestionVariants({
insertActive: hasActive || hasHover,
remove: hasRemove,
removeActive: (hasActive || hasHover) && hasRemove,
})
)}
attributes={{
...props.attributes,
onMouseEnter: () => {
store.set({ hoverId: leafId });
},
onMouseLeave: () => {
store.set({ hoverId: null });
},
}}
>
{props.children}
</PlateLeaf>
);
}
export const SuggestionLineBreak: RenderNodeWrapper = ({ editor, element }) => {
if (!getSuggestionApi(editor).isBlockSuggestion(element)) {
return undefined;
}
const suggestionData = element.suggestion;
const columnGroup = editor.plugin(PLUGINS.columnGroup);
const isColumnGroup =
columnGroup.installed && element.type === columnGroup.schema.type;
return function Component({ children }) {
return (
<SuggestionLineBreakContent
isColumnGroup={isColumnGroup}
suggestionData={suggestionData}
>
{children}
</SuggestionLineBreakContent>
);
};
};
export function SuggestionLineBreakContent({
children,
isColumnGroup,
suggestionData,
}: {
children: React.ReactNode;
isColumnGroup: boolean;
suggestionData: SuggestionData;
}) {
const { isLineBreak, type } = suggestionData;
const isRemove = type === 'remove';
const isInsert = type === 'insert';
const activeSuggestionId = usePluginStore(SuggestionPlugin, 'activeId');
const hoverSuggestionId = usePluginStore(SuggestionPlugin, 'hoverId');
const isActive = activeSuggestionId === suggestionData.id;
const isHover = hoverSuggestionId === suggestionData.id;
const { store } = useEditorPlugin(SuggestionPlugin);
const lineBreakBadgeClassName = cn(
isInsert &&
'bg-transparent! text-emerald-700! transition-colors duration-200',
isInsert && (isActive || isHover) && 'bg-transparent! text-emerald-700!',
isRemove && 'bg-transparent! text-red-700! transition-colors duration-200',
isRemove && (isActive || isHover) && 'bg-transparent! text-red-700!'
);
return (
<>
{isLineBreak ? (
React.isValidElement(children) && typeof children.type !== 'string' ? (
<SuggestionLineBreakElementAnchor
badgeProps={{
onClick: (event) => {
event.stopPropagation();
store.set({ activeId: suggestionData.id });
},
onMouseDown: (event) => {
event.preventDefault();
},
}}
className={lineBreakBadgeClassName}
>
{children}
</SuggestionLineBreakElementAnchor>
) : React.isValidElement(children) &&
(children.type === 'ol' || children.type === 'ul') ? (
<SuggestionLineBreakElementAnchor
badgeProps={{
onClick: (event) => {
event.stopPropagation();
store.set({ activeId: suggestionData.id });
},
onMouseDown: (event) => {
event.preventDefault();
},
}}
className={lineBreakBadgeClassName}
>
{children}
</SuggestionLineBreakElementAnchor>
) : (
<SuggestionLineBreakAnchor
badgeProps={{
onClick: (event) => {
event.stopPropagation();
store.set({ activeId: suggestionData.id });
},
onMouseDown: (event) => {
event.preventDefault();
},
}}
className={lineBreakBadgeClassName}
>
{children}
</SuggestionLineBreakAnchor>
)
) : (
<div
className={getBlockSuggestionWrapperClassName({
isActive,
isColumnGroup,
isHover,
isInsert,
isRemove,
})}
onMouseEnter={() => {
store.set({ hoverId: suggestionData.id });
}}
onMouseLeave={() => {
store.set({ hoverId: null });
}}
data-block-suggestion="true"
>
{children}
</div>
)}
</>
);
}
const INLINE_SUGGESTION_RENDER_TARGETS = [
PLUGINS.date,
PLUGINS.inlineEquation,
PLUGINS.link,
PLUGINS.mention,
];
export type SuggestionKitPluginState = {
currentUserId: string | null;
};
const createInitialState = (
currentUserId: string | null
): SuggestionKitPluginState => ({ currentUserId });
export const suggestionPlugin = SuggestionPlugin.extend(({ api, editor }) => ({
initialState: createInitialState(
editor.plugin(discussionPlugin).store.get('currentUserId')
),
override: {
plugins: {
[PLUGINS.trailingBlock]: {
initialState: {
insert: (insert) => {
api.untracked(insert);
},
},
} satisfies BasePluginOverride<TrailingBlockDefinition>,
},
},
})).configure({
component: SuggestionLeaf,
on: {
// unset active suggestion when clicking outside of suggestion
click: ({ api, event, name, read, store }) => {
const markTarget = getDiscussionClickTarget({
selector: `.plite-${name}`,
target: event.target,
});
const blockTarget = markTarget
? null
: getDiscussionBlockClickTarget({
target: event.target,
});
if (!markTarget && !blockTarget) {
store.set({ activeId: null });
return;
}
const suggestionEntry = read.node({
isText: !blockTarget,
});
store.set({
activeId: suggestionEntry ? (api.id(suggestionEntry[0]) ?? null) : null,
});
},
},
inject: {
isElement: true,
nodeProps: {
nodeKey: '',
styleKey: 'cssText',
transformProps: ({ editor, element, props }) => {
if (!element) return props;
const { api } = editor.plugin(BaseSuggestionPlugin);
let suggestionData = api.suggestionData(element);
if (!suggestionData) {
for (const child of element.children) {
if (!TextApi.isText(child)) continue;
suggestionData = api.dataList(child).at(-1);
if (suggestionData) break;
}
}
if (!suggestionData) return props;
return {
...props,
'data-inline-suggestion': suggestionData.type,
};
},
transformStyle: () => ({}) as CSSStyleDeclaration,
},
},
render: {
belowNodes: SuggestionLineBreak,
belowRootNodes: VoidRemoveSuggestionOverlay,
},
targetPlugins: INLINE_SUGGESTION_RENDER_TARGETS,
});
export const SuggestionKit = [suggestionPlugin];'use client';
import type { Element } from '@platejs/plite';
import { type SuggestionData, BaseSuggestionPlugin } from '@platejs/suggestion';
import { SuggestionPlugin } from '@platejs/suggestion/react';
import { cva } from 'class-variance-authority';
import { CornerDownLeftIcon } from 'lucide-react';
import {
PLUGINS,
type BasePluginOverride,
type TrailingBlockDefinition,
TextApi,
} from 'platejs';
import {
type PlateEditor,
type PlateLeafProps,
type RenderNodeWrapper,
SuggestionLeaf: Renders suggestion text marksBlockSuggestion: Renders block-level suggestionsSuggestionLineBreak: Handles line breaks in suggestionsimport { createPlateEditor } from 'platejs/react';
import { SuggestionKit } from '@/components/editor/suggestion';
const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
...SuggestionKit,
],
});import { createPlateEditor } from 'platejs/react';
import { SuggestionKit } from '@/components/editor/suggestion';
const editor = createPlateEditor
Create the suggestion plugin with extended configuration for state management:
import { isEditor, isElement, isString } from '@platejs/plite-dom';
import { SuggestionPlugin } from '@platejs/suggestion/react';
import { BlockSuggestion } from '@/components/editor/block-discussion';
import { SuggestionLeaf } from '@/components/editor/suggestion';
export const suggestionPlugin = SuggestionPlugin.configure({
component: SuggestionLeaf,
initialState: {
currentUserId: 'alice', // Set your current user ID
},
render: {
belowRootNodes: ({ api, element }) => {
if (!api.isBlockSuggestion(element)) {
return
SuggestionPlugin owns the active and hovered suggestion state used by renderersinitialState.currentUserId: ID of the current user creating suggestions.configure({ component: SuggestionLeaf }): Renders suggestion text marks with
SuggestionLeafrender.belowRootNodes: Renders BlockSuggestion for block-level suggestionsAdd click handling to manage active suggestion state:
export const suggestionPlugin = SuggestionPlugin.configure({
on: {
// Unset active suggestion when clicking outside of suggestion
click: ({ api, editor, event, store, type }) => {
let leaf = event.target as HTMLElement;
let isSet = false;
const unsetActiveSuggestion = () => {
store.set({ activeId: null });
isSet = true;
};
if (!isString(leaf))
The click handler tracks which suggestion is currently active:
activeId when clicking on suggestionsactiveId when clicking outside suggestionsimport { createPlateEditor, definePlatePlugin } from 'platejs/react';
import { SuggestionLineBreak } from '@/components/editor/suggestion';
const suggestionLineBreakPlugin = definePlatePlugin('suggestionLineBreak', {
render: { belowNodes: SuggestionLineBreak as any },
});
const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
suggestionPlugin,
suggestionLineBreakPlugin,
],
});render.belowNodes: Renders SuggestionLineBreak below nodes to handle line break suggestionsUse the plugin's API to control suggestion mode:
import { useEditor, usePluginStore } from 'platejs/react';
function SuggestionToolbar() {
const editor = useEditor();
const isSuggesting = usePluginStore(suggestionPlugin, 'isSuggesting');
const toggleSuggesting = () => {
editor
.plugin(suggestionPlugin)
.store.set({ isSuggesting: !isSuggesting });
};
return (
<button onClick={toggleSuggesting}>
{isSuggesting ? 'Stop Suggesting' : 'Start Suggesting'}
</button
You can add SuggestionToolbarButton to your Toolbar to toggle suggestion mode in the editor.
The suggestion plugin works with the discussion plugin for complete collaboration:
const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
discussionPlugin,
suggestionPlugin.configure({
initialState: {
currentUserId: 'alice',
},
}),
suggestionLineBreakPlugin,
],
});const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
discussionPlugin,
suggestionPlugin.configure
| Key | Description |
|---|---|
| Cmd + Shift + S | Add a suggestion on the selected text. |
Plugin for creating and managing text and block suggestions with state tracking and discussion integration.
Suggestion queries and value helpers live on the installed plugin. This keeps the editor type, plugin state, and schema in the inference path.
const { api, read } = editor.plugin(BaseSuggestionPlugin);
const suggestions = read.nodes();
const descriptions = read.activeDescriptions();const { api, read } = editor.plugin(BaseSuggestionPlugin);
const suggestions = read.nodes();
const descriptions = read.activeDescriptions();| Method | Description |
|---|---|
read.activeDescriptions() | Returns the active suggestion descriptions. |
api.createFragment(fragment, identity) | Adds explicit insertion suggestion identity to a fragment. |
api.createIdentity(options?) | Creates a suggestion ID and timestamp. |
api.dataList(node) | Returns every inline suggestion record on a text node. |
read.findIdentity(options) | Finds an existing suggestion identity. |
api.getProps(node, options?) | Builds suggestion properties for a node. |
api.inlineData(node) | Returns the active inline suggestion record. |
api.isBlockSuggestion(node) | Narrows a node to a block suggestion. |
api.isCurrentUser(node) | Checks suggestion ownership against currentUserId. |
api.isTracking(tags) | Checks whether suggestion middleware tracks an update. |
api.key(id?) / api.keyId(node) / api.keys(node) | Reads and builds inline suggestion keys. |
read.node(options?) / read.nodes(options?) | Finds one or all suggestion entries. |
read.nodeEntries(id, options?) | Finds inline entries for a suggestion ID. |
api.id(node) | Returns a node's suggestion ID. |
api.skipDeletes(node) | Returns text without removed suggestions. |
api.suggestionData(node) | Returns inline or block suggestion data. |
api.untracked(fn) | Runs synchronous work without recursively creating suggestions. |
api.userId(node) / api.userIds(node) | Returns suggestion author IDs. |
Suggestion transforms are installed on the editor:
editor.update.suggestion.accept(description);
editor.update.suggestion.reject(description);editor.update.suggestion.accept(description);
editor.update.suggestion.reject(description);| Update | Description |
|---|---|
editor.update.suggestion.accept(description) | Applies a resolved suggestion. |
editor.update.suggestion.reject(description) | Discards a resolved suggestion. |
editor.update.suggestion.addMark(key, value) | Adds a mark as a suggestion. |
editor.update.suggestion.removeMark(key, previousValue?) | Removes a mark as a suggestion. |
editor.update.suggestion.delete(at, options?) | Records a range deletion. |
editor.update.suggestion.deleteFragment(options?) | Records deletion of the active selection. |
editor.update.suggestion.insertFragment(fragment, insertContent?) | Inserts a suggested fragment. |
editor.update.suggestion.insertText(text) | Inserts suggested text. |
editor.update.suggestion.removeNodes(nodes) | Records node removals. |
editor.update.suggestion.setNodes(options?) | Records node property updates. |
Runs an update without creating suggestion marks. Use the package-owned preset for direct or atomic updates.
import { SuggestionUpdatePolicy } from '@platejs/suggestion';
editor.update(SuggestionUpdatePolicy.skip, (tx) => {
tx.text.insert('Accepted text');
});import { SuggestionUpdatePolicy } from '@platejs/suggestion';
editor.update(SuggestionUpdatePolicy.skip, (tx) => {
tx.text.insert('Accepted text');
});Text nodes that can contain suggestions.
Block elements that contain suggestion metadata.
Data structure for inline text suggestions.
Data structure for block-level suggestions.
import { isEditor, isElement, isString } from '@platejs/plite-dom';
import { SuggestionPlugin } from '@platejs/suggestion/react';
import { BlockSuggestion } from '@/components/editor/block-discussion';
import { SuggestionLeaf } from '@/components/editor/suggestion';
export const suggestionPlugin = SuggestionPlugin.configure({
component: SuggestionLeaf,
initialState: {
currentUserId: 'alice', // Set your current user ID
},
render: {
belowRootNodes: ({ api, element }) => {
if (!api.isBlockSuggestion(element)) {
return null;
}
return <BlockSuggestion element={element} />;
},
},
});export const suggestionPlugin = SuggestionPlugin.configure({
on: {
// Unset active suggestion when clicking outside of suggestion
click: ({ api, editor, event, store, type }) => {
let leaf = event.target as HTMLElement;
let isSet = false;
const unsetActiveSuggestion = () => {
store.set({ activeId: null });
isSet = true;
};
if (!isString(leaf)) unsetActiveSuggestion();
while (
leaf.parentElement &&
!isElement(leaf.parentElement) &&
!isEditor(leaf.parentElement)
) {
if (leaf.classList.contains(`plite-${type}`)) {
const suggestionEntry = editor
.plugin(SuggestionPlugin)
.read.node({ isText: true });
if (!suggestionEntry) {
unsetActiveSuggestion();
break;
}
const id = api.id(suggestionEntry[0]);
store.set({ activeId: id ?? null });
isSet = true;
break;
}
leaf = leaf.parentElement;
}
if (!isSet) unsetActiveSuggestion();
},
},
// ... previous state and render
});import { createPlateEditor, definePlatePlugin } from 'platejs/react';
import { SuggestionLineBreak } from '@/components/editor/suggestion';
const suggestionLineBreakPlugin = definePlatePlugin('suggestionLineBreak', {
render: { belowNodes: SuggestionLineBreak as any },
});
const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
suggestionPlugin,
suggestionLineBreakPlugin,
],
});import { useEditor, usePluginStore } from 'platejs/react';
function SuggestionToolbar() {
const editor = useEditor();
const isSuggesting = usePluginStore(suggestionPlugin, 'isSuggesting');
const toggleSuggesting = () => {
editor
.plugin(suggestionPlugin)
.store.set({ isSuggesting: !isSuggesting });
};
return (
<button onClick={toggleSuggesting}>
{isSuggesting ? 'Stop Suggesting' : 'Start Suggesting'}
</button>
);
}