mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
refactor: remove BlockListStatic component and integrate InlineCombobox for slash commands in the editor
This commit is contained in:
parent
93a0487e56
commit
e799c3f061
3 changed files with 499 additions and 156 deletions
|
|
@ -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<SlateRenderElementProps>;
|
||||
Marker: React.FC<SlateRenderElementProps>;
|
||||
}
|
||||
> = {
|
||||
todo: {
|
||||
Li: TodoLiStatic,
|
||||
Marker: TodoMarkerStatic,
|
||||
},
|
||||
};
|
||||
|
||||
export const BlockListStatic: RenderStaticNodeWrapper = (props) => {
|
||||
if (!props.element.listStyleType) return;
|
||||
|
||||
return (props) => <List {...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 (
|
||||
<List
|
||||
className="relative m-0 p-0"
|
||||
style={{ listStyleType, marginLeft }}
|
||||
start={listStart}
|
||||
>
|
||||
{Marker && <Marker {...props} />}
|
||||
{Li ? <Li {...props} /> : <li>{props.children}</li>}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoMarkerStatic(props: SlateRenderElementProps) {
|
||||
const checked = props.element.checked as boolean;
|
||||
|
||||
return (
|
||||
<div contentEditable={false}>
|
||||
<button
|
||||
className={cn(
|
||||
'peer -left-6 pointer-events-none absolute top-1 size-4 shrink-0 rounded-sm border border-primary bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
props.className
|
||||
)}
|
||||
data-state={checked ? 'checked' : 'unchecked'}
|
||||
type="button"
|
||||
>
|
||||
<div className={cn('flex items-center justify-center text-current')}>
|
||||
{checked && <CheckIcon className="size-4" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoLiStatic(props: SlateRenderElementProps) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'list-none',
|
||||
(props.element.checked as boolean) &&
|
||||
'text-muted-foreground line-through'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
437
surfsense_web/components/ui/inline-combobox.tsx
Normal file
437
surfsense_web/components/ui/inline-combobox.tsx
Normal file
|
|
@ -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<HTMLInputElement | null>;
|
||||
removeInput: UseComboboxInputResult['removeInput'];
|
||||
showTrigger: boolean;
|
||||
trigger: string;
|
||||
setHasEmpty: (hasEmpty: boolean) => void;
|
||||
};
|
||||
|
||||
const InlineComboboxContext = React.createContext<InlineComboboxContextValue>(
|
||||
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<HTMLInputElement>(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<Point | null>(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 (
|
||||
<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 = ({
|
||||
className,
|
||||
ref: propRef,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLInputElement> & {
|
||||
ref?: React.RefObject<HTMLInputElement | null>;
|
||||
}) => {
|
||||
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}
|
||||
|
||||
<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 top-0 left-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
|
||||
const store = useComboboxContext();
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
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 (
|
||||
<Portal>
|
||||
<ComboboxPopover
|
||||
className={cn(
|
||||
'z-500 max-h-[288px] w-[300px] overflow-y-auto rounded-md bg-popover shadow-md',
|
||||
className
|
||||
)}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
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<Pick<ComboboxItemProps, 'value'>>) => {
|
||||
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 (
|
||||
<ComboboxItem
|
||||
className={cn(comboboxItemVariants(), className)}
|
||||
onClick={(event) => {
|
||||
removeInput(focusEditor);
|
||||
onClick?.(event);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineComboboxEmpty = ({
|
||||
children,
|
||||
className,
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
className={cn(comboboxItemVariants({ interactive: false }), className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineComboboxRow = ComboboxRow;
|
||||
|
||||
function InlineComboboxGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ComboboxGroup>) {
|
||||
return (
|
||||
<ComboboxGroup
|
||||
{...props}
|
||||
className={cn(
|
||||
'hidden not-last:border-b py-1.5 [&:has([role=option])]:block',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineComboboxGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ComboboxGroupLabel>) {
|
||||
return (
|
||||
<ComboboxGroupLabel
|
||||
{...props}
|
||||
className={cn(
|
||||
'mt-1.5 mb-2 px-3 font-medium text-muted-foreground text-xs',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
InlineCombobox,
|
||||
InlineComboboxContent,
|
||||
InlineComboboxEmpty,
|
||||
InlineComboboxGroup,
|
||||
InlineComboboxGroupLabel,
|
||||
InlineComboboxInput,
|
||||
InlineComboboxItem,
|
||||
InlineComboboxRow,
|
||||
};
|
||||
|
|
@ -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: <PilcrowIcon />,
|
||||
keywords: ['paragraph', 'text', 'plain'],
|
||||
label: 'Text',
|
||||
value: 'text',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.p),
|
||||
},
|
||||
{
|
||||
icon: <Heading1Icon />,
|
||||
keywords: ['title', 'h1', 'heading'],
|
||||
label: 'Heading 1',
|
||||
value: 'heading1',
|
||||
onSelect: (editor) => insertBlock(editor, 'h1'),
|
||||
},
|
||||
{
|
||||
icon: <Heading2Icon />,
|
||||
keywords: ['subtitle', 'h2', 'heading'],
|
||||
label: 'Heading 2',
|
||||
value: 'heading2',
|
||||
onSelect: (editor) => insertBlock(editor, 'h2'),
|
||||
},
|
||||
{
|
||||
icon: <Heading3Icon />,
|
||||
keywords: ['subtitle', 'h3', 'heading'],
|
||||
label: 'Heading 3',
|
||||
value: 'heading3',
|
||||
onSelect: (editor) => insertBlock(editor, 'h3'),
|
||||
},
|
||||
{
|
||||
icon: <QuoteIcon />,
|
||||
keywords: ['citation', 'blockquote'],
|
||||
label: 'Quote',
|
||||
value: 'quote',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.blockquote),
|
||||
},
|
||||
{
|
||||
icon: <MinusIcon />,
|
||||
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: <ListIcon />,
|
||||
keywords: ['unordered', 'ul', 'bullet'],
|
||||
label: 'Bulleted list',
|
||||
value: 'bulleted-list',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.ul),
|
||||
},
|
||||
{
|
||||
icon: <ListOrderedIcon />,
|
||||
keywords: ['ordered', 'ol', 'numbered'],
|
||||
label: 'Numbered list',
|
||||
value: 'numbered-list',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.ol),
|
||||
},
|
||||
{
|
||||
icon: <SquareIcon />,
|
||||
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: <TableIcon />,
|
||||
keywords: ['table', 'grid'],
|
||||
label: 'Table',
|
||||
value: 'table',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.table),
|
||||
},
|
||||
{
|
||||
icon: <FileCodeIcon />,
|
||||
keywords: ['code', 'codeblock', 'snippet'],
|
||||
label: 'Code block',
|
||||
value: 'code-block',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.codeBlock),
|
||||
},
|
||||
{
|
||||
icon: <InfoIcon />,
|
||||
keywords: ['callout', 'note', 'info', 'warning', 'tip'],
|
||||
label: 'Callout',
|
||||
value: 'callout',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.callout),
|
||||
},
|
||||
{
|
||||
icon: <ChevronRightIcon />,
|
||||
keywords: ['toggle', 'collapsible', 'expand'],
|
||||
label: 'Toggle',
|
||||
value: 'toggle',
|
||||
onSelect: (editor) => insertBlock(editor, KEYS.toggle),
|
||||
},
|
||||
{
|
||||
icon: <RadicalIcon />,
|
||||
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: <Code2Icon />,
|
||||
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<HTMLSpanElement>(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 (
|
||||
<PlateElement {...props} as="span">
|
||||
<span ref={anchorRef} />
|
||||
{menuPosition &&
|
||||
createPortal(
|
||||
<Command
|
||||
className="fixed z-50 w-[330px] overflow-hidden rounded-lg border bg-popover shadow-lg"
|
||||
style={{ top: menuPosition.top, left: menuPosition.left }}
|
||||
shouldFilter={false}
|
||||
>
|
||||
<CommandList className="max-h-[min(400px,40vh)] overflow-y-auto p-1">
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{slashCommandGroups.map(({ heading, items }) => (
|
||||
<CommandGroup key={heading} heading={heading}>
|
||||
{items.map(({ icon, keywords, label, onSelect }) => (
|
||||
<CommandItem
|
||||
key={label}
|
||||
className="flex items-center gap-3 px-2 py-2"
|
||||
keywords={keywords}
|
||||
value={label}
|
||||
onSelect={() => {
|
||||
editor.tf.removeNodes({
|
||||
match: (n) => (n as any).type === SlashInputPlugin.key,
|
||||
});
|
||||
onSelect(editor);
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<InlineCombobox
|
||||
element={props.element}
|
||||
trigger="/"
|
||||
>
|
||||
<InlineComboboxInput />
|
||||
|
||||
<InlineComboboxContent>
|
||||
<InlineComboboxEmpty>No results found.</InlineComboboxEmpty>
|
||||
|
||||
{slashCommandGroups.map(({ heading, items }) => (
|
||||
<InlineComboboxGroup key={heading}>
|
||||
<InlineComboboxGroupLabel>{heading}</InlineComboboxGroupLabel>
|
||||
|
||||
{items.map(({ icon, keywords, label, value, onSelect }) => (
|
||||
<InlineComboboxItem
|
||||
key={value}
|
||||
className="flex items-center gap-3 px-2 py-1.5"
|
||||
keywords={keywords}
|
||||
label={label}
|
||||
value={value}
|
||||
group={heading}
|
||||
onClick={() => {
|
||||
onSelect(editor);
|
||||
editor.tf.focus();
|
||||
}}
|
||||
>
|
||||
<span className="flex size-5 items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</InlineComboboxItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>,
|
||||
document.body
|
||||
)}
|
||||
</InlineComboboxGroup>
|
||||
))}
|
||||
</InlineComboboxContent>
|
||||
</InlineCombobox>
|
||||
|
||||
{children}
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue