tfrere's picture
tfrere HF Staff
feat(editor): embed studio with data files and agent-aware editing
8fc8501
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();
},
};
},
};
}