ubiquitous-invention/apps/web/components/editor/slash-menu.tsx
Randall Stillwell a508ece6e7 feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:

- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)

Made-with: Cursor
2026-03-26 22:39:16 -05:00

413 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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`;
}
},
}),
];
},
});