ubiquitous-invention/apps/web/components/editor/extensions/mention.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

275 lines
7.8 KiB
TypeScript

"use client";
import { Node, mergeAttributes } from "@tiptap/react";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion, { type SuggestionProps } from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export type MentionEntityType = "user" | "object";
export type MentionItem = {
id: string;
label: string;
type: MentionEntityType;
};
const MOCK_ITEMS: MentionItem[] = [
{ id: "u1", label: "Alice Chen", type: "user" },
{ id: "u2", label: "Jordan Smith", type: "user" },
{ id: "u3", label: "Sam Rivera", type: "user" },
{ id: "o1", label: "Q1 Roadmap", type: "object" },
{ id: "o2", label: "Design System", type: "object" },
{ id: "o3", label: "Customer Onboarding", type: "object" },
];
export const mentionSuggestionPluginKey = new PluginKey("mentionSuggestion");
function filterItems(query: string): MentionItem[] {
const q = query.trim().toLowerCase();
if (!q) return MOCK_ITEMS.slice(0, 8);
return MOCK_ITEMS.filter((item) =>
item.label.toLowerCase().includes(q),
).slice(0, 8);
}
function positionList(el: HTMLElement, props: SuggestionProps<MentionItem>) {
const rect = props.clientRect?.();
if (!rect) return;
el.style.position = "fixed";
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 8}px`;
el.style.zIndex = "50";
el.style.minWidth = `${Math.max(rect.width, 220)}px`;
}
type ListProps = {
items: MentionItem[];
command: (item: MentionItem) => void;
selectedIndex: number;
};
function MentionList({ items, command, selectedIndex }: ListProps) {
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const el = ref.current?.children[selectedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (items.length === 0) {
return (
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-sm text-muted-foreground shadow-lg">
No matches
</div>
);
}
return (
<div
ref={ref}
className="max-h-[min(280px,50vh)] overflow-auto rounded-lg border border-border bg-popover p-1 shadow-lg"
role="listbox"
>
{items.map((item, index) => (
<button
key={`${item.type}-${item.id}`}
className={cn(
"flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
item.type === "user"
? "text-foreground"
: "text-foreground",
index === selectedIndex && "bg-accent",
)}
type="button"
role="option"
aria-selected={index === selectedIndex}
onClick={() => command(item)}
>
<span
className={cn(
"mr-2 rounded-md px-1.5 py-0.5 text-xs font-medium",
item.type === "user"
? "bg-primary/15 text-primary dark:bg-primary/25"
: "bg-teal/15 text-teal dark:bg-teal/25",
)}
>
{item.type === "user" ? "User" : "Object"}
</span>
<span className="truncate">@{item.label}</span>
</button>
))}
</div>
);
}
export const Mention = Node.create({
name: "mention",
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
id: {
default: null as string | null,
parseHTML: (el) => el.getAttribute("data-id"),
renderHTML: (attrs) => (attrs.id ? { "data-id": attrs.id } : {}),
},
label: {
default: "",
parseHTML: (el) =>
el.textContent?.replace(/^@/, "").trim() ?? "",
renderHTML: () => ({}),
},
type: {
default: "user" as MentionEntityType,
parseHTML: (el) =>
(el.getAttribute("data-mention-type") as MentionEntityType | null) ??
"user",
renderHTML: (attrs) => ({
"data-mention-type": attrs.type ?? "user",
}),
},
};
},
parseHTML() {
return [
{
tag: 'span[data-type="mention"]',
},
];
},
renderHTML({ node, HTMLAttributes }) {
const label = String(node.attrs.label ?? "");
const isUser = node.attrs.type === "user";
return [
"span",
mergeAttributes(HTMLAttributes, {
"data-type": "mention",
"data-id": node.attrs.id ?? "",
"data-mention-type": node.attrs.type ?? "user",
class: cn(
"mention-chip rounded-md px-1.5 py-0.5 text-sm font-medium",
isUser
? "bg-primary/15 text-primary dark:bg-primary/25"
: "bg-teal/15 text-teal dark:bg-teal/25",
),
}),
`@${label}`,
];
},
renderText({ node }) {
return `@${node.attrs.label ?? ""}`;
},
addProseMirrorPlugins() {
const extensionName = this.name;
let renderer: ReactRenderer | null = null;
let lastProps: SuggestionProps<MentionItem> | null = null;
let selectedIndex = 0;
const updateSelected = (next: number) => {
if (!lastProps || !renderer) return;
const len = lastProps.items.length;
if (len === 0) return;
selectedIndex = ((next % len) + len) % len;
renderer.updateProps({
items: lastProps.items,
command: lastProps.command,
selectedIndex,
});
};
return [
Suggestion<MentionItem, MentionItem>({
editor: this.editor,
pluginKey: mentionSuggestionPluginKey,
char: "@",
allowSpaces: false,
items: ({ query }) => filterItems(query),
command: ({ editor, range, props }) => {
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: extensionName,
attrs: {
id: props.id,
label: props.label,
type: props.type,
},
},
{ type: "text", text: " " },
])
.run();
},
render: () => ({
onStart: (props) => {
lastProps = props;
selectedIndex = 0;
renderer = new ReactRenderer(MentionList, {
editor: props.editor,
props: {
items: props.items,
command: props.command,
selectedIndex: 0,
},
});
renderer.element.style.pointerEvents = "auto";
document.body.appendChild(renderer.element);
positionList(renderer.element, props);
},
onUpdate: (props) => {
lastProps = props;
selectedIndex = Math.min(
selectedIndex,
Math.max(0, props.items.length - 1),
);
renderer?.updateProps({
items: props.items,
command: props.command,
selectedIndex,
});
if (renderer) positionList(renderer.element, props);
},
onExit: () => {
renderer?.destroy();
renderer = null;
lastProps = null;
selectedIndex = 0;
},
onKeyDown: ({ event }) => {
if (event.key === "ArrowDown") {
event.preventDefault();
updateSelected(selectedIndex + 1);
return true;
}
if (event.key === "ArrowUp") {
event.preventDefault();
updateSelected(selectedIndex - 1);
return true;
}
if (event.key === "Enter") {
event.preventDefault();
const item = lastProps?.items[selectedIndex];
if (item && lastProps) {
lastProps.command(item);
}
return true;
}
return false;
},
}),
}),
];
},
});