From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Discussion
    • Comments
    • Suggestion
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • List Classic
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Toggle
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Cursor Overlay
    • Drag & Drop
    • Navigation Feedback
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • DOCX
    • HTML
    • Markdown

Suggestion

PreviousNext

Add suggestions to text as marks.

PlusSuggestion LeafSuggestion Toolbar ButtonBlock suggestionBlock discussion
Loading…
CommentsBasic Blocks

On This Page

FeaturesKit UsageInstallationAdd KitManual UsageInstallationExtend Suggestion PluginAdd Click HandlerAdd PluginsEnable Suggestion ModeAdd Toolbar ButtonDiscussion IntegrationKeyboard ShortcutsPlate PlusPluginsSuggestionPluginPlugin APIUpdatesUpdate PoliciesSuggestionUpdatePolicy.skipTypesSuggestionTextSuggestionElementInlineSuggestionDataSuggestionData
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

  • Text Suggestions: Add suggestions as text marks with inline annotations
  • Block Suggestions: Create suggestions for entire blocks of content
  • State Tracking: Track suggestion state and user interactions
  • Undo/Redo Support: Full undo/redo support for suggestion changes
  • Discussion Integration: Works with discussion plugin for complete collaboration
Report an issue

Kit Usage

Installation

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 marks
  • BlockSuggestion: Renders block-level suggestions
  • SuggestionLineBreak: Handles line breaks in suggestions

Add Kit

import { 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




Manual Usage

Installation

pnpm add @platejs/suggestion @platejs/plite-dom
pnpm add @platejs/suggestion @platejs/plite-dom

Extend Suggestion Plugin

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 renderers
  • initialState.currentUserId: ID of the current user creating suggestions
  • .configure({ component: SuggestionLeaf }): Renders suggestion text marks with SuggestionLeaf
  • render.belowRootNodes: Renders BlockSuggestion for block-level suggestions

Add Click Handler

Add 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:

  • Detects suggestion clicks: Traverses DOM to find suggestion elements
  • Sets active state: Updates activeId when clicking on suggestions
  • Clears state: Unsets activeId when clicking outside suggestions
  • Visual feedback: Enables hover/active styling in suggestion components

Add Plugins

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,
  ],
});
  • render.belowNodes: Renders SuggestionLineBreak below nodes to handle line break suggestions

Enable Suggestion Mode

Use 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

Add Toolbar Button

You can add SuggestionToolbarButton to your Toolbar to toggle suggestion mode in the editor.

Discussion Integration

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






Keyboard Shortcuts

KeyDescription
Cmd + Shift + S

Add a suggestion on the selected text.

Plate Plus

  • Full stack example for Suggestion and Comment
  • Floating comments & suggestions UI with better user experience
  • Comment rendered with Plate editor
  • Discussion list in the sidebar
Get the code

Plugins

SuggestionPlugin

Plugin for creating and managing text and block suggestions with state tracking and discussion integration.

Options

    ID of the current user creating suggestions. Required for proper suggestion attribution.

    Whether the editor is currently in suggestion mode. Used internally to track state.

Plugin API

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();
MethodDescription
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.

Updates

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);
UpdateDescription
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.

Update Policies

SuggestionUpdatePolicy.skip

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');
});

Types

SuggestionText

Text nodes that can contain suggestions.

Attributes

    Whether this is a suggestion.

    Suggestion data. Multiple suggestions can exist in one text node.

SuggestionElement

Block elements that contain suggestion metadata.

Attributes

    Block-level suggestion data including type, user, and timing information.

InlineSuggestionData

Data structure for inline text suggestions.

Attributes

    Unique identifier for the suggestion.

    ID of the user who created the suggestion.

    Timestamp when the suggestion was created.

    Type of suggestion operation.

    For update suggestions, the new mark properties being suggested.

    For update suggestions, the previous mark properties.

SuggestionData

Data structure for block-level suggestions.

Attributes

    Unique identifier for the suggestion.

    ID of the user who created the suggestion.

    Timestamp when the suggestion was created.

    Type of block suggestion operation.

    Whether this suggestion represents a line break insertion.

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];
({
plugins: [
// ...otherPlugins,
...SuggestionKit,
],
});
null
;
}
return <BlockSuggestion element={element} />;
},
},
});
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} />;
    },
  },
});
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
});
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>
  );
}
({
initialState: {
currentUserId: 'alice',
},
}),
suggestionLineBreakPlugin,
],
});