diff --git a/surfsense_web/components/ui/block-list-static.tsx b/surfsense_web/components/ui/block-list-static.tsx deleted file mode 100644 index 4efd7c12b..000000000 --- a/surfsense_web/components/ui/block-list-static.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import * as React from 'react'; - -import type { RenderStaticNodeWrapper, TListElement } from 'platejs'; -import type { SlateRenderElementProps } from 'platejs/static'; - -import { isOrderedList } from '@platejs/list'; -import { CheckIcon } from 'lucide-react'; - -import { cn } from '@/lib/utils'; - -const config: Record< - string, - { - Li: React.FC; - Marker: React.FC; - } -> = { - todo: { - Li: TodoLiStatic, - Marker: TodoMarkerStatic, - }, -}; - -export const BlockListStatic: RenderStaticNodeWrapper = (props) => { - if (!props.element.listStyleType) return; - - return (props) => ; -}; - -function List(props: SlateRenderElementProps) { - const { indent, listStart, listStyleType } = props.element as TListElement & { - indent?: number; - }; - const { Li, Marker } = config[listStyleType] ?? {}; - const List = isOrderedList(props.element) ? 'ol' : 'ul'; - - // Apply margin-left for indent (24px per level) for DOCX export compatibility - const marginLeft = indent ? `${indent * 24}px` : undefined; - - return ( - - {Marker && } - {Li ?
  • :
  • {props.children}
  • } -
    - ); -} - -function TodoMarkerStatic(props: SlateRenderElementProps) { - const checked = props.element.checked as boolean; - - return ( -
    - -
    - ); -} - -function TodoLiStatic(props: SlateRenderElementProps) { - return ( -
  • - {props.children} -
  • - ); -} diff --git a/surfsense_web/components/ui/inline-combobox.tsx b/surfsense_web/components/ui/inline-combobox.tsx new file mode 100644 index 000000000..cf056d3d8 --- /dev/null +++ b/surfsense_web/components/ui/inline-combobox.tsx @@ -0,0 +1,437 @@ +'use client'; + +import * as React from 'react'; + +import type { Point, TElement } from 'platejs'; + +import { + type ComboboxItemProps, + Combobox, + ComboboxGroup, + ComboboxGroupLabel, + ComboboxItem, + 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 { useComposedRef, useEditorRef } from 'platejs/react'; + +import { cn } from '@/lib/utils'; + +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!, 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 any).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, + inputRef, + inputProps, + removeInput, + setHasEmpty, + ] + ); + + 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 (!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 = 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} + + + + + + + + ); +}; + +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', + }, + }, + } +); + +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 = useComboboxContext()!; + + // Optimization: Do not subscribe to value if filter is false + const search = filter && store.useState('value'); + + const visible = React.useMemo( + () => + !filter || filter({ group, keywords, label, value }, search as string), + [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 = useComboboxContext()!; + 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, +}; diff --git a/surfsense_web/components/ui/slash-node.tsx b/surfsense_web/components/ui/slash-node.tsx index 16c371f42..7bd3c0969 100644 --- a/surfsense_web/components/ui/slash-node.tsx +++ b/surfsense_web/components/ui/slash-node.tsx @@ -1,7 +1,6 @@ 'use client'; import * as React from 'react'; -import { createPortal } from 'react-dom'; import type { PlateElementProps } from 'platejs/react'; @@ -24,21 +23,24 @@ import { TableIcon, } from 'lucide-react'; import { KEYS } from 'platejs'; -import { PlateElement, useEditorPlugin, useEditorRef } from 'platejs/react'; +import { PlateElement, useEditorRef } from 'platejs/react'; import { - Command, - CommandEmpty, - CommandGroup, - CommandItem, - CommandList, -} from '@/components/ui/command'; + InlineCombobox, + InlineComboboxContent, + InlineComboboxEmpty, + InlineComboboxGroup, + InlineComboboxGroupLabel, + InlineComboboxInput, + InlineComboboxItem, +} from '@/components/ui/inline-combobox'; import { insertBlock, insertInlineElement } from '@/components/editor/transforms'; interface SlashCommandItem { icon: React.ReactNode; keywords: string[]; label: string; + value: string; onSelect: (editor: any) => void; } @@ -50,36 +52,42 @@ const slashCommandGroups: { heading: string; items: SlashCommandItem[] }[] = [ icon: , keywords: ['paragraph', 'text', 'plain'], label: 'Text', + value: 'text', onSelect: (editor) => insertBlock(editor, KEYS.p), }, { icon: , keywords: ['title', 'h1', 'heading'], label: 'Heading 1', + value: 'heading1', onSelect: (editor) => insertBlock(editor, 'h1'), }, { icon: , keywords: ['subtitle', 'h2', 'heading'], label: 'Heading 2', + value: 'heading2', onSelect: (editor) => insertBlock(editor, 'h2'), }, { icon: , keywords: ['subtitle', 'h3', 'heading'], label: 'Heading 3', + value: 'heading3', onSelect: (editor) => insertBlock(editor, 'h3'), }, { icon: , keywords: ['citation', 'blockquote'], label: 'Quote', + value: 'quote', onSelect: (editor) => insertBlock(editor, KEYS.blockquote), }, { icon: , keywords: ['divider', 'separator', 'line'], label: 'Divider', + value: 'divider', onSelect: (editor) => insertBlock(editor, KEYS.hr), }, ], @@ -91,18 +99,21 @@ const slashCommandGroups: { heading: string; items: SlashCommandItem[] }[] = [ icon: , keywords: ['unordered', 'ul', 'bullet'], label: 'Bulleted list', + value: 'bulleted-list', onSelect: (editor) => insertBlock(editor, KEYS.ul), }, { icon: , keywords: ['ordered', 'ol', 'numbered'], label: 'Numbered list', + value: 'numbered-list', onSelect: (editor) => insertBlock(editor, KEYS.ol), }, { icon: , keywords: ['checklist', 'task', 'checkbox', 'todo'], label: 'To-do list', + value: 'todo-list', onSelect: (editor) => insertBlock(editor, KEYS.listTodo), }, ], @@ -114,30 +125,35 @@ const slashCommandGroups: { heading: string; items: SlashCommandItem[] }[] = [ icon: , keywords: ['table', 'grid'], label: 'Table', + value: 'table', onSelect: (editor) => insertBlock(editor, KEYS.table), }, { icon: , keywords: ['code', 'codeblock', 'snippet'], label: 'Code block', + value: 'code-block', onSelect: (editor) => insertBlock(editor, KEYS.codeBlock), }, { icon: , keywords: ['callout', 'note', 'info', 'warning', 'tip'], label: 'Callout', + value: 'callout', onSelect: (editor) => insertBlock(editor, KEYS.callout), }, { icon: , keywords: ['toggle', 'collapsible', 'expand'], label: 'Toggle', + value: 'toggle', onSelect: (editor) => insertBlock(editor, KEYS.toggle), }, { icon: , keywords: ['equation', 'math', 'formula', 'latex'], label: 'Equation', + value: 'equation', onSelect: (editor) => insertInlineElement(editor, KEYS.equation), }, ], @@ -149,6 +165,7 @@ const slashCommandGroups: { heading: string; items: SlashCommandItem[] }[] = [ icon: , keywords: ['link', 'url', 'href'], label: 'Link', + value: 'link', onSelect: (editor) => insertInlineElement(editor, KEYS.link), }, ], @@ -159,74 +176,48 @@ export function SlashInputElement({ children, ...props }: PlateElementProps) { - const { editor, setOption } = useEditorPlugin(SlashInputPlugin); - const anchorRef = React.useRef(null); - const [menuPosition, setMenuPosition] = React.useState<{ top: number; left: number } | null>(null); - - React.useEffect(() => { - const updatePosition = () => { - if (anchorRef.current) { - const rect = anchorRef.current.getBoundingClientRect(); - setMenuPosition({ - top: rect.bottom + window.scrollY, - left: rect.left + window.scrollX, - }); - } - }; - - updatePosition(); - - // Re-position on scroll/resize since the editor may scroll - window.addEventListener('scroll', updatePosition, true); - window.addEventListener('resize', updatePosition); - return () => { - window.removeEventListener('scroll', updatePosition, true); - window.removeEventListener('resize', updatePosition); - }; - }, []); + const editor = useEditorRef(); return ( - - {menuPosition && - createPortal( - - - No results found. - {slashCommandGroups.map(({ heading, items }) => ( - - {items.map(({ icon, keywords, label, onSelect }) => ( - { - editor.tf.removeNodes({ - match: (n) => (n as any).type === SlashInputPlugin.key, - }); - onSelect(editor); - editor.tf.focus(); - }} - > - - {icon} - - {label} - - ))} - + + + + + No results found. + + {slashCommandGroups.map(({ heading, items }) => ( + + {heading} + + {items.map(({ icon, keywords, label, value, onSelect }) => ( + { + onSelect(editor); + editor.tf.focus(); + }} + > + + {icon} + + {label} + ))} - - , - document.body - )} + + ))} + + + {children} ); } -