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

Slash Menu

The slash menu provides quick access to various formatting options and content types.
How to use the slash menu:
  • Type '/' anywhere in your document to open the slash menu.
  • Start typing to filter options or use arrow keys to navigate.
  • Press Enter or click to select an option.
  • Press Escape to close the menu without selecting.
Available options include:
  • Headings: Heading 1, Heading 2, Heading 3
  • Lists: Bulleted list, Numbered list
  • Inline Elements: Date
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!