"use client"; import { useState, useEffect, useRef, useCallback, useLayoutEffect, forwardRef, useImperativeHandle, } from "react"; import { Extension } from "@tiptap/core"; import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion"; import { PluginKey } from "@tiptap/pm/state"; import { createPortal } from "react-dom"; import type { Editor, Range } from "@tiptap/core"; import type { SearchIndexItem } from "@/lib/search-index"; import { buildEntryLink, buildFileLink } from "@/lib/workspace-links"; // Unique plugin keys so both suggestions can coexist const slashCommandPluginKey = new PluginKey("slashCommand"); const fileMentionPluginKey = new PluginKey("fileMention"); // --- Types --- export type TreeNode = { name: string; path: string; type: "object" | "document" | "folder" | "file" | "database" | "report"; icon?: string; children?: TreeNode[]; }; type SlashItem = { title: string; description?: string; badge?: string; icon: React.ReactNode; category: "file" | "block" | "entry"; command: (props: { editor: Editor; range: Range }) => void; }; /** Search function signature accepted by createWorkspaceMention. */ export type MentionSearchFn = (query: string, limit?: number) => SearchIndexItem[]; function nodeTypeIcon(type: string) { switch (type) { case "document": return ( ); case "object": return ( ); case "report": return ( ); default: return ( ); } } // --- Block command icons --- const headingIcon = (level: number) => ( H{level} ); const bulletListIcon = ( ); const orderedListIcon = ( ); const blockquoteIcon = ( ); const codeBlockIcon = ( ); const horizontalRuleIcon = ( ); const imageIcon = ( ); const tableIcon = ( ); const taskListIcon = ( ); const reportIcon = ( ); const entryIcon = ( ); // --- Build items --- /** Convert a SearchIndexItem to a SlashItem for the @ mention popup. */ function searchItemToSlashItem(item: SearchIndexItem): SlashItem { if (item.kind === "entry") { const label = item.label || `(${item.objectName} entry)`; return { title: label, description: item.fields ? Object.entries(item.fields) .slice(0, 2) .map(([k, v]) => `${k}: ${v}`) .join(" | ") : undefined, badge: item.objectName, icon: entryIcon, category: "entry", command: ({ editor, range }: { editor: Editor; range: Range }) => { const href = buildEntryLink(item.objectName!, item.entryId!); editor .chain() .focus() .deleteRange(range) .insertContent({ type: "text", text: label, marks: [{ type: "link", attrs: { href, target: null } }], }) .run(); }, }; } const nodeType = item.nodeType ?? (item.kind === "object" ? "object" : "file"); const label = item.label || item.path || item.id; return { title: label, description: item.sublabel, icon: nodeTypeIcon(nodeType), category: "file", command: ({ editor, range }: { editor: Editor; range: Range }) => { const href = buildFileLink(item.path ?? item.id); editor .chain() .focus() .deleteRange(range) .insertContent({ type: "text", text: label, marks: [ { type: "link", attrs: { href, target: null } }, ], }) .run(); }, }; } function buildBlockCommands(): SlashItem[] { return [ { title: "Heading 1", description: "Large section heading", icon: headingIcon(1), category: "block", command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); }, }, { title: "Heading 2", description: "Medium section heading", icon: headingIcon(2), category: "block", command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); }, }, { title: "Heading 3", description: "Small section heading", icon: headingIcon(3), category: "block", command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); }, }, { title: "Bullet List", description: "Unordered list", icon: bulletListIcon, category: "block", command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleBulletList().run(); }, }, { title: "Numbered List", description: "Ordered list", icon: orderedListIcon, category: "block", command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleOrderedList().run(); }, }, { title: "Task List", description: "Checklist with checkboxes", icon: taskListIcon, category: "block", command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleTaskList().run(); }, }, { title: "Blockquote", description: "Quote block", icon: blockquoteIcon, category: "block", command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleBlockquote().run(); }, }, { title: "Code Block", description: "Fenced code block", icon: codeBlockIcon, category: "block", command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); }, }, { title: "Horizontal Rule", description: "Divider line", icon: horizontalRuleIcon, category: "block", command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, }, { title: "Image", description: "Insert image from URL", icon: imageIcon, category: "block", command: ({ editor, range }) => { const url = window.prompt("Image URL:"); if (url) { editor.chain().focus().deleteRange(range).setImage({ src: url }).run(); } }, }, { title: "Table", description: "Insert a 3x3 table", icon: tableIcon, category: "block", command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .run(); }, }, { title: "Report Chart", description: "Interactive report-json block", icon: reportIcon, category: "block", command: ({ editor, range }) => { const template = JSON.stringify( { version: 1, title: "New Report", panels: [ { id: "panel-1", title: "Chart", type: "bar", sql: "SELECT 1 as x, 10 as y", mapping: { xAxis: "x", yAxis: ["y"] }, }, ], }, null, 2, ); editor .chain() .focus() .deleteRange(range) .insertContent({ type: "reportBlock", attrs: { config: template }, }) .run(); }, }, ]; } // buildFileItems removed -- replaced by searchItemToSlashItem + search index // --- Popup Component --- type CommandListRef = { onKeyDown: (props: { event: KeyboardEvent }) => boolean; }; type CommandListProps = { items: SlashItem[]; command: (item: SlashItem) => void; }; const CommandList = forwardRef( ({ items, command }, ref) => { const [selectedIndex, setSelectedIndex] = useState(0); const listRef = useRef(null); useEffect(() => { setSelectedIndex(0); }, [items]); // Scroll selected into view useEffect(() => { const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined; el?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); const selectItem = useCallback( (index: number) => { const item = items[index]; if (item) { command(item); } }, [items, command], ); useImperativeHandle(ref, () => ({ onKeyDown: ({ event }: { event: KeyboardEvent }) => { if (event.key === "ArrowUp") { setSelectedIndex((i) => (i + items.length - 1) % items.length); return true; } if (event.key === "ArrowDown") { setSelectedIndex((i) => (i + 1) % items.length); return true; } if (event.key === "Enter") { selectItem(selectedIndex); return true; } return false; }, })); if (items.length === 0) { return (
No results
); } return (
{items.map((item, index) => ( ))}
); }, ); CommandList.displayName = "CommandList"; // --- Floating wrapper that renders into a portal --- function SlashPopupRenderer({ items, command, clientRect, componentRef, }: { items: SlashItem[]; command: (item: SlashItem) => void; clientRect: (() => DOMRect | null) | null | undefined; componentRef: React.RefObject; }) { const popupRef = useRef(null); // Position popup near the cursor useLayoutEffect(() => { if (!popupRef.current || !clientRect) {return;} const rect = clientRect(); if (!rect) {return;} const el = popupRef.current; el.style.position = "fixed"; el.style.left = `${rect.left}px`; el.style.top = `${rect.bottom + 4}px`; el.style.zIndex = "50"; }, [clientRect, items]); return createPortal(
, document.body, ); } // --- Shared suggestion render factory --- function createSuggestionRenderer() { return () => { let container: HTMLDivElement | null = null; let root: ReturnType | null = null; const componentRef: React.RefObject = { current: null }; return { onStart: (props: { items: SlashItem[]; command: (item: SlashItem) => void; clientRect?: (() => DOMRect | null) | null; }) => { container = document.createElement("div"); document.body.appendChild(container); void import("react-dom/client").then(({ createRoot }) => { root = createRoot(container!); root.render( , ); }); }, onUpdate: (props: { items: SlashItem[]; command: (item: SlashItem) => void; clientRect?: (() => DOMRect | null) | null; }) => { root?.render( , ); }, onKeyDown: (props: { event: KeyboardEvent }) => { if (props.event.key === "Escape") { root?.unmount(); container?.remove(); container = null; root = null; return true; } return componentRef.current?.onKeyDown(props) ?? false; }, onExit: () => { root?.unmount(); container?.remove(); container = null; root = null; }, }; }; } // --- Extension factories --- /** * "/" slash command -- markdown block commands only (headings, lists, code, etc.) */ export function createSlashCommand() { const blockCommands = buildBlockCommands(); return Extension.create({ name: "slashCommand", addOptions() { return { suggestion: { char: "/", pluginKey: slashCommandPluginKey, startOfLine: false, command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => { item.command({ editor, range }); }, items: ({ query }: { query: string }) => { const q = query.toLowerCase(); if (!q) {return blockCommands;} return blockCommands.filter( (item) => item.title.toLowerCase().includes(q) || (item.description?.toLowerCase().includes(q) ?? false), ); }, render: createSuggestionRenderer(), } satisfies Partial>, }; }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), ]; }, }); } /** * "@" mention command -- unified workspace search (files + objects + entries). * Accepts a search function from the useSearchIndex hook for fast fuzzy matching. */ export function createWorkspaceMention(searchFn: MentionSearchFn) { return Extension.create({ name: "fileMention", addOptions() { return { suggestion: { char: "@", pluginKey: fileMentionPluginKey, startOfLine: false, command: ({ editor, range, props: item }: { editor: Editor; range: Range; props: SlashItem }) => { item.command({ editor, range }); }, items: ({ query }: { query: string }) => { const results = searchFn(query, 15); return results.map(searchItemToSlashItem); }, render: createSuggestionRenderer(), } satisfies Partial>, }; }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), ]; }, }); } /** * "@" mention command -- legacy file-only cross-linking (fallback). * @deprecated Use createWorkspaceMention with useSearchIndex instead. */ export function createFileMention(tree: TreeNode[]) { function flattenTree(nodes: TreeNode[]): TreeNode[] { const result: TreeNode[] = []; for (const node of nodes) { if (node.type !== "folder") {result.push(node);} if (node.children) {result.push(...flattenTree(node.children));} } return result; } const flatFiles = flattenTree(tree); const searchItems: SearchIndexItem[] = flatFiles.map((node) => ({ id: node.path, label: node.name.replace(/\.md$/, ""), sublabel: node.path, kind: (node.type === "object" ? "object" : "file") as SearchIndexItem["kind"], path: node.path, nodeType: node.type as SearchIndexItem["nodeType"], })); const searchFn: MentionSearchFn = (query, limit = 15) => { if (!query) {return searchItems.slice(0, limit);} const q = query.toLowerCase(); return searchItems .filter( (item) => item.label.toLowerCase().includes(q) || (item.sublabel?.toLowerCase().includes(q) ?? false), ) .slice(0, limit); }; return createWorkspaceMention(searchFn); }