"use client"; import { Combobox, ComboboxGroup, ComboboxGroupLabel, ComboboxItem, type ComboboxItemProps, ComboboxPopover, ComboboxProvider, ComboboxRow, Portal, useComboboxContext, useComboboxStore, } from "@ariakit/react"; import { filterWords } from "@platejs/combobox"; import { type UseComboboxInputResult, useComboboxInput, useHTMLInputCursorState, } from "@platejs/combobox/react"; import { cva } from "class-variance-authority"; import type { Point, TElement } from "platejs"; import { useComposedRef, useEditorRef } from "platejs/react"; import * as React from "react"; import { cn } from "@/lib/utils"; function useRequiredComboboxContext() { const context = useComboboxContext(); if (!context) { throw new Error("InlineCombobox compound components must be rendered within InlineCombobox"); } return context; } type FilterFn = ( item: { value: string; group?: string; keywords?: string[]; label?: string }, search: string ) => boolean; type InlineComboboxContextValue = { filter: FilterFn | false; inputProps: UseComboboxInputResult["props"]; inputRef: React.RefObject; removeInput: UseComboboxInputResult["removeInput"]; showTrigger: boolean; trigger: string; setHasEmpty: (hasEmpty: boolean) => void; }; const InlineComboboxContext = React.createContext( null as unknown as InlineComboboxContextValue ); const defaultFilter: FilterFn = ({ group, keywords = [], label, value }, search) => { const uniqueTerms = new Set([value, ...keywords, group, label].filter(Boolean)); return Array.from(uniqueTerms).some((keyword) => filterWords(keyword as string, search)); }; type InlineComboboxProps = { children: React.ReactNode; element: TElement; trigger: string; filter?: FilterFn | false; hideWhenNoValue?: boolean; showTrigger?: boolean; value?: string; setValue?: (value: string) => void; }; const InlineCombobox = ({ children, element, filter = defaultFilter, hideWhenNoValue = false, setValue: setValueProp, showTrigger = true, trigger, value: valueProp, }: InlineComboboxProps) => { const editor = useEditorRef(); const inputRef = React.useRef(null); const cursorState = useHTMLInputCursorState(inputRef); const [valueState, setValueState] = React.useState(""); const hasValueProp = valueProp !== undefined; const value = hasValueProp ? valueProp : valueState; // Check if current user is the creator of this element (for Yjs collaboration) const isCreator = React.useMemo(() => { const elementUserId = (element as Record).userId; const currentUserId = editor.meta.userId; // If no userId (backwards compatibility or non-Yjs), allow if (!elementUserId) return true; return elementUserId === currentUserId; }, [editor.meta.userId, element]); const setValue = React.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 = React.useRef(null); React.useEffect(() => { const path = editor.api.findPath(element); if (!path) return; const point = editor.api.before(path); if (!point) return; const pointRef = editor.api.pointRef(point); insertPoint.current = pointRef.current; return () => { pointRef.unref(); }; }, [editor, element]); const { props: inputProps, removeInput } = useComboboxInput({ cancelInputOnBlur: true, cursorState, autoFocus: isCreator, ref: inputRef, onCancelInput: (cause) => { if (cause !== "backspace") { editor.tf.insertText(trigger + value, { at: insertPoint?.current ?? undefined, }); } if (cause === "arrowLeft" || cause === "arrowRight") { editor.tf.move({ distance: 1, reverse: cause === "arrowLeft", }); } }, }); const [hasEmpty, setHasEmpty] = React.useState(false); const contextValue: InlineComboboxContextValue = React.useMemo( () => ({ filter, inputProps, inputRef, removeInput, setHasEmpty, showTrigger, trigger, }), [trigger, showTrigger, filter, inputProps, removeInput] ); const store = useComboboxStore({ // open: , setValue: (newValue) => React.startTransition(() => setValue(newValue)), }); const items = store.useState("items"); /** * If there is no active ID and the list of items changes, select the first * item. */ React.useEffect(() => { if (items.length === 0) return; if (!store.getState().activeId) { store.setActiveId(store.first()); } }, [items, store]); return ( 0 || hasEmpty) && (!hideWhenNoValue || value.length > 0)} store={store} > {children} ); }; const InlineComboboxInput = ({ className, ref: propRef, ...props }: React.HTMLAttributes & { ref?: React.RefObject; }) => { const { inputProps, inputRef: contextRef, showTrigger, trigger, } = React.useContext(InlineComboboxContext); const store = useRequiredComboboxContext(); 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} ); }; InlineComboboxInput.displayName = "InlineComboboxInput"; const InlineComboboxContent: typeof ComboboxPopover = ({ className, ...props }) => { // Portal prevents CSS from leaking into popover const store = useComboboxContext(); function handleKeyDown(event: React.KeyboardEvent) { if (!store) return; const state = store.getState(); const { items, activeId } = state; if (!items.length) return; const currentIndex = items.findIndex((item) => item.id === activeId); if (event.key === "ArrowUp" && currentIndex <= 0) { event.preventDefault(); store.setActiveId(store.last()); } else if (event.key === "ArrowDown" && currentIndex >= items.length - 1) { event.preventDefault(); store.setActiveId(store.first()); } } return ( ); }; const comboboxItemVariants = cva( "relative mx-1 flex h-[28px] select-none items-center rounded-sm px-2 text-foreground text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { 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 dark:hover:bg-neutral-700 dark:data-[active-item=true]:bg-neutral-700", }, }, } ); const InlineComboboxItem = ({ className, focusEditor = true, group, keywords, label, onClick, ...props }: { focusEditor?: boolean; group?: string; keywords?: string[]; label?: string; } & ComboboxItemProps & Required>) => { const { value } = props; const { filter, removeInput } = React.useContext(InlineComboboxContext); const store = useRequiredComboboxContext(); // Always call hook unconditionally; only use value if filter is active const storeValue = store.useState("value"); const search = filter ? storeValue : ""; const visible = React.useMemo( () => !filter || filter({ group, keywords, label, value }, search), [filter, group, keywords, label, value, search] ); if (!visible) return null; return ( { removeInput(focusEditor); onClick?.(event); }} {...props} /> ); }; const InlineComboboxEmpty = ({ children, className }: React.HTMLAttributes) => { const { setHasEmpty } = React.useContext(InlineComboboxContext); const store = useRequiredComboboxContext(); const items = store.useState("items"); React.useEffect(() => { setHasEmpty(true); return () => { setHasEmpty(false); }; }, [setHasEmpty]); if (items.length > 0) return null; return (
{children}
); }; const InlineComboboxRow = ComboboxRow; function InlineComboboxGroup({ className, ...props }: React.ComponentProps) { return ( ); } function InlineComboboxGroupLabel({ className, ...props }: React.ComponentProps) { return ( ); } export { InlineCombobox, InlineComboboxContent, InlineComboboxEmpty, InlineComboboxGroup, InlineComboboxGroupLabel, InlineComboboxInput, InlineComboboxItem, InlineComboboxRow, };