| 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); |
| }, |
| }, |
| ]; |
|
|
| |
| |
| |
| |
| |
| |
| 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 { |
| |
| 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()), |
| ]; |
|
|
| |
| |
| |
| |
| const SECTION_ORDER: SlashSection[] = [ |
| "Insert", |
| "Text", |
| "Media", |
| "Academic", |
| "Layout", |
| "Advanced", |
| ]; |
|
|
| |
| |
| 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); |
|
|
| |
| |
| |
| |
| 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; |
| |
| |
| 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(); |
| }, |
| }; |
| }, |
| }; |
| } |
|
|