ubiquitous-invention/apps/web/components/editor/slash-menu.tsx

414 lines
12 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import {
Extension,
type Editor,
type Range,
ReactRenderer,
} from "@tiptap/react";
import Suggestion, { type SuggestionKeyDownProps } from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
import * as React from "react";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListTodo,
ImageIcon,
Code2,
Table2,
Minus,
MessageSquareQuote,
ChevronRight,
Sparkles,
Type,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
export const slashCommandPluginKey = new PluginKey("slashCommand");
export type SlashItem = {
title: string;
description: string;
section: string;
icon: React.ComponentType<{ className?: string }>;
command: (opts: { editor: Editor; range: Range }) => void;
};
/** TipTap chain typing does not merge all extension commands on the base Editor type. */
function afterSlashDelete(editor: Editor, range: Range) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return editor.chain().focus().deleteRange(range) as any;
}
function getSlashItems(): SlashItem[] {
return [
{
title: "Paragraph",
description: "Plain text block",
section: "Text",
icon: Type,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setParagraph().run();
},
},
{
title: "Heading 1",
description: "Large section title",
section: "Text",
icon: Heading1,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setHeading({ level: 1 }).run();
},
},
{
title: "Heading 2",
description: "Medium section title",
section: "Text",
icon: Heading2,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setHeading({ level: 2 }).run();
},
},
{
title: "Heading 3",
description: "Small section title",
section: "Text",
icon: Heading3,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setHeading({ level: 3 }).run();
},
},
{
title: "Bullet List",
description: "Unordered list",
section: "Lists",
icon: List,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Ordered list",
section: "Lists",
icon: ListOrdered,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleOrderedList().run();
},
},
{
title: "Task List",
description: "Checklist with tasks",
section: "Lists",
icon: ListTodo,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleTaskList().run();
},
},
{
title: "Image",
description: "Embed an image by URL",
section: "Media",
icon: ImageIcon,
command: ({ editor, range }) => {
const url = window.prompt("Image URL");
if (!url) return;
afterSlashDelete(editor, range).setImage({ src: url }).run();
},
},
{
title: "Code Block",
description: "Syntax-highlighted code",
section: "Media",
icon: Code2,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleCodeBlock().run();
},
},
{
title: "Table",
description: "3×3 table with header",
section: "Media",
icon: Table2,
command: ({ editor, range }) => {
afterSlashDelete(editor, range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
},
{
title: "Horizontal Rule",
description: "Divider line",
section: "Media",
icon: Minus,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).setHorizontalRule().run();
},
},
{
title: "Callout",
description: "Highlighted callout block",
section: "Advanced",
icon: Sparkles,
command: ({ editor, range }) => {
afterSlashDelete(editor, range)
.insertContent(
'<blockquote class="notion-callout border-l-4 border-teal pl-4 py-2 my-2 rounded-r-md bg-muted/50"><p>Callout</p></blockquote>',
)
.run();
},
},
{
title: "Toggle",
description: "Collapsible section (placeholder)",
section: "Advanced",
icon: ChevronRight,
command: ({ editor, range }) => {
afterSlashDelete(editor, range)
.insertContent({
type: "heading",
attrs: { level: 3 },
content: [{ type: "text", text: "Toggle heading" }],
})
.run();
},
},
{
title: "Quote",
description: "Blockquote citation",
section: "Advanced",
icon: MessageSquareQuote,
command: ({ editor, range }) => {
afterSlashDelete(editor, range).toggleBlockquote().run();
},
},
];
}
function filterItems(query: string): SlashItem[] {
const q = query.trim().toLowerCase();
const all = getSlashItems();
if (!q) return all;
return all.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
item.section.toLowerCase().includes(q),
);
}
export type SlashMenuProps = {
items: SlashItem[];
command: (item: SlashItem) => void;
editor: Editor;
};
export type SlashMenuHandle = {
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
};
export const SlashMenu = React.forwardRef<SlashMenuHandle, SlashMenuProps>(
function SlashMenu({ items, command }, ref) {
const [selected, setSelected] = React.useState(0);
React.useEffect(() => {
setSelected(0);
}, [items]);
const grouped = React.useMemo(() => {
const map = new Map<string, SlashItem[]>();
for (const item of items) {
const list = map.get(item.section) ?? [];
list.push(item);
map.set(item.section, list);
}
return map;
}, [items]);
React.useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (items.length === 0) return false;
if (event.key === "ArrowDown") {
event.preventDefault();
setSelected((i) => (i + 1) % Math.max(items.length, 1));
return true;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSelected((i) =>
i === 0 ? Math.max(items.length - 1, 0) : i - 1,
);
return true;
}
if (event.key === "Enter") {
event.preventDefault();
const item = items[selected];
if (item) command(item);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="animate-in fade-in-0 zoom-in-95 duration-150 z-[100] w-72 rounded-lg border border-border bg-popover p-3 text-sm text-muted-foreground shadow-lg">
No matching commands
</div>
);
}
let flatIndex = 0;
return (
<div
className={cn(
"animate-in fade-in-0 zoom-in-95 duration-150 z-[100] w-80 overflow-hidden rounded-lg border border-border bg-popover shadow-lg",
)}
>
<ScrollArea className="max-h-[min(320px,50vh)]">
<div className="p-1.5">
{Array.from(grouped.entries()).map(
([section, sectionItems], sectionIndex, sections) => (
<div key={section}>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{section}
</div>
<div className="space-y-0.5">
{sectionItems.map((item) => {
const idx = flatIndex++;
const Icon = item.icon;
const isActive = idx === selected;
return (
<Button
key={`${section}-${item.title}`}
type="button"
variant="ghost"
className={cn(
"h-auto w-full justify-start gap-3 px-2 py-2 text-left font-normal",
isActive &&
"bg-primary/10 text-primary dark:bg-primary/20",
)}
onClick={() => command(item)}
onMouseEnter={() => setSelected(idx)}
>
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60",
isActive && "border-primary/40 bg-primary/5",
)}
>
<Icon className="size-4 text-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-tight">
{item.title}
</span>
<span className="text-xs text-muted-foreground">
{item.description}
</span>
</span>
</Button>
);
})}
</div>
{sectionIndex < sections.length - 1 ? (
<Separator className="my-2 opacity-50" />
) : null}
</div>
),
)}
</div>
</ScrollArea>
</div>
);
},
);
SlashMenu.displayName = "SlashMenu";
export const SlashCommand = Extension.create({
name: "slashCommand",
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
pluginKey: slashCommandPluginKey,
char: "/",
allowSpaces: true,
startOfLine: false,
command: ({ editor: ed, range, props }) => {
props.command({ editor: ed, range });
},
items: ({ query }) => filterItems(query),
render: () => {
let component: ReactRenderer | null = null;
return {
onStart: (props) => {
component = new ReactRenderer(SlashMenu, {
props: {
...props,
command: (item: SlashItem) => {
item.command({
editor: props.editor,
range: props.range,
});
},
},
editor: props.editor,
});
component.element.style.position = "absolute";
component.element.style.zIndex = "100";
document.body.appendChild(component.element);
updatePosition(props);
},
onUpdate: (props) => {
component?.updateProps({
...props,
command: (item: SlashItem) => {
item.command({
editor: props.editor,
range: props.range,
});
},
});
updatePosition(props);
},
onExit: () => {
component?.destroy();
component = null;
},
onKeyDown: (keyProps) => {
const ref = component?.ref as SlashMenuHandle | null;
if (ref?.onKeyDown) {
return ref.onKeyDown(keyProps);
}
return false;
},
};
function updatePosition(p: {
clientRect?: (() => DOMRect | null) | null;
}) {
if (!component) return;
const rect = p.clientRect?.();
if (!rect) return;
const el = component.element;
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 6}px`;
}
},
}),
];
},
});