222 lines
8.7 KiB
TypeScript
222 lines
8.7 KiB
TypeScript
import { type Component, createSignal, For, createEffect } from "solid-js";
|
|
import { Type, Code, Quote, Heading1, Heading2, Heading3, List, ListTodo, FileText, AppWindow, Minus, Grid3X3, Trash2 } from "lucide-solid";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface CommandItem {
|
|
title: string;
|
|
description: string;
|
|
aliases?: string[];
|
|
icon: any;
|
|
command: (props: { editor: any; range: any }) => void;
|
|
}
|
|
|
|
export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] => {
|
|
return [
|
|
{
|
|
title: "Text",
|
|
description: "Just start typing with plain text.",
|
|
icon: Type,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).setNode("paragraph").run();
|
|
},
|
|
},
|
|
{
|
|
title: "Heading 1",
|
|
description: "Big section heading.",
|
|
aliases: ["h1"],
|
|
icon: Heading1,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
|
},
|
|
},
|
|
{
|
|
title: "Heading 2",
|
|
description: "Medium section heading.",
|
|
aliases: ["h2"],
|
|
icon: Heading2,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
|
},
|
|
},
|
|
{
|
|
title: "Heading 3",
|
|
description: "Small section heading.",
|
|
aliases: ["h3"],
|
|
icon: Heading3,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
|
},
|
|
},
|
|
{
|
|
title: "Bullet List",
|
|
description: "Create a simple bulleted list.",
|
|
icon: List,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Checklist",
|
|
description: "Insert a checklist.",
|
|
icon: ListTodo,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Multi Checklist",
|
|
description: "Insert a row with multiple checkboxes.",
|
|
aliases: ["multi", "checkboxes", "multi-checklist"],
|
|
icon: ListTodo,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).toggleMultiTaskList().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Code Block",
|
|
description: "Capture a code snippet.",
|
|
icon: Code,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Blockquote",
|
|
description: "Capture a quote.",
|
|
icon: Quote,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Upload",
|
|
description: "Upload an image, video, or document.",
|
|
aliases: ["image", "video", "file", "upload", "pdf", "movie"],
|
|
icon: FileText,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).uploadMedia().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Job File",
|
|
description: "Embed a job file from Prism.",
|
|
aliases: ["job", "jobfile", "prism"],
|
|
icon: AppWindow,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).insertFileViewer().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Divider",
|
|
description: "Insert a horizontal rule.",
|
|
icon: Minus,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
|
},
|
|
},
|
|
{
|
|
title: "Table",
|
|
description: "Insert a 3x3 table.",
|
|
aliases: ["grid", "table"],
|
|
icon: Grid3X3,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
|
},
|
|
},
|
|
|
|
{
|
|
title: "Delete Table",
|
|
description: "Delete the entire table.",
|
|
icon: Trash2,
|
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
|
editor.chain().focus().deleteRange(range).deleteTable().run();
|
|
},
|
|
},
|
|
].filter(item => {
|
|
const lowerQuery = query.toLowerCase();
|
|
return item.title.toLowerCase().startsWith(lowerQuery) ||
|
|
item.aliases?.some(alias => alias.toLowerCase().startsWith(lowerQuery));
|
|
});
|
|
};
|
|
|
|
export const SlashMenu: Component<{
|
|
items: CommandItem[];
|
|
command: (item: CommandItem) => void;
|
|
editor: any;
|
|
ref: (handlers: { onKeyDown: (e: KeyboardEvent) => boolean }) => void;
|
|
}> = (props) => {
|
|
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
|
|
// Auto-select the first item when the list changes (filtering)
|
|
createEffect(() => {
|
|
// We track props.items. When it changes, we reset.
|
|
props.items;
|
|
setSelectedIndex(0);
|
|
});
|
|
|
|
const selectItem = (index: number) => {
|
|
const item = props.items[index];
|
|
if (item) {
|
|
props.command(item);
|
|
}
|
|
};
|
|
|
|
// Expose handlers to the parent via the ref prop
|
|
props.ref({
|
|
onKeyDown: (e: KeyboardEvent) => {
|
|
if (e.key === "ArrowUp") {
|
|
setSelectedIndex((prev) => (prev + props.items.length - 1) % props.items.length);
|
|
return true;
|
|
}
|
|
if (e.key === "ArrowDown") {
|
|
setSelectedIndex((prev) => (prev + 1) % props.items.length);
|
|
return true;
|
|
}
|
|
if (e.key === "Enter") {
|
|
selectItem(selectedIndex());
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
});
|
|
|
|
return (
|
|
<div
|
|
class="z-[200] w-64 bg-popover border border-border rounded-lg shadow-xl overflow-hidden p-1 bg-background pointer-events-auto"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onWheel={(e) => e.stopPropagation()}
|
|
>
|
|
<div class="max-h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 overscroll-contain">
|
|
<For each={props.items}>
|
|
{(item, index) => (
|
|
<button
|
|
class={cn(
|
|
"flex items-center gap-3 w-full px-2 py-1.5 text-left rounded-md transition-colors outline-none",
|
|
index() === selectedIndex() ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
|
)}
|
|
onMouseDown={(e) => {
|
|
// Prevent editor focus loss and side-sheet closure
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
selectItem(index());
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(index())}
|
|
>
|
|
<div class="flex items-center justify-center w-8 h-8 rounded border border-border bg-muted/50 text-muted-foreground shrink-0">
|
|
<item.icon size={16} />
|
|
</div>
|
|
<div class="flex flex-col min-w-0 overflow-hidden">
|
|
<span class="text-sm font-medium leading-none">{item.title}</span>
|
|
<span class="text-[0.6875rem] text-muted-foreground truncate mt-1">{item.description}</span>
|
|
</div>
|
|
</button>
|
|
)}
|
|
</For>
|
|
{props.items.length === 0 && (
|
|
<div class="p-2 text-xs text-muted-foreground text-center">No results found</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|