Spaces:
Runtime error
Runtime error
| 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<string, SlashSection> = { | |
| 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<string, SlashSection> = { | |
| 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<Omit<SlashItem, "section"> & { 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<SlashSection, SlashItem[]>(); | |
| 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<SlashMenuListRef, SlashMenuListProps>( | |
| ({ 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 ( | |
| <div className="slash-menu"> | |
| {groups.map((group) => ( | |
| <div key={group.section} className="slash-menu-group"> | |
| <div className="slash-menu-section" aria-hidden="true"> | |
| {group.section} | |
| </div> | |
| {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 ( | |
| <button | |
| key={`${group.section}:${item.title}`} | |
| className={`slash-menu-item ${flatIndex === selectedIndex ? "is-selected" : ""}`} | |
| onClick={() => selectItem(flatIndex)} | |
| onMouseEnter={() => setSelectedIndex(flatIndex)} | |
| > | |
| <span className="slash-menu-item-icon">{item.icon}</span> | |
| <span className="slash-menu-item-content"> | |
| <span className="slash-menu-item-title">{item.title}</span> | |
| <span className="slash-menu-item-desc">{item.description}</span> | |
| </span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| }, | |
| ); | |
| 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<SlashMenuListRef> | null = null; | |
| let popup: TippyInstance[] | null = null; | |
| return { | |
| onStart: (props: SuggestionProps<SlashItem>) => { | |
| 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<SlashItem>) { | |
| 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(); | |
| }, | |
| }; | |
| }, | |
| }; | |
| } | |