The fastest way to add toolbar functionality is with the FixedToolbarKit and FloatingToolbarKit, which include pre-configured toolbar plugins along with their Plate UI components.
'use client';
import {
BaselineIcon,
BoldIcon,
Code2Icon,
HighlighterIcon,
ItalicIcon,
PaintBucketIcon,
StrikethroughIcon,
UnderlineIcon,
WandSparklesIcon,
} from 'lucide-react';
import {
BaseAudioPlugin,
BaseFilePlugin,
BaseImagePlugin,
BaseVideoPlugin,
} from 'platejs/media';
import {
BoldPlugin,
CodePlugin,
HighlightPlugin,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
FontBackgroundColorPlugin,
FontColorPlugin,
useEditorReadOnly,
definePlugin,
} from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { ToolbarGroup, Toolbar } from '@/components/editor/toolbar';
import { AIToolbarButton } from './ai-toolbar-button';
import { AlignToolbarButton } from './align-toolbar-button';
import {
AllCommentsButton,
CommentToolbarButton,
} from './comment-toolbar-button';
import { DetailsToolbarButton } from './details-toolbar-button';
import { EmojiToolbarButton } from './emoji-toolbar-button';
import {
DEFAULT_COLORS,
FontColorToolbarButton,
} from './font-color-toolbar-button';
import { FontSizeToolbarButton } from './font-size-toolbar-button';
import { RedoToolbarButton, UndoToolbarButton } from './history-toolbar-button';
import {
IndentToolbarButton,
OutdentToolbarButton,
} from './indent-toolbar-button';
import { InsertToolbarButton } from './insert-toolbar-button';
import { LineHeightToolbarButton } from './line-height-toolbar-button';
import { LinkToolbarButton } from './link-toolbar-button';
import {
BulletedListToolbarButton,
NumberedListToolbarButton,
TodoListToolbarButton,
} from './list-toolbar-button';
import { MarkToolbarButton } from './mark-toolbar-button';
import { MediaToolbarButton } from './media-toolbar-button';
import { ModeToolbarButton } from './mode-toolbar-button';
import { MoreToolbarButton } from './more-toolbar-button';
import { TableToolbarButton } from './table-toolbar-button';
import { TurnIntoToolbarButton } from './turn-into-toolbar-button';
export function FixedToolbarButtons({
children,
}: {
children?: React.ReactNode;
} = {}) {
const readOnly = useEditorReadOnly();
return (
<div className="flex w-full">
{!readOnly && (
<>
<ToolbarGroup>
<UndoToolbarButton />
<RedoToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<AIToolbarButton tooltip="AI commands">
<WandSparklesIcon />
</AIToolbarButton>
</ToolbarGroup>
{children}
<ToolbarGroup>
<InsertToolbarButton />
<TurnIntoToolbarButton />
<FontSizeToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<MarkToolbarButton plugin={BoldPlugin} tooltip="Bold (⌘+B)">
<BoldIcon />
</MarkToolbarButton>
<MarkToolbarButton plugin={ItalicPlugin} tooltip="Italic (⌘+I)">
<ItalicIcon />
</MarkToolbarButton>
<MarkToolbarButton
plugin={UnderlinePlugin}
tooltip="Underline (⌘+U)"
>
<UnderlineIcon />
</MarkToolbarButton>
<MarkToolbarButton
plugin={StrikethroughPlugin}
tooltip="Strikethrough (⌘+⇧+M)"
>
<StrikethroughIcon />
</MarkToolbarButton>
<MarkToolbarButton plugin={CodePlugin} tooltip="Code (⌘+E)">
<Code2Icon />
</MarkToolbarButton>
<FontColorToolbarButton
colors={DEFAULT_COLORS}
plugin={FontColorPlugin}
tooltip="Text color"
>
<BaselineIcon />
</FontColorToolbarButton>
<FontColorToolbarButton
colors={DEFAULT_COLORS}
plugin={FontBackgroundColorPlugin}
tooltip="Background color"
>
<PaintBucketIcon />
</FontColorToolbarButton>
</ToolbarGroup>
<ToolbarGroup>
<AlignToolbarButton />
<NumberedListToolbarButton />
<BulletedListToolbarButton />
<TodoListToolbarButton />
<DetailsToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<LinkToolbarButton />
<TableToolbarButton />
<EmojiToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<MediaToolbarButton plugin={BaseImagePlugin} />
<MediaToolbarButton plugin={BaseVideoPlugin} />
<MediaToolbarButton plugin={BaseAudioPlugin} />
<MediaToolbarButton plugin={BaseFilePlugin} />
</ToolbarGroup>
<ToolbarGroup>
<LineHeightToolbarButton />
<OutdentToolbarButton />
<IndentToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<MoreToolbarButton />
</ToolbarGroup>
</>
)}
<div className="grow" />
<ToolbarGroup>
<MarkToolbarButton plugin={HighlightPlugin} tooltip="Highlight">
<HighlighterIcon />
</MarkToolbarButton>
<CommentToolbarButton />
<AllCommentsButton />
</ToolbarGroup>
<ToolbarGroup>
<ModeToolbarButton />
</ToolbarGroup>
</div>
);
}
export function FixedToolbar({
className,
ref,
...props
}: React.ComponentProps<typeof Toolbar>) {
return (
<Toolbar
{...props}
ref={ref}
className={cn(
'scrollbar-hide z-50 w-full shrink-0 justify-between overflow-x-auto rounded-t-lg border-b border-b-border bg-background/95 p-1 backdrop-blur-sm supports-backdrop-blur:bg-background/60',
className
)}
data-slot="fixed-toolbar"
/>
);
}
export const FixedToolbarPlugin = definePlugin('fixedToolbar', {
slots: {
beforeContainer: () => (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
),
},
});
export const FixedToolbarKit = [FixedToolbarPlugin] as const;'use client';
import {
BaselineIcon,
BoldIcon,
Code2Icon,
HighlighterIcon,
ItalicIcon,
PaintBucketIcon,
StrikethroughIcon,
UnderlineIcon,
WandSparklesIcon,
} from 'lucide-react';
import {
BaseAudioPlugin,
BaseFilePlugin,
BaseImagePlugin,
BaseVideoPlugin,
} from 'platejs/media';
import {
BoldPlugin,
CodePlugin,
HighlightPlugin,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
FontBackgroundColorPlugin,
FontColorPlugin,
'use client';
import { flip, offset, useDismiss, useInteractions } from '@floating-ui/react';
import {
BoldIcon,
Code2Icon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon,
WandSparklesIcon,
} from 'lucide-react';
import { AIChatPlugin } from 'platejs/ai/react';
import {
BoldPlugin,
CodePlugin,
type EditableSiblingProps,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
definePlugin,
useComposedRef,
useEditorFocused,
useEditorReadOnly,
useEditorSelector,
FixedToolbar: Renders a persistent toolbar above the editorFixedToolbarButtons: Pre-configured button set for the fixed toolbarFloatingToolbar: Renders a contextual toolbar on text selectionFloatingToolbarButtons: Pre-configured button set for the floating toolbarimport { createEditor } from 'platejs/react';
import { FixedToolbarKit } from '@/components/editor/fixed-toolbar';
import { FloatingToolbarKit } from '@/components/editor/floating-toolbar';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...FixedToolbarKit,
...FloatingToolbarKit,
],
});import { createEditor } from 'platejs/react'
import { definePlugin } from 'platejs/react';
import { FixedToolbar } from '@/components/editor/fixed-toolbar';
import { FixedToolbarButtons } from '@/components/editor/fixed-toolbar';
import { FloatingToolbar } from '@/components/editor/floating-toolbar';
const fixedToolbarPlugin = definePlugin('fixedToolbar', {
slots: {
beforeContainer: () => (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
),
},
});
const floatingToolbarPlugin = definePlugin('floatingToolbar', {
slots: {
afterEditable: FloatingToolbar,
},
});
const editor = createEditor({
plugins: [
// ...otherPlugins,
fixedToolbarPlugin,
floatingToolbarPlugin,
],
});import { definePlugin } from 'platejs/react';
import { FixedToolbar } from '@/components/editor/fixed-toolbar';
import { FixedToolbarButtons } from '@/components/editor/fixed-toolbar';
import { FloatingToolbar } from '@/components/editor/floating-toolbar';
const fixedToolbarPlugin = definePlugin('fixedToolbar', {
slots: {
beforeContainer: () => (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
),
},
});
const floatingToolbarPlugin = definePlugin('floatingToolbar'
slots.beforeContainer: Renders FixedToolbar before the editor scroll containerslots.afterEditable: Renders FloatingToolbar as an overlay after the editorPlace the editor container in an EditorFrame. The fixed toolbar and scroll
container then occupy separate rows inside the same bounded layout.
<EditorFrame className="h-[650px]">
<EditorContainer>
<Editor />
</EditorContainer>
</EditorFrame><EditorFrame className="h-[650px]">
<EditorContainer>
<Editor />
</EditorContainer>
</EditorFrame>The FixedToolbarButtons component contains the default set of buttons for the fixed toolbar.
'use client';
import {
BaselineIcon,
BoldIcon,
Code2Icon,
HighlighterIcon,
ItalicIcon,
PaintBucketIcon,
StrikethroughIcon,
UnderlineIcon,
WandSparklesIcon,
} from 'lucide-react';
import {
BaseAudioPlugin,
BaseFilePlugin,
BaseImagePlugin,
BaseVideoPlugin,
} from 'platejs/media';
import {
BoldPlugin,
CodePlugin,
HighlightPlugin,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
FontBackgroundColorPlugin,
To customize it, you can edit components/editor/fixed-toolbar.tsx.
Similarly, you can customize the floating toolbar by editing components/editor/floating-toolbar.tsx.
'use client';
import { flip, offset, useDismiss, useInteractions } from '@floating-ui/react';
import {
BoldIcon,
Code2Icon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon,
WandSparklesIcon,
} from 'lucide-react';
import { AIChatPlugin } from 'platejs/ai/react';
import {
BoldPlugin,
CodePlugin,
type EditableSiblingProps,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
definePlugin,
useComposedRef,
useEditorFocused,
useEditorReadOnly,
useEditorSelector,
This example shows a button that inserts custom text into the editor.
import { useEditor } from 'platejs/react';
import { CustomIcon } from 'lucide-react';
import { ToolbarButton } from '@/components/editor/toolbar';
export function CustomToolbarButton() {
const editor = useEditor();
return (
<ToolbarButton
onClick={() => {
// Custom action
editor.update((tx) => {
tx.text.insert('Custom text');
});
}}
tooltip="Custom Action"
For toggling marks like bold or italic, you can use the MarkToolbarButton component. It simplifies the process by handling the toggle state and action automatically.
This example creates a "Bold" button.
import { BoldIcon } from 'lucide-react';
import { BoldPlugin } from 'platejs/react';
import { MarkToolbarButton } from '@/components/editor/mark-toolbar-button';
export function BoldToolbarButton() {
return (
<MarkToolbarButton plugin={BoldPlugin} tooltip="Bold (⌘+B)">
<BoldIcon />
</MarkToolbarButton>
);
}plugin: Specifies the mark plugin to toggle (for example, BoldPlugin).tooltip: Provides a helpful tooltip for the button.MarkToolbarButton reads mark state and handles toggling directly in the component.The TurnIntoToolbarButton provides a dropdown menu for leaf formats and structural block actions.
'use client';
import {
ChevronRightIcon,
Columns3Icon,
FileCodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
ListIcon,
ListOrderedIcon,
PilcrowIcon,
QuoteIcon,
SquareIcon,
} from 'lucide-react';
import {
BaseBlockquotePlugin,
BaseCodeBlockPlugin,
BaseHeadingPlugin,
BaseListPlugin,
ElementApi,
PathApi,
SelectionApi,
type Element,
type Path,
Each item calls its feature directly. Keep mutually exclusive text, heading, and list formats in the radio group. Render wrappers and layouts as separate actions with their own selection checks.
const quote = editor.plugin(BaseBlockquotePlugin);
if (quote.installed) {
editor.update((tx) => {
tx.plugin(BaseBlockquotePlugin).toggle();
});
}const quote = editor.plugin(BaseBlockquotePlugin);
if (quote.installed) {
editor.update((tx) => {
tx.plugin
The InsertToolbarButton provides a dropdown menu to insert various elements (blocks, lists, media, inline elements).
'use client';
import {
CalendarIcon,
ChevronRightIcon,
Code2,
Columns3Icon,
FileCodeIcon,
FilmIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImageIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
MinusIcon,
PenToolIcon,
PilcrowIcon,
PlusIcon,
QuoteIcon,
RadicalIcon,
SquareIcon,
SuperscriptIcon,
TableIcon,
TableOfContentsIcon,
} from 'lucide-react';
import {
BaseBlockquotePlugin,
To add an insertable item, define its typed operation in getGroups. The local value identifies the menu item; it does not dispatch the edit.
{
icon: <Heading2Icon />,
label: 'Heading 2',
value: 'heading-2',
onSelect: () => {
const heading = editor.plugin(BaseHeadingPlugin);
if (!heading.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
insertBlock(tx, {
matches: (block) =>
!block.listType &&
block.type === heading.schema.type &&
Plugin that renders a fixed toolbar before the editor scroll container.
Plugin that renders a floating toolbar that appears on text selection.
'use client';
import { flip, offset, useDismiss, useInteractions } from '@floating-ui/react';
import {
BoldIcon,
Code2Icon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon,
WandSparklesIcon,
} from 'lucide-react';
import { AIChatPlugin } from 'platejs/ai/react';
import {
BoldPlugin,
CodePlugin,
type EditableSiblingProps,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
definePlugin,
useComposedRef,
useEditorFocused,
useEditorReadOnly,
useEditorSelector,
usePluginStore,
useSelectionGeometry,
} from 'platejs/react';
import * as React from 'react';
import { ToolbarGroup, Toolbar } from '@/components/editor/toolbar';
import { useFloatingRect } from '@/hooks/use-floating-rect';
import { AIToolbarButton } from './ai-toolbar-button';
import { CommentToolbarButton } from './comment-toolbar-button';
import { InlineEquationToolbarButton } from './equation-toolbar-button';
import { linkPlugin } from './link';
import { LinkToolbarButton } from './link-toolbar-button';
import { MarkToolbarButton } from './mark-toolbar-button';
import { MoreToolbarButton } from './more-toolbar-button';
import { SuggestionToolbarButton } from './suggestion-toolbar-button';
import { TurnIntoToolbarButton } from './turn-into-toolbar-button';
export function FloatingToolbarButtons() {
const readOnly = useEditorReadOnly();
return (
<>
{!readOnly && (
<>
<ToolbarGroup>
<AIToolbarButton tooltip="AI commands">
<WandSparklesIcon />
Ask AI
</AIToolbarButton>
</ToolbarGroup>
<ToolbarGroup>
<TurnIntoToolbarButton />
<MarkToolbarButton plugin={BoldPlugin} tooltip="Bold (⌘+B)">
<BoldIcon />
</MarkToolbarButton>
<MarkToolbarButton plugin={ItalicPlugin} tooltip="Italic (⌘+I)">
<ItalicIcon />
</MarkToolbarButton>
<MarkToolbarButton
plugin={UnderlinePlugin}
tooltip="Underline (⌘+U)"
>
<UnderlineIcon />
</MarkToolbarButton>
<MarkToolbarButton
plugin={StrikethroughPlugin}
tooltip="Strikethrough (⌘+⇧+M)"
>
<StrikethroughIcon />
</MarkToolbarButton>
<MarkToolbarButton plugin={CodePlugin} tooltip="Code (⌘+E)">
<Code2Icon />
</MarkToolbarButton>
<InlineEquationToolbarButton />
<LinkToolbarButton />
</ToolbarGroup>
</>
)}
<ToolbarGroup>
<CommentToolbarButton />
<SuggestionToolbarButton />
{!readOnly && <MoreToolbarButton />}
</ToolbarGroup>
</>
);
}
export function FloatingToolbar({
children,
...props
}: React.PropsWithChildren<EditableSiblingProps>) {
const hasNodeSelection = useEditorSelector(
(editor) => editor.read.selection.nodes().length > 0
);
if (hasNodeSelection) return null;
return (
<TextFloatingToolbar {...props}>
{children === undefined ? <FloatingToolbarButtons /> : children}
</TextFloatingToolbar>
);
}
function TextFloatingToolbar({
children,
editableRef,
}: React.PropsWithChildren<EditableSiblingProps>) {
const editorFocused = useEditorFocused();
const isFloatingLinkOpen = !!usePluginStore(linkPlugin, 'mode');
const isAIChatOpen = usePluginStore(AIChatPlugin, 'open');
const selectionExpanded = useEditorSelector((innerEditor) =>
innerEditor.read.selection.isExpanded()
);
const selectionText = useEditorSelector((innerEditor2) =>
innerEditor2.read.text.string()
);
const selectionRange = useEditorSelector((innerEditor3) =>
innerEditor3.read.selection()
);
const waitForCollapsedSelection = useEditorSelector(
(innerEditor4, previous = false) => {
if (!innerEditor4.read.selection.isExpanded()) return false;
if (!innerEditor4.read.view.isFocused()) return true;
return previous;
}
);
const readOnly = useEditorReadOnly();
const [dismissedSelection, setDismissedSelection] =
React.useState<typeof selectionRange>(null);
const [mouseDownOpen, setMouseDownOpen] = React.useState<boolean | null>(
null
);
const [ownedOverlayOpen, setOwnedOverlayOpen] = React.useState(false);
const open =
selectionExpanded &&
!!selectionText &&
(editorFocused || ownedOverlayOpen) &&
!isFloatingLinkOpen &&
!isAIChatOpen &&
!readOnly &&
(!waitForCollapsedSelection || ownedOverlayOpen) &&
mouseDownOpen !== false &&
dismissedSelection !== selectionRange;
const openStateRef = React.useRef(open);
React.useEffect(() => {
openStateRef.current = open;
}, [open]);
React.useEffect(() => {
const document = editableRef.current?.ownerDocument;
if (!document) return undefined;
const onMouseUp = () => {
setMouseDownOpen(null);
};
const onMouseDown = () => {
setMouseDownOpen(openStateRef.current);
};
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('mousedown', onMouseDown);
return () => {
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('mousedown', onMouseDown);
};
}, [editableRef]);
if (!open) return null;
return (
<PositionedFloatingToolbar
editableRef={editableRef}
onOpenChange={(nextOpen) => {
setDismissedSelection(nextOpen ? null : selectionRange);
}}
onOverlayOpenChange={setOwnedOverlayOpen}
>
{children}
</PositionedFloatingToolbar>
);
}
function PositionedFloatingToolbar({
children,
editableRef,
onOpenChange,
onOverlayOpenChange,
}: React.PropsWithChildren<EditableSiblingProps> & {
onOpenChange: (open: boolean) => void;
onOverlayOpenChange: (open: boolean) => void;
}) {
const geometry = useSelectionGeometry({ editableRef });
const floating = useFloatingRect(geometry?.boundingRect ?? null, {
open: true,
middleware: [
offset(12),
flip({
fallbackPlacements: [
'top-start',
'top-end',
'bottom-start',
'bottom-end',
],
padding: 12,
}),
],
placement: 'top',
onOpenChange,
});
const dismiss = useDismiss(floating.context, {
escapeKey: false,
outsidePress: (event) => {
const Element =
floating.elements.floating?.ownerDocument.defaultView?.Element;
return (
!Element ||
!(event.target instanceof Element) ||
!event.target.closest('[class~="ignore-click-outside/toolbar"]')
);
},
});
const { getFloatingProps } = useInteractions([dismiss]);
const ref = useComposedRef(floating.refs.setFloating);
return (
<Toolbar
{...getFloatingProps()}
ref={ref}
onOverlayOpenChange={onOverlayOpenChange}
style={floating.style}
className="absolute z-50 scrollbar-hide max-w-[80vw] overflow-x-auto rounded-md border bg-popover p-1 whitespace-nowrap opacity-100 shadow-md print:hidden"
>
{children}
</Toolbar>
);
}
export const FloatingToolbarPlugin = definePlugin('floatingToolbar', {
slots: {
afterEditable: FloatingToolbar,
},
});
export const FloatingToolbarKit = [FloatingToolbarPlugin] as const;'use client';
import {
BaselineIcon,
BoldIcon,
Code2Icon,
HighlighterIcon,
ItalicIcon,
PaintBucketIcon,
StrikethroughIcon,
UnderlineIcon,
WandSparklesIcon,
} from 'lucide-react';
import {
BaseAudioPlugin,
BaseFilePlugin,
BaseImagePlugin,
BaseVideoPlugin,
} from 'platejs/media';
import {
BoldPlugin,
CodePlugin,
HighlightPlugin,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
FontBackgroundColorPlugin,
FontColorPlugin,
useEditorReadOnly,
definePlugin,
} from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { ToolbarGroup, Toolbar } from '@/components/editor/toolbar';
import { AIToolbarButton } from './ai-toolbar-button';
import { AlignToolbarButton } from './align-toolbar-button';
import {
AllCommentsButton,
CommentToolbarButton,
} from './comment-toolbar-button';
import { DetailsToolbarButton } from './details-toolbar-button';
import { EmojiToolbarButton } from './emoji-toolbar-button';
import {
DEFAULT_COLORS,
FontColorToolbarButton,
} from './font-color-toolbar-button';
import { FontSizeToolbarButton } from './font-size-toolbar-button';
import { RedoToolbarButton, UndoToolbarButton } from './history-toolbar-button';
import {
IndentToolbarButton,
OutdentToolbarButton,
} from './indent-toolbar-button';
import { InsertToolbarButton } from './insert-toolbar-button';
import { LineHeightToolbarButton } from './line-height-toolbar-button';
import { LinkToolbarButton } from './link-toolbar-button';
import {
BulletedListToolbarButton,
NumberedListToolbarButton,
TodoListToolbarButton,
} from './list-toolbar-button';
import { MarkToolbarButton } from './mark-toolbar-button';
import { MediaToolbarButton } from './media-toolbar-button';
import { ModeToolbarButton } from './mode-toolbar-button';
import { MoreToolbarButton } from './more-toolbar-button';
import { TableToolbarButton } from './table-toolbar-button';
import { TurnIntoToolbarButton } from './turn-into-toolbar-button';
export function FixedToolbarButtons({
children,
}: {
children?: React.ReactNode;
} = {}) {
const readOnly = useEditorReadOnly();
return (
<div className="flex w-full">
{!readOnly && (
<>
<ToolbarGroup>
<UndoToolbarButton />
<RedoToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<AIToolbarButton tooltip="AI commands">
<WandSparklesIcon />
</AIToolbarButton>
</ToolbarGroup>
{children}
<ToolbarGroup>
<InsertToolbarButton />
<TurnIntoToolbarButton />
<FontSizeToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<MarkToolbarButton plugin={BoldPlugin} tooltip="Bold (⌘+B)">
<BoldIcon />
</MarkToolbarButton>
<MarkToolbarButton plugin={ItalicPlugin} tooltip="Italic (⌘+I)">
<ItalicIcon />
</MarkToolbarButton>
<MarkToolbarButton
plugin={UnderlinePlugin}
tooltip="Underline (⌘+U)"
>
<UnderlineIcon />
</MarkToolbarButton>
<MarkToolbarButton
plugin={StrikethroughPlugin}
tooltip="Strikethrough (⌘+⇧+M)"
>
<StrikethroughIcon />
</MarkToolbarButton>
<MarkToolbarButton plugin={CodePlugin} tooltip="Code (⌘+E)">
<Code2Icon />
</MarkToolbarButton>
<FontColorToolbarButton
colors={DEFAULT_COLORS}
plugin={FontColorPlugin}
tooltip="Text color"
>
<BaselineIcon />
</FontColorToolbarButton>
<FontColorToolbarButton
colors={DEFAULT_COLORS}
plugin={FontBackgroundColorPlugin}
tooltip="Background color"
>
<PaintBucketIcon />
</FontColorToolbarButton>
</ToolbarGroup>
<ToolbarGroup>
<AlignToolbarButton />
<NumberedListToolbarButton />
<BulletedListToolbarButton />
<TodoListToolbarButton />
<DetailsToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<LinkToolbarButton />
<TableToolbarButton />
<EmojiToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<MediaToolbarButton plugin={BaseImagePlugin} />
<MediaToolbarButton plugin={BaseVideoPlugin} />
<MediaToolbarButton plugin={BaseAudioPlugin} />
<MediaToolbarButton plugin={BaseFilePlugin} />
</ToolbarGroup>
<ToolbarGroup>
<LineHeightToolbarButton />
<OutdentToolbarButton />
<IndentToolbarButton />
</ToolbarGroup>
<ToolbarGroup>
<MoreToolbarButton />
</ToolbarGroup>
</>
)}
<div className="grow" />
<ToolbarGroup>
<MarkToolbarButton plugin={HighlightPlugin} tooltip="Highlight">
<HighlighterIcon />
</MarkToolbarButton>
<CommentToolbarButton />
<AllCommentsButton />
</ToolbarGroup>
<ToolbarGroup>
<ModeToolbarButton />
</ToolbarGroup>
</div>
);
}
export function FixedToolbar({
className,
ref,
...props
}: React.ComponentProps<typeof Toolbar>) {
return (
<Toolbar
{...props}
ref={ref}
className={cn(
'scrollbar-hide z-50 w-full shrink-0 justify-between overflow-x-auto rounded-t-lg border-b border-b-border bg-background/95 p-1 backdrop-blur-sm supports-backdrop-blur:bg-background/60',
className
)}
data-slot="fixed-toolbar"
/>
);
}
export const FixedToolbarPlugin = definePlugin('fixedToolbar', {
slots: {
beforeContainer: () => (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
),
},
});
export const FixedToolbarKit = [FixedToolbarPlugin] as const;'use client';
import { flip, offset, useDismiss, useInteractions } from '@floating-ui/react';
import {
BoldIcon,
Code2Icon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon,
WandSparklesIcon,
} from 'lucide-react';
import { AIChatPlugin } from 'platejs/ai/react';
import {
BoldPlugin,
CodePlugin,
type EditableSiblingProps,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
definePlugin,
useComposedRef,
useEditorFocused,
useEditorReadOnly,
useEditorSelector,
usePluginStore,
useSelectionGeometry,
} from 'platejs/react';
import * as React from 'react';
import { ToolbarGroup, Toolbar } from '@/components/editor/toolbar';
import { useFloatingRect } from '@/hooks/use-floating-rect';
import { AIToolbarButton } from './ai-toolbar-button';
import { CommentToolbarButton } from './comment-toolbar-button';
import { InlineEquationToolbarButton } from './equation-toolbar-button';
import { linkPlugin } from './link';
import { LinkToolbarButton } from './link-toolbar-button';
import { MarkToolbarButton } from './mark-toolbar-button';
import { MoreToolbarButton } from './more-toolbar-button';
import { SuggestionToolbarButton } from './suggestion-toolbar-button';
import { TurnIntoToolbarButton } from './turn-into-toolbar-button';
export function FloatingToolbarButtons() {
const readOnly = useEditorReadOnly();
return (
<>
{!readOnly && (
<>
<ToolbarGroup>
<AIToolbarButton tooltip="AI commands">
<WandSparklesIcon />
Ask AI
</AIToolbarButton>
</ToolbarGroup>
<ToolbarGroup>
<TurnIntoToolbarButton />
<MarkToolbarButton plugin={BoldPlugin} tooltip="Bold (⌘+B)">
<BoldIcon />
</MarkToolbarButton>
<MarkToolbarButton plugin={ItalicPlugin} tooltip="Italic (⌘+I)">
<ItalicIcon />
</MarkToolbarButton>
<MarkToolbarButton
plugin={UnderlinePlugin}
tooltip="Underline (⌘+U)"
>
<UnderlineIcon />
</MarkToolbarButton>
<MarkToolbarButton
plugin={StrikethroughPlugin}
tooltip="Strikethrough (⌘+⇧+M)"
>
<StrikethroughIcon />
</MarkToolbarButton>
<MarkToolbarButton plugin={CodePlugin} tooltip="Code (⌘+E)">
<Code2Icon />
</MarkToolbarButton>
<InlineEquationToolbarButton />
<LinkToolbarButton />
</ToolbarGroup>
</>
)}
<ToolbarGroup>
<CommentToolbarButton />
<SuggestionToolbarButton />
{!readOnly && <MoreToolbarButton />}
</ToolbarGroup>
</>
);
}
export function FloatingToolbar({
children,
...props
}: React.PropsWithChildren<EditableSiblingProps>) {
const hasNodeSelection = useEditorSelector(
(editor) => editor.read.selection.nodes().length > 0
);
if (hasNodeSelection) return null;
return (
<TextFloatingToolbar {...props}>
{children === undefined ? <FloatingToolbarButtons /> : children}
</TextFloatingToolbar>
);
}
function TextFloatingToolbar({
children,
editableRef,
}: React.PropsWithChildren<EditableSiblingProps>) {
const editorFocused = useEditorFocused();
const isFloatingLinkOpen = !!usePluginStore(linkPlugin, 'mode');
const isAIChatOpen = usePluginStore(AIChatPlugin, 'open');
const selectionExpanded = useEditorSelector((innerEditor) =>
innerEditor.read.selection.isExpanded()
);
const selectionText = useEditorSelector((innerEditor2) =>
innerEditor2.read.text.string()
);
const selectionRange = useEditorSelector((innerEditor3) =>
innerEditor3.read.selection()
);
const waitForCollapsedSelection = useEditorSelector(
(innerEditor4, previous = false) => {
if (!innerEditor4.read.selection.isExpanded()) return false;
if (!innerEditor4.read.view.isFocused()) return true;
return previous;
}
);
const readOnly = useEditorReadOnly();
const [dismissedSelection, setDismissedSelection] =
React.useState<typeof selectionRange>(null);
const [mouseDownOpen, setMouseDownOpen] = React.useState<boolean | null>(
null
);
const [ownedOverlayOpen, setOwnedOverlayOpen] = React.useState(false);
const open =
selectionExpanded &&
!!selectionText &&
(editorFocused || ownedOverlayOpen) &&
!isFloatingLinkOpen &&
!isAIChatOpen &&
!readOnly &&
(!waitForCollapsedSelection || ownedOverlayOpen) &&
mouseDownOpen !== false &&
dismissedSelection !== selectionRange;
const openStateRef = React.useRef(open);
React.useEffect(() => {
openStateRef.current = open;
}, [open]);
React.useEffect(() => {
const document = editableRef.current?.ownerDocument;
if (!document) return undefined;
const onMouseUp = () => {
setMouseDownOpen(null);
};
const onMouseDown = () => {
setMouseDownOpen(openStateRef.current);
};
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('mousedown', onMouseDown);
return () => {
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('mousedown', onMouseDown);
};
}, [editableRef]);
if (!open) return null;
return (
<PositionedFloatingToolbar
editableRef={editableRef}
onOpenChange={(nextOpen) => {
setDismissedSelection(nextOpen ? null : selectionRange);
}}
onOverlayOpenChange={setOwnedOverlayOpen}
>
{children}
</PositionedFloatingToolbar>
);
}
function PositionedFloatingToolbar({
children,
editableRef,
onOpenChange,
onOverlayOpenChange,
}: React.PropsWithChildren<EditableSiblingProps> & {
onOpenChange: (open: boolean) => void;
onOverlayOpenChange: (open: boolean) => void;
}) {
const geometry = useSelectionGeometry({ editableRef });
const floating = useFloatingRect(geometry?.boundingRect ?? null, {
open: true,
middleware: [
offset(12),
flip({
fallbackPlacements: [
'top-start',
'top-end',
'bottom-start',
'bottom-end',
],
padding: 12,
}),
],
placement: 'top',
onOpenChange,
});
const dismiss = useDismiss(floating.context, {
escapeKey: false,
outsidePress: (event) => {
const Element =
floating.elements.floating?.ownerDocument.defaultView?.Element;
return (
!Element ||
!(event.target instanceof Element) ||
!event.target.closest('[class~="ignore-click-outside/toolbar"]')
);
},
});
const { getFloatingProps } = useInteractions([dismiss]);
const ref = useComposedRef(floating.refs.setFloating);
return (
<Toolbar
{...getFloatingProps()}
ref={ref}
onOverlayOpenChange={onOverlayOpenChange}
style={floating.style}
className="absolute z-50 scrollbar-hide max-w-[80vw] overflow-x-auto rounded-md border bg-popover p-1 whitespace-nowrap opacity-100 shadow-md print:hidden"
>
{children}
</Toolbar>
);
}
export const FloatingToolbarPlugin = definePlugin('floatingToolbar', {
slots: {
afterEditable: FloatingToolbar,
},
});
export const FloatingToolbarKit = [FloatingToolbarPlugin] as const;import { useEditor } from 'platejs/react';
import { CustomIcon } from 'lucide-react';
import { ToolbarButton } from '@/components/editor/toolbar';
export function CustomToolbarButton() {
const editor = useEditor();
return (
<ToolbarButton
onClick={() => {
// Custom action
editor.update((tx) => {
tx.text.insert('Custom text');
});
}}
tooltip="Custom Action"
>
<CustomIcon />
</ToolbarButton>
);
}import { BoldIcon } from 'lucide-react';
import { BoldPlugin } from 'platejs/react';
import { MarkToolbarButton } from '@/components/editor/mark-toolbar-button';
export function BoldToolbarButton() {
return (
<MarkToolbarButton plugin={BoldPlugin} tooltip="Bold (⌘+B)">
<BoldIcon />
</MarkToolbarButton>
);
}'use client';
import {
ChevronRightIcon,
Columns3Icon,
FileCodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
ListIcon,
ListOrderedIcon,
PilcrowIcon,
QuoteIcon,
SquareIcon,
} from 'lucide-react';
import {
BaseBlockquotePlugin,
BaseCodeBlockPlugin,
BaseHeadingPlugin,
BaseListPlugin,
ElementApi,
PathApi,
SelectionApi,
type Element,
type Path,
} from 'platejs';
import { BaseDetailsPlugin } from 'platejs/details';
import { BaseColumnPlugin } from 'platejs/layout';
import {
type Editor,
useEditor,
useEditorReadOnly,
useEditorSelector,
useSelectionFragmentProp,
} from 'platejs/react';
import * as React from 'react';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/editor/dropdown-menu';
import {
ToolbarButton,
ToolbarMenuGroup,
} from '@/components/editor/toolbar';
type LeafFormat =
| 'decimal'
| 'disc'
| 'heading-1'
| 'heading-2'
| 'heading-3'
| 'heading-4'
| 'heading-5'
| 'heading-6'
| 'text'
| 'todo';
type LeafItem = {
icon: React.ReactNode;
label: string;
onSelect: () => void;
value: LeafFormat;
};
type StructuralActionState = {
active: boolean;
eligible: boolean;
};
type StructuralState = {
code: StructuralActionState;
columns: StructuralActionState;
details: StructuralActionState;
quote: StructuralActionState;
};
const structuralStateEqual = (
previous: StructuralState | null,
next: StructuralState
) =>
!!previous &&
previous.code.active === next.code.active &&
previous.code.eligible === next.code.eligible &&
previous.columns.active === next.columns.active &&
previous.columns.eligible === next.columns.eligible &&
previous.details.active === next.details.active &&
previous.details.eligible === next.details.eligible &&
previous.quote.active === next.quote.active &&
previous.quote.eligible === next.quote.eligible;
const readBlocks = (
editor: Editor,
mode: 'highest' | 'lowest'
): ReturnType<Editor['read']['nodes']['blocks']> => {
const selection = editor.read.selection();
if (
!selection ||
(SelectionApi.isText(selection) &&
!editor.read.selection.isValid(selection))
) {
return [];
}
return editor.read.nodes.blocks({ mode });
};
const isContiguousSiblingRun = (
entries: ReturnType<Editor['read']['nodes']['blocks']>
) => {
if (entries.length === 0) return false;
const paths = entries.map(([, path]) => path).toSorted(PathApi.compare);
const parent = PathApi.parent(paths[0]);
const firstIndex = paths[0].at(-1);
return (
firstIndex !== undefined &&
paths.every(
(path, index) =>
PathApi.equals(PathApi.parent(path), parent) &&
path.at(-1) === firstIndex + index
)
);
};
const hasWrapper = (
editor: Editor,
entry: ReturnType<Editor['read']['nodes']['blocks']>[number],
plugin: typeof BaseBlockquotePlugin | typeof BaseDetailsPlugin
) => {
const portal = editor.plugin(plugin);
return (
portal.installed &&
(entry[0].type === portal.schema.type ||
!!editor.read.nodes.above({ at: entry[1], type: plugin }))
);
};
const getStructuralState = (editor: Editor): StructuralState => {
const entries = readBlocks(editor, 'highest');
const leafEntries = readBlocks(editor, 'lowest');
const selectedEntries = editor.read.selection.nodes();
const hasSelection = entries.length > 0;
const hasContainerNodeSelection = selectedEntries.some(([, selectedPath]) =>
leafEntries.some(([, path]) => PathApi.isAncestor(selectedPath, path))
);
const quote = editor.plugin(BaseBlockquotePlugin);
const details = editor.plugin(BaseDetailsPlugin);
const code = editor.plugin(BaseCodeBlockPlugin);
const columns = editor.plugin(BaseColumnPlugin);
const quoteActive =
quote.installed &&
hasSelection &&
entries.every((entry) => hasWrapper(editor, entry, BaseBlockquotePlugin));
const detailsMembership = details.installed
? entries.map((entry) => hasWrapper(editor, entry, BaseDetailsPlugin))
: [];
const detailsActive = hasSelection && detailsMembership.every(Boolean);
const detailsMixed = detailsMembership.some(Boolean) && !detailsActive;
const codeMembership = code.installed
? entries.map(([node]) => !node.listType && node.type === code.schema.type)
: [];
const codeActive = hasSelection && codeMembership.every(Boolean);
const codeMixed = codeMembership.some(Boolean) && !codeActive;
const columnActive =
columns.installed &&
entries.length === 1 &&
(entries[0][0].type === columns.schema.type ||
!!editor.read.nodes.above({
at: entries[0][1],
type: BaseColumnPlugin,
}));
return {
code: {
active: codeActive,
eligible: code.installed && !codeMixed && isContiguousSiblingRun(entries),
},
columns: {
active: columnActive,
eligible:
columns.installed &&
(columnActive || (!hasContainerNodeSelection && entries.length === 1)),
},
details: {
active: detailsActive,
eligible:
details.installed &&
!detailsMixed &&
(detailsActive ||
(!hasContainerNodeSelection && isContiguousSiblingRun(entries))),
},
quote: {
active: quoteActive,
eligible: quote.installed && hasSelection,
},
};
};
const getDetailsPaths = (editor: Editor): Path[] => {
const details = editor.plugin(BaseDetailsPlugin);
if (!details.installed) return [];
const paths = editor.read.nodes
.blocks({ mode: 'highest' })
.flatMap(([node, path]) => {
if (node.type === details.schema.type) return [path];
const ancestor = editor.read.nodes.above({
at: path,
type: BaseDetailsPlugin,
});
return ancestor ? [ancestor[1]] : [];
});
return paths.filter(
(path, index) =>
paths.findIndex((candidate) => PathApi.equals(candidate, path)) === index
);
};
const getLeafFormat = (editor: Editor, node: Element): LeafFormat => {
const list = editor.plugin(BaseListPlugin);
if (list.installed && node.listType) {
if (node.listType === 'numbered') return 'decimal';
if (node.listType === 'task') return 'todo';
return 'disc';
}
const heading = editor.plugin(BaseHeadingPlugin);
if (heading.installed && node.type === heading.schema.type) {
switch (node.level) {
case 1:
case 2:
case 3:
case 4:
case 5:
case 6: {
return `heading-${node.level}`;
}
}
}
return 'text';
};
const isLeafFormatActive = (editor: Editor, value: LeafFormat) => {
const entries = readBlocks(editor, 'lowest');
return (
entries.length > 0 &&
entries.every(([node]) => getLeafFormat(editor, node) === value)
);
};
const getTurnIntoLabel = (value: LeafFormat | undefined) => {
switch (value) {
case 'disc': {
return 'Bulleted list';
}
case 'decimal': {
return 'Numbered list';
}
case 'todo': {
return 'To-do list';
}
case 'heading-1': {
return 'Heading 1';
}
case 'heading-2': {
return 'Heading 2';
}
case 'heading-3': {
return 'Heading 3';
}
case 'heading-4': {
return 'Heading 4';
}
case 'heading-5': {
return 'Heading 5';
}
case 'heading-6': {
return 'Heading 6';
}
case 'text': {
return 'Text';
}
default: {
return 'Mixed';
}
}
};
function TurnIntoLeafItem({
children,
icon,
value,
}: React.PropsWithChildren<{
icon: React.ReactNode;
value: LeafFormat;
}>) {
return (
<DropdownMenuRadioItem
className="min-w-[180px] pl-2 *:first:[span]:hidden"
value={value}
>
{icon}
{children}
</DropdownMenuRadioItem>
);
}
export function TurnIntoToolbarButton() {
const editor = useEditor();
const readOnly = useEditorReadOnly();
const [open, setOpen] = React.useState(false);
const leafValue = useSelectionFragmentProp({
defaultValue: 'text',
getProp: (node) =>
ElementApi.isElement(node) ? getLeafFormat(editor, node) : undefined,
}) as LeafFormat | undefined;
const structural = useEditorSelector(getStructuralState, {
equalityFn: structuralStateEqual,
});
const hasSelection = useEditorSelector(
(current) => readBlocks(current, 'highest').length > 0
);
const heading = editor.plugin(BaseHeadingPlugin);
const list = editor.plugin(BaseListPlugin);
const quote = editor.plugin(BaseBlockquotePlugin);
const details = editor.plugin(BaseDetailsPlugin);
const code = editor.plugin(BaseCodeBlockPlugin);
const columns = editor.plugin(BaseColumnPlugin);
const leafItems: LeafItem[] = [
{
icon: <PilcrowIcon />,
label: 'Text',
value: 'text',
onSelect: () => {
if (
editor.read.view.isReadOnly() ||
isLeafFormatActive(editor, 'text')
) {
return;
}
editor.update((tx) => {
if (editor.plugin(BaseListPlugin).installed) {
tx.plugin(BaseListPlugin).clear();
}
tx.blocks.reset();
});
},
},
];
if (heading.installed) {
(
[
['heading-1', 'Heading 1', <Heading1Icon key="heading-1" />, 1],
['heading-2', 'Heading 2', <Heading2Icon key="heading-2" />, 2],
['heading-3', 'Heading 3', <Heading3Icon key="heading-3" />, 3],
['heading-4', 'Heading 4', <Heading4Icon key="heading-4" />, 4],
['heading-5', 'Heading 5', <Heading5Icon key="heading-5" />, 5],
['heading-6', 'Heading 6', <Heading6Icon key="heading-6" />, 6],
] as const
).forEach(([value, label, icon, level]) => {
leafItems.push({
icon,
label,
value,
onSelect: () => {
const current = editor.plugin(BaseHeadingPlugin);
if (
!current.installed ||
editor.read.view.isReadOnly() ||
isLeafFormatActive(editor, value)
) {
return;
}
editor.update((tx) => {
if (editor.plugin(BaseListPlugin).installed) {
tx.plugin(BaseListPlugin).clear();
}
tx.blocks.set({
level,
type: current.schema.type,
});
});
},
});
});
}
if (list.installed) {
(
[
['disc', 'Bulleted list', <ListIcon key="disc" />, 'bulleted'],
[
'decimal',
'Numbered list',
<ListOrderedIcon key="decimal" />,
'numbered',
],
['todo', 'To-do list', <SquareIcon key="todo" />, 'task'],
] as const
).forEach(([value, label, icon, type]) => {
leafItems.push({
icon,
label,
value,
onSelect: () => {
if (
!editor.plugin(BaseListPlugin).installed ||
editor.read.view.isReadOnly() ||
isLeafFormatActive(editor, value)
) {
return;
}
editor.update((tx) => {
tx.plugin(BaseListPlugin).clear();
tx.blocks.reset();
tx.plugin(BaseListPlugin).toggle({ type });
});
},
});
});
}
const structuralItems = [
...(quote.installed
? [
{
...structural.quote,
icon: <QuoteIcon />,
label: 'Quote',
onSelect: () => {
const current = getStructuralState(editor).quote;
if (!current.eligible || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
if (editor.plugin(BaseListPlugin).installed) {
tx.plugin(BaseListPlugin).clear();
}
tx.plugin(BaseBlockquotePlugin).toggle();
});
},
},
]
: []),
...(details.installed
? [
{
...structural.details,
icon: <ChevronRightIcon />,
label: 'Details',
onSelect: () => {
const current = getStructuralState(editor).details;
if (!current.eligible || editor.read.view.isReadOnly()) return;
const portal = editor.plugin(BaseDetailsPlugin);
if (!portal.installed) return;
if (current.active) {
const paths = getDetailsPaths(editor);
if (paths.length === 0) return;
const selection = editor.read.selection();
const root = selection && SelectionApi.root(selection);
portal.update.unwrap({
at: SelectionApi.nodes(paths as [Path, ...Path[]], {
...(root ? { root } : {}),
}),
});
} else {
portal.update.wrap();
}
},
},
]
: []),
...(code.installed
? [
{
...structural.code,
icon: <FileCodeIcon />,
label: 'Code',
onSelect: () => {
const current = getStructuralState(editor).code;
const portal = editor.plugin(BaseCodeBlockPlugin);
if (
!portal.installed ||
!current.eligible ||
editor.read.view.isReadOnly()
) {
return;
}
portal.update.toggle();
},
},
]
: []),
...(columns.installed
? [
{
...structural.columns,
icon: <Columns3Icon />,
label: '3 columns',
onSelect: () => {
const current = getStructuralState(editor).columns;
const portal = editor.plugin(BaseColumnPlugin);
if (
!portal.installed ||
!current.eligible ||
editor.read.view.isReadOnly()
) {
return;
}
portal.update.toggle({ columns: 3 });
},
},
]
: []),
];
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger>
<ToolbarButton
className="min-w-[125px]"
disabled={readOnly || !hasSelection}
pressed={open}
tooltip="Turn into"
isDropdown
>
{getTurnIntoLabel(leafValue)}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="ignore-click-outside/toolbar min-w-0"
onFinalFocus={(event) => {
event.preventDefault();
editor.api.dom.focus();
}}
align="start"
>
<DropdownMenuRadioGroup
value={leafValue ?? ''}
onValueChange={(value) => {
const item = leafItems.find(
(candidate) => candidate.value === value
);
item?.onSelect();
}}
>
<ToolbarMenuGroup label="Turn into">
{leafItems.map((item) => (
<TurnIntoLeafItem
key={item.value}
icon={item.icon}
value={item.value}
>
{item.label}
</TurnIntoLeafItem>
))}
</ToolbarMenuGroup>
</DropdownMenuRadioGroup>
{structuralItems.length > 0 && (
<ToolbarMenuGroup label="Structure">
{structuralItems.map((item) => (
<DropdownMenuCheckboxItem
key={item.label}
checked={item.active}
className="min-w-[180px]"
disabled={!item.eligible}
onSelect={item.onSelect}
>
{item.icon}
{item.label}
</DropdownMenuCheckboxItem>
))}
</ToolbarMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}'use client';
import {
CalendarIcon,
ChevronRightIcon,
Code2,
Columns3Icon,
FileCodeIcon,
FilmIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImageIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
MinusIcon,
PenToolIcon,
PilcrowIcon,
PlusIcon,
QuoteIcon,
RadicalIcon,
SquareIcon,
SuperscriptIcon,
TableIcon,
TableOfContentsIcon,
} from 'lucide-react';
import {
BaseBlockquotePlugin,
BaseCodeBlockPlugin,
BaseHeadingPlugin,
BaseHorizontalRulePlugin,
BaseListPlugin,
BaseParagraphPlugin,
PLUGINS,
} from 'platejs';
import { BaseCodeDrawingPlugin } from 'platejs/code-drawing';
import { BaseDatePlugin } from 'platejs/date';
import { BaseDetailsPlugin } from 'platejs/details';
import { BaseExcalidrawPlugin } from 'platejs/excalidraw';
import { BaseFootnotePlugin } from 'platejs/footnote';
import { BaseColumnPlugin } from 'platejs/layout';
import { BaseEquationPlugin, BaseInlineEquationPlugin } from 'platejs/math';
import { BaseImagePlugin, BaseMediaEmbedPlugin } from 'platejs/media';
import { type Editor, useEditor, useEditorReadOnly } from 'platejs/react';
import { BaseTablePlugin } from 'platejs/table';
import { BaseTocPlugin } from 'platejs/toc';
import * as React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/editor/dropdown-menu';
import {
ToolbarButton,
ToolbarMenuGroup,
} from '@/components/editor/toolbar';
import { linkPlugin } from './link';
type Item = {
icon: React.ReactNode;
onSelect: () => void;
value: string;
focusEditor?: boolean;
label?: string;
};
type Group = {
group: string;
items: Item[];
};
const canRestoreFocus = (
editor: Editor,
ownerDocument: Document | undefined,
activeElement: Element | null | undefined
) => {
const editable = editor.api.dom.editable();
const currentActiveElement = ownerDocument?.activeElement;
return (
editable?.isConnected === true &&
!editor.read.view.isReadOnly() &&
(!currentActiveElement ||
currentActiveElement === ownerDocument?.body ||
currentActiveElement === activeElement)
);
};
function getGroups(editor: Editor): Group[] {
const groups: Group[] = [];
const basicBlocks: Item[] = [];
const lists: Item[] = [];
const media: Item[] = [];
const advancedBlocks: Item[] = [];
const inline: Item[] = [];
const paragraph = editor.plugin(BaseParagraphPlugin);
const heading = editor.plugin(BaseHeadingPlugin);
const list = editor.plugin(BaseListPlugin);
const table = editor.plugin(BaseTablePlugin);
const codeBlock = editor.plugin(BaseCodeBlockPlugin);
const blockquote = editor.plugin(BaseBlockquotePlugin);
const horizontalRule = editor.plugin(BaseHorizontalRulePlugin);
const details = editor.plugin(BaseDetailsPlugin);
const image = editor.plugin(BaseImagePlugin);
const mediaEmbed = editor.plugin(BaseMediaEmbedPlugin);
const toc = editor.plugin(BaseTocPlugin);
const column = editor.plugin(BaseColumnPlugin);
const equation = editor.plugin(BaseEquationPlugin);
const excalidraw = editor.plugin(BaseExcalidrawPlugin);
const codeDrawing = editor.plugin(BaseCodeDrawingPlugin);
const link = editor.plugin(linkPlugin);
const date = editor.plugin(BaseDatePlugin);
const footnote = editor.plugin(BaseFootnotePlugin);
const inlineEquation = editor.plugin(BaseInlineEquationPlugin);
if (paragraph.installed) {
basicBlocks.push({
icon: <PilcrowIcon />,
label: 'Paragraph',
value: PLUGINS.paragraph,
onSelect: () => {
const current = editor.plugin(BaseParagraphPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseParagraphPlugin).insert({}, { select: true });
});
},
});
}
if (heading.installed) {
(
[
['heading-1', 'Heading 1', <Heading1Icon key="heading-1" />, 1],
['heading-2', 'Heading 2', <Heading2Icon key="heading-2" />, 2],
['heading-3', 'Heading 3', <Heading3Icon key="heading-3" />, 3],
] as const
).forEach(([value, label, icon, level]) => {
basicBlocks.push({
icon,
label,
value,
onSelect: () => {
const current = editor.plugin(BaseHeadingPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseHeadingPlugin).insert({ level }, { select: true });
});
},
});
});
}
if (table.installed) {
basicBlocks.push({
icon: <TableIcon />,
label: 'Table',
value: PLUGINS.table,
onSelect: () => {
const current = editor.plugin(BaseTablePlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseTablePlugin).insert({}, { select: true });
});
},
});
}
if (codeBlock.installed) {
basicBlocks.push({
icon: <FileCodeIcon />,
label: 'Code',
value: PLUGINS.codeBlock,
onSelect: () => {
const current = editor.plugin(BaseCodeBlockPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseCodeBlockPlugin).insert({}, { select: true });
});
},
});
}
if (blockquote.installed) {
basicBlocks.push({
icon: <QuoteIcon />,
label: 'Quote',
value: PLUGINS.blockquote,
onSelect: () => {
const current = editor.plugin(BaseBlockquotePlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseBlockquotePlugin).insert({}, { select: true });
});
},
});
}
if (horizontalRule.installed) {
basicBlocks.push({
icon: <MinusIcon />,
label: 'Divider',
value: PLUGINS.horizontalRule,
onSelect: () => {
const current = editor.plugin(BaseHorizontalRulePlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseHorizontalRulePlugin).insert({}, { select: true });
});
},
});
}
if (list.installed) {
(
[
['disc', 'Bulleted list', <ListIcon key="disc" />, 'bulleted'],
[
'decimal',
'Numbered list',
<ListOrderedIcon key="decimal" />,
'numbered',
],
['todo', 'To-do list', <SquareIcon key="todo" />, 'task'],
] as const
).forEach(([value, label, icon, type]) => {
lists.push({
icon,
label,
value,
onSelect: () => {
if (
!editor.plugin(BaseListPlugin).installed ||
editor.read.view.isReadOnly()
) {
return;
}
editor.update((tx) => {
tx.plugin(BaseListPlugin).insert({ type }, { select: true });
});
},
});
});
}
if (details.installed) {
lists.push({
icon: <ChevronRightIcon />,
label: 'Details',
value: PLUGINS.details,
onSelect: () => {
const current = editor.plugin(BaseDetailsPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseDetailsPlugin).insert({}, { select: true });
});
},
});
}
if (image.installed) {
media.push({
focusEditor: false,
icon: <ImageIcon />,
label: 'Image',
value: PLUGINS.image,
onSelect: () => {
const current = editor.plugin(BaseImagePlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
const editable = editor.api.dom.editable();
const ownerDocument = editable?.ownerDocument;
const activeElement = ownerDocument?.activeElement;
void current.api
.insertUrl(
// oxlint-disable-next-line no-alert -- This copied menu owns its URL input policy.
() => window.prompt('Enter the URL of the image'),
{ select: true }
)
.then((inserted) => {
if (
inserted &&
canRestoreFocus(editor, ownerDocument, activeElement)
) {
editor.api.dom.focus();
}
})
.catch((error: unknown) => {
console.error('Could not insert the image URL.', error);
});
},
});
}
if (mediaEmbed.installed) {
media.push({
focusEditor: false,
icon: <FilmIcon />,
label: 'Embed',
value: PLUGINS.mediaEmbed,
onSelect: () => {
const current = editor.plugin(BaseMediaEmbedPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
const editable = editor.api.dom.editable();
const ownerDocument = editable?.ownerDocument;
const activeElement = ownerDocument?.activeElement;
void current.api
.insertUrl(
// oxlint-disable-next-line no-alert -- This copied menu owns its URL input policy.
() => window.prompt('Enter the URL of the embed'),
{ select: true }
)
.then((inserted) => {
if (
inserted &&
canRestoreFocus(editor, ownerDocument, activeElement)
) {
editor.api.dom.focus();
}
})
.catch((error: unknown) => {
console.error('Could not insert the embed URL.', error);
});
},
});
}
if (toc.installed) {
advancedBlocks.push({
icon: <TableOfContentsIcon />,
label: 'Table of contents',
value: PLUGINS.toc,
onSelect: () => {
const current = editor.plugin(BaseTocPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseTocPlugin).insert({}, { select: true });
});
},
});
}
if (column.installed) {
advancedBlocks.push({
icon: <Columns3Icon />,
label: '3 columns',
value: 'action_three_columns',
onSelect: () => {
const current = editor.plugin(BaseColumnPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseColumnPlugin).insert({ columns: 3 }, { select: true });
});
},
});
}
if (equation.installed) {
advancedBlocks.push({
focusEditor: false,
icon: <RadicalIcon />,
label: 'Equation',
value: PLUGINS.equation,
onSelect: () => {
const current = editor.plugin(BaseEquationPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseEquationPlugin).insert({}, { select: true });
});
},
});
}
if (excalidraw.installed) {
advancedBlocks.push({
icon: <PenToolIcon />,
label: 'Excalidraw',
value: PLUGINS.excalidraw,
onSelect: () => {
const current = editor.plugin(BaseExcalidrawPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseExcalidrawPlugin).insert({}, { select: true });
});
},
});
}
if (codeDrawing.installed) {
advancedBlocks.push({
icon: <Code2 />,
label: 'Code Drawing',
value: PLUGINS.codeDrawing,
onSelect: () => {
const current = editor.plugin(BaseCodeDrawingPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
tx.plugin(BaseCodeDrawingPlugin).insert({}, { select: true });
});
},
});
}
if (link.installed) {
inline.push({
focusEditor: false,
icon: <Link2Icon />,
label: 'Link',
value: PLUGINS.link,
onSelect: () => {
const current = editor.plugin(linkPlugin);
if (!current.installed || editor.read.view.isReadOnly()) return;
current.store.set({ text: editor.read.text.string() });
current.api.show('insert', editor.id);
},
});
}
if (date.installed) {
inline.push({
icon: <CalendarIcon />,
label: 'Date',
value: PLUGINS.date,
onSelect: () => {
if (
!editor.plugin(BaseDatePlugin).installed ||
editor.read.view.isReadOnly()
) {
return;
}
editor.update((tx) => {
tx.plugin(BaseDatePlugin).insert({}, { select: true });
});
},
});
}
if (footnote.installed) {
inline.push({
icon: <SuperscriptIcon />,
label: 'Footnote',
value: 'action_footnote',
onSelect: () => {
if (
!editor.plugin(BaseFootnotePlugin).installed ||
editor.read.view.isReadOnly()
) {
return;
}
editor.update((tx) => {
tx.plugin(BaseFootnotePlugin).insert({}, { select: true });
});
},
});
}
if (inlineEquation.installed) {
inline.push({
focusEditor: false,
icon: <RadicalIcon />,
label: 'Inline Equation',
value: PLUGINS.inlineEquation,
onSelect: () => {
if (
!editor.plugin(BaseInlineEquationPlugin).installed ||
editor.read.view.isReadOnly()
) {
return;
}
editor.update((tx) => {
tx.plugin(BaseInlineEquationPlugin).insert({}, { select: true });
});
},
});
}
if (basicBlocks.length > 0) {
groups.push({ group: 'Basic blocks', items: basicBlocks });
}
if (lists.length > 0) groups.push({ group: 'Lists', items: lists });
if (media.length > 0) groups.push({ group: 'Media', items: media });
if (advancedBlocks.length > 0) {
groups.push({ group: 'Advanced blocks', items: advancedBlocks });
}
if (inline.length > 0) groups.push({ group: 'Inline', items: inline });
return groups;
}
export function InsertToolbarButton() {
const editor = useEditor();
const readOnly = useEditorReadOnly();
const [open, setOpen] = React.useState(false);
const groups = getGroups(editor);
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger>
<ToolbarButton
disabled={readOnly || groups.length === 0}
pressed={open}
tooltip="Insert"
isDropdown
>
<PlusIcon />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="flex max-h-[500px] min-w-0 flex-col overflow-y-auto"
align="start"
>
{groups.map(({ group, items }) => (
<ToolbarMenuGroup key={group} label={group}>
{items.map(
({ focusEditor = true, icon, label, value, onSelect }) => (
<DropdownMenuItem
key={value}
className="min-w-[180px]"
finalFocus={
focusEditor
? () => {
if (!editor.read.view.isReadOnly()) {
editor.api.dom.focus();
}
}
: false
}
onSelect={onSelect}
>
{icon}
{label}
</DropdownMenuItem>
)
)}
</ToolbarMenuGroup>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}{
icon: <Heading2Icon />,
label: 'Heading 2',
value: 'heading-2',
onSelect: () => {
const heading = editor.plugin(BaseHeadingPlugin);
if (!heading.installed || editor.read.view.isReadOnly()) return;
editor.update((tx) => {
insertBlock(tx, {
matches: (block) =>
!block.listType &&
block.type === heading.schema.type &&
block.level === 2,
insert: (options) => {
tx.plugin(BaseHeadingPlugin).insert({ level: 2 }, options);
},
});
});
},
}