Components
Slash Input Element
Slash Input Element
Allows you to insert various elements into your editor using a slash command.
Installation
npx @udecode/plate-ui@latest add slash-input-element -r plate-ui
Examples
import React from 'react';
import { withRef } from '@udecode/cn';
import { AIChatPlugin } from '@udecode/plate-ai/react';
import { DatePlugin } from '@udecode/plate-date/react';
import { HEADING_KEYS } from '@udecode/plate-heading';
import { ListStyleType, toggleIndentList } from '@udecode/plate-indent-list';
import { Icons } from '@/components/icons';
import {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxInput,
InlineComboboxItem,
} from './inline-combobox';
import { PlateElement } from './plate-element';
import type { ComponentType, SVGProps } from 'react';
import type { PlateEditor } from '@udecode/plate-common/react';
interface SlashCommandRule {
icon: ComponentType<SVGProps<SVGSVGElement>>;
onSelect: (editor: PlateEditor) => void;
value: string;
className?: string;
focusEditor?: boolean;
keywords?: string[];
}
const rules: SlashCommandRule[] = [
{
focusEditor: false,
icon: Icons.ai,
value: 'AI',
onSelect: (editor) => {
editor.getApi(AIChatPlugin).aiChat.show();
},
},
{
icon: Icons.h1,
value: 'Heading 1',
onSelect: (editor) => {
editor.tf.toggle.block({ type: HEADING_KEYS.h1 });
},
},
{
icon: Icons.h2,
value: 'Heading 2',
onSelect: (editor) => {
editor.tf.toggle.block({ type: HEADING_KEYS.h2 });
},
},
{
icon: Icons.h3,
value: 'Heading 3',
onSelect: (editor) => {
editor.tf.toggle.block({ type: HEADING_KEYS.h3 });
},
},
{
icon: Icons.ul,
keywords: ['ul', 'unordered list'],
value: 'Bulleted list',
onSelect: (editor) => {
toggleIndentList(editor, {
listStyleType: ListStyleType.Disc,
});
},
},
{
icon: Icons.ol,
keywords: ['ol', 'ordered list'],
value: 'Numbered list',
onSelect: (editor) => {
toggleIndentList(editor, {
listStyleType: ListStyleType.Decimal,
});
},
},
{
icon: Icons.add,
keywords: ['inline', 'date'],
value: 'Date',
onSelect: (editor) => {
editor.getTransforms(DatePlugin).insert.date();
},
},
];
export const SlashInputElement = withRef<typeof PlateElement>(
({ className, ...props }, ref) => {
const { children, editor, element } = props;
return (
<PlateElement
ref={ref}
as="span"
data-slate-value={element.value}
{...props}
>
<InlineCombobox element={element} trigger="/">
<InlineComboboxInput />
<InlineComboboxContent>
<InlineComboboxEmpty>
No matching commands found
</InlineComboboxEmpty>
{rules.map(
({ focusEditor, icon: Icon, keywords, value, onSelect }) => (
<InlineComboboxItem
key={value}
value={value}
onClick={() => onSelect(editor)}
focusEditor={focusEditor}
keywords={keywords}
>
<Icon className="mr-2 size-4" aria-hidden />
{value}
</InlineComboboxItem>
)
)}
</InlineComboboxContent>
</InlineCombobox>
{children}
</PlateElement>
);
}
);
import React, {
createContext,
forwardRef,
startTransition,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
Combobox,
ComboboxItem,
ComboboxPopover,
ComboboxProvider,
Portal,
useComboboxContext,
useComboboxStore,
} from '@ariakit/react';
import { cn } from '@udecode/cn';
import { filterWords } from '@udecode/plate-combobox';
import {
useComboboxInput,
useHTMLInputCursorState,
} from '@udecode/plate-combobox/react';
import {
createPointRef,
getPointBefore,
insertText,
moveSelection,
} from '@udecode/plate-common';
import {
findNodePath,
useComposedRef,
useEditorRef,
} from '@udecode/plate-common/react';
import { cva } from 'class-variance-authority';
import type { HTMLAttributes, ReactNode, RefObject } from 'react';
import type { ComboboxItemProps } from '@ariakit/react';
import type { UseComboboxInputResult } from '@udecode/plate-combobox/react';
import type { TElement } from '@udecode/plate-common';
import type { PointRef } from 'slate';
type FilterFn = (
item: { value: string; keywords?: string[] },
search: string
) => boolean;
interface InlineComboboxContextValue {
filter: FilterFn | false;
inputProps: UseComboboxInputResult['props'];
inputRef: RefObject<HTMLInputElement | null>;
removeInput: UseComboboxInputResult['removeInput'];
setHasEmpty: (hasEmpty: boolean) => void;
showTrigger: boolean;
trigger: string;
}
const InlineComboboxContext = createContext<InlineComboboxContextValue>(
null as any
);
export const defaultFilter: FilterFn = ({ keywords = [], value }, search) =>
[value, ...keywords].some((keyword) => filterWords(keyword, search));
interface InlineComboboxProps {
children: ReactNode;
element: TElement;
trigger: string;
filter?: FilterFn | false;
hideWhenNoValue?: boolean;
setValue?: (value: string) => void;
showTrigger?: boolean;
value?: string;
}
const InlineCombobox = ({
children,
element,
filter = defaultFilter,
hideWhenNoValue = false,
setValue: setValueProp,
showTrigger = true,
trigger,
value: valueProp,
}: InlineComboboxProps) => {
const editor = useEditorRef();
const inputRef = React.useRef<HTMLInputElement>(null);
const cursorState = useHTMLInputCursorState(inputRef);
const [valueState, setValueState] = useState('');
const hasValueProp = valueProp !== undefined;
const value = hasValueProp ? valueProp : valueState;
const setValue = useCallback(
(newValue: string) => {
setValueProp?.(newValue);
if (!hasValueProp) {
setValueState(newValue);
}
},
[setValueProp, hasValueProp]
);
/**
* Track the point just before the input element so we know where to
* insertText if the combobox closes due to a selection change.
*/
const [insertPoint, setInsertPoint] = useState<PointRef | null>(null);
useEffect(() => {
const path = findNodePath(editor, element);
if (!path) return;
const point = getPointBefore(editor, path);
if (!point) return;
const pointRef = createPointRef(editor, point);
setInsertPoint(pointRef);
return () => {
pointRef.unref();
};
}, [editor, element]);
const { props: inputProps, removeInput } = useComboboxInput({
cancelInputOnBlur: false,
cursorState,
ref: inputRef,
onCancelInput: (cause) => {
if (cause !== 'backspace') {
insertText(editor, trigger + value, {
at: insertPoint?.current ?? undefined,
});
}
if (cause === 'arrowLeft' || cause === 'arrowRight') {
moveSelection(editor, {
distance: 1,
reverse: cause === 'arrowLeft',
});
}
},
});
const [hasEmpty, setHasEmpty] = useState(false);
const contextValue: InlineComboboxContextValue = useMemo(
() => ({
filter,
inputProps,
inputRef,
removeInput,
setHasEmpty,
showTrigger,
trigger,
}),
[
trigger,
showTrigger,
filter,
inputRef,
inputProps,
removeInput,
setHasEmpty,
]
);
const store = useComboboxStore({
// open: ,
setValue: (newValue) => startTransition(() => setValue(newValue)),
});
const items = store.useState('items');
/**
* If there is no active ID and the list of items changes, select the first
* item.
*/
useEffect(() => {
if (!store.getState().activeId) {
store.setActiveId(store.first());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, store]);
return (
<span contentEditable={false}>
<ComboboxProvider
open={
(items.length > 0 || hasEmpty) &&
(!hideWhenNoValue || value.length > 0)
}
store={store}
>
<InlineComboboxContext.Provider value={contextValue}>
{children}
</InlineComboboxContext.Provider>
</ComboboxProvider>
</span>
);
};
const InlineComboboxInput = forwardRef<
HTMLInputElement,
HTMLAttributes<HTMLInputElement>
>(({ className, ...props }, propRef) => {
const {
inputProps,
inputRef: contextRef,
showTrigger,
trigger,
} = useContext(InlineComboboxContext);
const store = useComboboxContext()!;
const value = store.useState('value');
const ref = useComposedRef(propRef, contextRef);
/**
* To create an auto-resizing input, we render a visually hidden span
* containing the input value and position the input element on top of it.
* This works well for all cases except when input exceeds the width of the
* container.
*/
return (
<>
{showTrigger && trigger}
<span className="relative min-h-[1lh]">
<span
className="invisible overflow-hidden text-nowrap"
aria-hidden="true"
>
{value || '\u200B'}
</span>
<Combobox
ref={ref}
className={cn(
'absolute left-0 top-0 size-full bg-transparent outline-none',
className
)}
value={value}
autoSelect
{...inputProps}
{...props}
/>
</span>
</>
);
});
InlineComboboxInput.displayName = 'InlineComboboxInput';
const InlineComboboxContent: typeof ComboboxPopover = ({
className,
...props
}) => {
// Portal prevents CSS from leaking into popover
return (
<Portal>
<ComboboxPopover
className={cn(
'z-[500] max-h-[288px] w-[300px] overflow-y-auto rounded-md bg-popover shadow-md',
className
)}
{...props}
/>
</Portal>
);
};
const comboboxItemVariants = cva(
'relative flex h-9 select-none items-center rounded-sm px-2 py-1.5 text-sm text-foreground outline-none',
{
defaultVariants: {
interactive: true,
},
variants: {
interactive: {
false: '',
true: 'cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground',
},
},
}
);
export type InlineComboboxItemProps = {
focusEditor?: boolean;
keywords?: string[];
} & ComboboxItemProps &
Required<Pick<ComboboxItemProps, 'value'>>;
const InlineComboboxItem = ({
className,
focusEditor = true,
keywords,
onClick,
...props
}: InlineComboboxItemProps) => {
const { value } = props;
const { filter, removeInput } = useContext(InlineComboboxContext);
const store = useComboboxContext()!;
// Optimization: Do not subscribe to value if filter is false
const search = filter && store.useState('value');
const visible = useMemo(
() => !filter || filter({ keywords, value }, search as string),
[filter, value, keywords, search]
);
if (!visible) return null;
return (
<ComboboxItem
className={cn(comboboxItemVariants(), className)}
onClick={(event) => {
removeInput(focusEditor);
onClick?.(event);
}}
{...props}
/>
);
};
const InlineComboboxEmpty = ({
children,
className,
}: HTMLAttributes<HTMLDivElement>) => {
const { setHasEmpty } = useContext(InlineComboboxContext);
const store = useComboboxContext()!;
const items = store.useState('items');
useEffect(() => {
setHasEmpty(true);
return () => {
setHasEmpty(false);
};
}, [setHasEmpty]);
if (items.length > 0) return null;
return (
<div
className={cn(comboboxItemVariants({ interactive: false }), className)}
>
{children}
</div>
);
};
export {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxInput,
InlineComboboxItem,
};
Plate Plus
We offer an enhanced user interface design and a more comprehensive set of options, including premium plugins such as Math Callout and Media Upload. This provides a more robust and feature-rich editing experience for users who require advanced functionality.
Some key improvements in Plate Plus include:
- Refined UI design for better usability and aesthetics
- Extended set of slash menu options
- Integration of premium plugins like Math Upload for specialized editing needs
- No need to worry about the focus issue mentioned above.
- Support grouping and Carefully selected keyword.
- Trigger slash menu by click the puls button on the left of the paragraph.
You can try typing /``` to quickly insert a code block.
Feel free to experiment with different commands to see how the slash menu enhances your editing experience!
Build your editor even faster
Complete, deployable AI-powered template with backend.
All components included.
Customizable and extensible.
Get all-accessCustomizable and extensible.