import { ReactRenderer } from "@tiptap/react"; import tippy, { type Instance as TippyInstance } from "tippy.js"; import { type SuggestionProps, type SuggestionKeyDownProps } from "@tiptap/suggestion"; import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useCallback, } from "react"; import type { Editor } from "@tiptap/core"; import { getComponentSlashItems } from "./components"; type SlashSection = | "Insert" | "Text" | "Academic" | "Media" | "Layout" | "Advanced"; interface SlashItem { title: string; description: string; icon: string; section: SlashSection; command: (editor: Editor) => void; } const BUILT_IN_ITEMS: SlashItem[] = [ { title: "Heading 1", description: "Large section heading", icon: "H1", section: "Text", command: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(), }, { title: "Heading 2", description: "Medium section heading", icon: "H2", section: "Text", command: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(), }, { title: "Heading 3", description: "Small section heading", icon: "H3", section: "Text", command: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(), }, { title: "Bullet list", description: "Unordered list", icon: "•", section: "Text", command: (editor) => editor.chain().focus().toggleBulletList().run(), }, { title: "Numbered list", description: "Ordered list", icon: "1.", section: "Text", command: (editor) => editor.chain().focus().toggleOrderedList().run(), }, { title: "Quote", description: "Blockquote", icon: "❝", section: "Text", command: (editor) => editor.chain().focus().toggleBlockquote().run(), }, { title: "Code block", description: "Code with syntax highlighting", icon: "<>", section: "Text", command: (editor) => editor.chain().focus().toggleCodeBlock().run(), }, { title: "Divider", description: "Horizontal rule", icon: "—", section: "Text", command: (editor) => editor.chain().focus().setHorizontalRule().run(), }, { title: "Image", description: "Upload or embed an image", icon: "🖼", section: "Insert", command: (editor) => { editor.chain().focus().insertImageUpload().run(); }, }, { title: "Citation", description: "Cite a paper (DOI or BibTeX)", icon: "📎", section: "Academic", command: () => { window.dispatchEvent(new CustomEvent("open-citation-panel")); }, }, { title: "Glossary", description: "Highlighted term with tooltip definition", icon: "📖", section: "Academic", command: (editor) => { editor.chain().focus().insertGlossary("term", "Definition here…").run(); }, }, { title: "Footnote", description: "Numbered footnote reference", icon: "¹", section: "Academic", command: (editor) => { editor.chain().focus().insertFootnote("Footnote content…").run(); }, }, { title: "Stack", description: "Multi-column layout (2-4 columns)", icon: "▥", section: "Layout", command: (editor) => { editor.chain().focus().insertStack(2).run(); }, }, { title: "New Chart", description: "Create a D3 chart with AI (Embed Studio)", icon: "📊", section: "Insert", command: (editor) => { const id = `d3-chart-${Date.now().toString(36)}`; const src = `${id}.html`; (editor.chain().focus() as any).insertHtmlEmbed().run(); setTimeout(() => { const { doc } = editor.state; let targetPos = -1; doc.descendants((node, pos) => { if (node.type.name === "htmlEmbed" && !node.attrs.src) { targetPos = pos; return false; } }); if (targetPos >= 0) { editor.view.dispatch( editor.state.tr.setNodeMarkup(targetPos, undefined, { ...editor.state.doc.nodeAt(targetPos)?.attrs, src, title: "New chart", }), ); } window.dispatchEvent( new CustomEvent("open-embed-studio", { detail: { src } }), ); }, 50); }, }, ]; /** * Component-driven slash items (Accordion, Note, HtmlEmbed, ...) come * from the shared component registry. We map each tag to its slash * section here so the menu stays categorized when new components are * added. */ const COMPONENT_SECTION_MAP: Record = { Accordion: "Layout", Note: "Academic", Quote: "Text", Wide: "Layout", FullWidth: "Layout", Sidenote: "Academic", Reference: "Media", HtmlEmbed: "Media", HfUser: "Media", RawHtml: "Advanced", Mermaid: "Media", }; function inferSection(title: string): SlashSection { // Component slash items don't carry the original tag; match by label. const byLabel: Record = { Accordion: "Layout", "Note / Callout": "Academic", "Quote block": "Text", Wide: "Layout", "Full width": "Layout", Sidenote: "Academic", "Reference / Figure": "Media", "HTML Embed": "Media", "HF User card": "Media", "Raw HTML": "Advanced", "Mermaid diagram": "Media", }; return byLabel[title] ?? "Layout"; } function withInferredSection( items: Array & { section?: SlashSection }>, ): SlashItem[] { return items.map((item) => ({ ...item, section: item.section ?? inferSection(item.title), })); } const ITEMS: SlashItem[] = [ ...BUILT_IN_ITEMS, ...withInferredSection(getComponentSlashItems()), ]; // Display order of section groups in the menu. "Insert" is a tiny // featured section at the very top holding the two flagship heavy // inserts (New Chart, Image) - this editor's signature features. // Text blocks follow because they're 80% of real usage. const SECTION_ORDER: SlashSection[] = [ "Insert", "Text", "Media", "Academic", "Layout", "Advanced", ]; // Used to re-export so we can also feed the section map to anyone who // wants it (e.g. the TopBar "more" menu could list categories). export { SECTION_ORDER, COMPONENT_SECTION_MAP }; interface SlashGroup { section: SlashSection; items: SlashItem[]; } function groupItems(items: SlashItem[]): SlashGroup[] { const map = new Map(); for (const item of items) { const list = map.get(item.section) ?? []; list.push(item); map.set(item.section, list); } return SECTION_ORDER.filter((s) => map.has(s)).map((section) => ({ section, items: map.get(section)!, })); } interface SlashMenuListProps { items: SlashItem[]; command: (item: SlashItem) => void; } interface SlashMenuListRef { onKeyDown: (props: SuggestionKeyDownProps) => boolean; } const SlashMenuList = forwardRef( ({ items, command }, ref) => { const [selectedIndex, setSelectedIndex] = useState(0); // We render items in section groups. Keyboard navigation still // operates on the flat `items` array (not on section headers), // so ArrowDown moves between actual items regardless of their // section. const groups = useMemo(() => groupItems(items), [items]); useEffect(() => { setSelectedIndex(0); }, [items]); const selectItem = useCallback( (index: number) => { const item = items[index]; if (item) command(item); }, [items, command], ); useImperativeHandle(ref, () => ({ onKeyDown: ({ event }: SuggestionKeyDownProps) => { 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 null; return (
{groups.map((group) => (
{group.items.map((item) => { // Map back to the flat index so keyboard state and // click highlight stay in sync. const flatIndex = items.indexOf(item); return ( ); })}
))}
); }, ); SlashMenuList.displayName = "SlashMenuList"; export function slashMenuSuggestion() { return { items: ({ query }: { query: string }) => { const q = query.toLowerCase(); if (!q) return ITEMS; // Match on title OR description so typing "image" surfaces // both "Image" and "HTML Embed", etc. return ITEMS.filter( (item) => item.title.toLowerCase().includes(q) || item.description.toLowerCase().includes(q), ); }, render: () => { let component: ReactRenderer | null = null; let popup: TippyInstance[] | null = null; return { onStart: (props: SuggestionProps) => { component = new ReactRenderer(SlashMenuList, { props, editor: props.editor, }); if (!props.clientRect) return; popup = tippy("body", { getReferenceClientRect: props.clientRect as () => DOMRect, appendTo: () => document.body, content: component.element, showOnCreate: true, interactive: true, trigger: "manual", placement: "bottom-start", }); }, onUpdate(props: SuggestionProps) { component?.updateProps(props); if (!props.clientRect || !popup?.[0]) return; popup[0].setProps({ getReferenceClientRect: props.clientRect as () => DOMRect, }); }, onKeyDown(props: SuggestionKeyDownProps) { if (props.event.key === "Escape") { popup?.[0]?.hide(); return true; } return component?.ref?.onKeyDown(props) ?? false; }, onExit() { popup?.[0]?.destroy(); component?.destroy(); }, }; }, }; }