refactor: remove BlockListStatic component and integrate InlineCombobox for slash commands in the editor

This commit is contained in:
Anish Sarkar 2026-02-16 16:05:33 +05:30
parent 93a0487e56
commit e799c3f061
3 changed files with 499 additions and 156 deletions

View file

@ -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>
);
}