414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
|
|
"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`;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
];
|
|||
|
|
},
|
|||
|
|
});
|