useVirtualFloating | @platejs/floating | useFloating with a controlled virtual reference element. |
floating-toolbar | Copied registry UI | Coordinates toolbar visibility, positioning, DOM refs, and outside clicks. |
| Rect utilities | @platejs/floating | Convert editor ranges, DOM selection, and client rect arrays into Floating UI-compatible rects. |
| Floating UI exports | @platejs/floating | Re-exported middleware and hooks from @floating-ui/react. |
useVirtualFloating creates a Floating UI virtual reference. Use it when the floating element follows a selection, cursor, or computed rectangle instead of a real DOM reference element.
import {
flip,
getDefaultBoundingClientRect,
offset,
useVirtualFloating,
} from '@platejs/floating';
export function SelectionPopover({
open,
rect,
}: {
open: boolean;
rect?: DOMRect;
}) {
const floating = useVirtualFloating({
getBoundingClientRect: () => rect ?? getDefaultBoundingClientRect(),
middleware: [offset(8), flip()],
open,
placement: 'top',
});
return (
<div ref={floating.refs.setFloating} style={floating.style}>
Selection actions
</div>
);
}import {
flip,
getDefaultBoundingClientRect,
offset,
useVirtualFloating,
} from '@platejs/floating';
export function SelectionPopover({
open,
rect,
}: {
open: boolean;
rect?: DOMRect;
}) {
const floating = useVirtualFloating({
getBoundingClientRect: () => rect ?? getDefaultBoundingClientRect(),
middleware: [offset(8), flip()],
open,
placement:
The copied floating-toolbar component owns editor focus, selection,
read-only policy, outside clicks, and toolbar positioning. Copy it when your
product needs that complete interaction; use useVirtualFloating directly for
a different floating surface.
'use client';
import { AIChatPlugin } from '@platejs/ai/react';
import {
BoldPlugin,
CodePlugin,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
} from '@platejs/basic-nodes/react';
import {
type UseVirtualFloatingOptions,
flip,
getSelectionBoundingClientRect,
offset,
useVirtualFloating,
} from '@platejs/floating';
import { useComposedRef } from '@udecode/cn';
import { useOnClickOutside } from '@udecode/react-utils';
import { mergeProps } from '@udecode/utils';
import {
BoldIcon,
Code2Icon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon,
WandSparklesIcon,
} from 'lucide-react';
import {
useEditorReadOnly,
definePlatePlugin,
useEditor,
useEditorId,
useEditorSelector,
useEventEditorValue,
usePluginStore,
} 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 { 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>
</>
);
}
type FloatingToolbarOptions = {
floatingOptions?: UseVirtualFloatingOptions;
hideToolbar?: boolean;
showWhenReadOnly?: boolean;
};
type FloatingToolbarProps = React.ComponentProps<typeof Toolbar> & {
options?: FloatingToolbarOptions;
};
export function FloatingToolbar(props: FloatingToolbarProps) {
const editorId = useEditorId();
const hasNodeSelection = useEditorSelector(
(editor) => editor.read.selection.nodes().length > 0,
{ id: editorId }
);
if (hasNodeSelection) return null;
return <TextFloatingToolbar {...props} />;
}
function TextFloatingToolbar({
children,
className,
options,
...props
}: FloatingToolbarProps) {
const editorId = useEditorId();
const focusedEditorId = useEventEditorValue('focus');
const isFloatingLinkOpen = !!usePluginStore(linkPlugin, 'mode');
const isAIChatOpen = usePluginStore(AIChatPlugin, 'open');
const editor = useEditor({ id: editorId });
const selectionExpanded = useEditorSelector(
(innerEditor) => innerEditor.read.selection.isExpanded(),
{ id: editorId }
);
const selectionText = useEditorSelector(
(innerEditor2) => innerEditor2.read.text.string(),
{ id: editorId }
);
const selectionRange = useEditorSelector(
(innerEditor3) => innerEditor3.read.selection(),
{ id: editorId }
);
const waitForCollapsedSelection = useEditorSelector(
(innerEditor4, previous = false) => {
if (!innerEditor4.read.selection.isExpanded()) return false;
if (editorId !== focusedEditorId) return true;
return previous;
},
{ id: editorId }
);
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 &&
(editorId === focusedEditorId || ownedOverlayOpen) &&
!isFloatingLinkOpen &&
!isAIChatOpen &&
!options?.hideToolbar &&
(!readOnly || !!options?.showWhenReadOnly) &&
(!waitForCollapsedSelection || readOnly || ownedOverlayOpen) &&
mouseDownOpen !== false &&
dismissedSelection !== selectionRange;
const floating = useVirtualFloating(
mergeProps<UseVirtualFloatingOptions>(
{
open,
getBoundingClientRect: () => getSelectionBoundingClientRect(editor),
onOpenChange: (nextOpen) => {
setDismissedSelection(nextOpen ? null : selectionRange);
},
},
{
middleware: [
offset(12),
flip({
fallbackPlacements: [
'top-start',
'top-end',
'bottom-start',
'bottom-end',
],
padding: 12,
}),
],
placement: 'top',
...options?.floatingOptions,
}
)
);
const openStateRef = React.useRef(open);
React.useEffect(() => {
openStateRef.current = open;
}, [open]);
React.useEffect(() => {
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);
};
}, []);
const editorVersion = useEditorSelector(
(innerEditor5) => innerEditor5.read.lastCommit()?.version ?? 0,
{ id: editorId }
);
const updateFloating = floating.update;
React.useEffect(() => {
if (open) updateFloating?.();
}, [editorVersion, open, updateFloating]);
const clickOutsideRef = useOnClickOutside(
() => {
setDismissedSelection(selectionRange);
},
{ ignoreClass: 'ignore-click-outside/toolbar' }
);
const ref = useComposedRef<HTMLDivElement>(
props.ref,
floating.refs.setFloating
);
if (!open) return null;
return (
<div ref={clickOutsideRef}>
<Toolbar
{...props}
ref={ref}
onOverlayOpenChange={setOwnedOverlayOpen}
style={floating.style}
className={cn(
'scrollbar-hide absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border bg-popover p-1 opacity-100 shadow-md print:hidden',
'max-w-[80vw]',
className
)}
>
{children}
</Toolbar>
</div>
);
}
export const FloatingToolbarPlugin = definePlatePlugin('floatingToolbar', {
render: {
afterEditable: () => (
<FloatingToolbar>
<FloatingToolbarButtons />
</FloatingToolbar>
),
},
});
export const FloatingToolbarKit = [FloatingToolbarPlugin] as const;'use client';
import { AIChatPlugin } from '@platejs/ai/react';
import {
BoldPlugin,
CodePlugin,
ItalicPlugin,
StrikethroughPlugin,
UnderlinePlugin,
} from '@platejs/basic-nodes/react';
import {
type UseVirtualFloatingOptions,
flip,
getSelectionBoundingClientRect,
offset,
useVirtualFloating,
} from '@platejs/floating';
import { useComposedRef } from '@udecode/cn';
import { useOnClickOutside } from '@udecode/react-utils';
import { mergeProps } from '@udecode/utils';
These helpers normalize editor locations and DOM ranges into rectangles.
Returns a zero-size offscreen rect used as a safe Floating UI fallback.
Creates a Floating UI virtual element with getDefaultBoundingClientRect.
Creates a ref-like object whose current.getBoundingClientRect() reads editor locations. It throws when no rect exists and no fallbackRect is provided.
Reads one or more editor locations, converts them to DOM ranges, and returns the merged bounding rect. If at is omitted, it uses editor.selection.
Returns the DOM rect for a range, or getDefaultBoundingClientRect() when the range or DOM range is missing.
Returns the selection rect only when the editor selection is expanded. Collapsed selections return the default rect.
Returns window.getSelection().getRangeAt(0).getBoundingClientRect(), or the default rect when no DOM selection exists.
Creates a DOMRect-like object and computes width, height, x, and y.
Merges client rects by min left/top and max right/bottom. It throws when the array is empty.
@platejs/floating re-exports the Floating UI middleware and React hooks used by Plate UI, including autoUpdate, flip, hide, inline, offset, shift, size, useFloating, useInteractions, useClick, useDismiss, FloatingPortal, and related types.
Use those exports when a Plate UI component already imports from @platejs/floating; use @floating-ui/react directly only when the component is not coupled to Plate.
useEditorId and useEventEditorValue.