+
+ {(
+ [
+ ["B", "bold", () => execRichTextCommand("bold")],
+ ["I", "italic", () => execRichTextCommand("italic")],
+ ["`", "code", wrapCode],
+ ["↗", "link", () => requestRichTextLink(undefined, ref.current)],
+ ["—", "item", () => execRichTextCommand("insertText", "- ")],
+ ] as const
+ ).map(([label, title, action]) => (
+
+ ))}
+
+
onChange(html2md(e.currentTarget.innerHTML))}
+ onClick={(e) => {
+ const anchor = (e.target as HTMLElement).closest("a[href]");
+ if (!anchor || !ref.current?.contains(anchor)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const range = document.createRange();
+ range.selectNodeContents(anchor);
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ sel?.addRange(range);
+ requestRichTextLink(anchor.getBoundingClientRect(), ref.current);
+ }}
+ onPaste={(e) => {
+ e.preventDefault();
+ document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
+ }}
+ />
+
+ );
+}
+
+// ─── md2html / html2md (minimal inline markdown → HTML conversion) ───────────
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function escapeAttr(value: string): string {
+ return escapeHtml(value).replace(/'/g, "'");
+}
+
+function inlineMdToHtml(md: string): string {
+ const links: string[] = [];
+ const withLinkTokens = md.replace(
+ /\[([^\]]+)\]\(([^)]+)\)/g,
+ (_m, label: string, href: string) => {
+ const token = `\uE000${links.length}\uE001`;
+ links.push(`
${inlineMdToHtml(label)}`);
+ return token;
+ },
+ );
+
+ let s = escapeHtml(withLinkTokens);
+ s = s
+ .replace(/\*\*(.+?)\*\*/gs, "
$1")
+ .replace(/__(.+?)__/gs, "
$1")
+ .replace(/\*(.+?)\*/gs, "
$1")
+ .replace(/_(.+?)_/gs, "
$1")
+ .replace(/`(.+?)`/g, "
$1");
+ links.forEach((html, index) => {
+ s = s.replace(`\uE000${index}\uE001`, html);
+ });
+ return s;
+}
+
+function md2html(md: string): string {
+ const lines = md.split("\n");
+ const out: string[] = [];
+ let normal: string[] = [];
+ let list: Array<{ ordered: boolean; text: string }> = [];
+
+ function flushNormal() {
+ if (normal.length === 0) return;
+ out.push(`
${inlineMdToHtml(normal.join("\n")).replace(/\n/g, "
")}
`);
+ normal = [];
+ }
+
+ function flushList() {
+ if (list.length === 0) return;
+ const ordered = list[0]?.ordered ?? false;
+ const tag = ordered ? "ol" : "ul";
+ out.push(
+ `<${tag}>${list.map((item) => `
${inlineMdToHtml(item.text)}`).join("")}${tag}>`,
+ );
+ list = [];
+ }
+
+ for (const rawLine of lines) {
+ const line = rawLine.trim();
+ const unordered = line.match(/^[-*+]\s+(.+)$/);
+ const ordered = line.match(/^\d+[.)]\s+(.+)$/);
+
+ if (unordered || ordered) {
+ flushNormal();
+ const isOrdered = Boolean(ordered);
+ if (list.length > 0 && list[0]?.ordered !== isOrdered) flushList();
+ list.push({ ordered: isOrdered, text: unordered?.[1] ?? ordered?.[1] ?? "" });
+ continue;
+ }
+
+ flushList();
+ normal.push(rawLine);
+ }
+
+ flushNormal();
+ flushList();
+ return out.join("") || "
";
+}
+
+function html2md(html: string): string {
+ if (typeof document === "undefined") return html;
+
+ const root = document.createElement("div");
+ root.innerHTML = html;
+
+ function inline(nodes: NodeListOf
| ChildNode[]): string {
+ return Array.from(nodes)
+ .map((node) => {
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
+ if (!(node instanceof HTMLElement)) return "";
+
+ const tag = node.tagName.toLowerCase();
+ if (tag === "br") return "\n";
+ if (tag === "strong" || tag === "b") return `**${inline(node.childNodes)}**`;
+ if (tag === "em" || tag === "i") return `_${inline(node.childNodes)}_`;
+ if (tag === "code") return `\`${node.textContent ?? ""}\``;
+ if (tag === "a") {
+ const href = node.getAttribute("href") ?? "";
+ const label = inline(node.childNodes).trim() || href;
+ return href ? `[${label}](${href})` : label;
+ }
+ if (tag === "li") return inline(node.childNodes);
+ return inline(node.childNodes);
+ })
+ .join("");
+ }
+
+ function block(node: ChildNode): string {
+ if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").trim();
+ if (!(node instanceof HTMLElement)) return "";
+
+ const tag = node.tagName.toLowerCase();
+ if (tag === "ul" || tag === "ol") {
+ return Array.from(node.children)
+ .filter((child): child is HTMLElement => child instanceof HTMLElement)
+ .map((li, index) => {
+ const prefix = tag === "ol" ? `${index + 1}. ` : "- ";
+ return `${prefix}${inline(li.childNodes).trim()}`;
+ })
+ .join("\n");
+ }
+ if (tag === "blockquote") {
+ return inline(node.childNodes)
+ .split("\n")
+ .map((line) => `> ${line}`)
+ .join("\n");
+ }
+ if (tag === "hr") return "---";
+ return inline(node.childNodes).trim();
+ }
+
+ return Array.from(root.childNodes)
+ .map(block)
+ .filter(Boolean)
+ .join("\n")
+ .replace(/\u00a0/g, " ")
+ .trim();
+}
+
+// ─── WYSIWYG Paragraph Editor (contentEditable, renders bold/italic/code) ────
+
+function WysiwygParagraphEditor({
+ initialContent,
+ style,
+ onChange,
+ onClose,
+}: {
+ initialContent: string;
+ style: React.CSSProperties;
+ onChange: (md: string) => void;
+ onClose: () => void;
+}) {
+ const ref = useRef(null);
+
+ // Set innerHTML only on mount — never overwrite while the user types
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ el.innerHTML = md2html(initialContent);
+ el.focus();
+ // Move caret to end
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ range.collapse(false);
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ sel?.addRange(range);
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+ onChange(html2md(e.currentTarget.innerHTML))}
+ onClick={(e) => {
+ const anchor = (e.target as HTMLElement).closest("a[href]");
+ if (!anchor || !ref.current?.contains(anchor)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const range = document.createRange();
+ range.selectNodeContents(anchor);
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ sel?.addRange(range);
+ requestRichTextLink(anchor.getBoundingClientRect(), ref.current);
+ }}
+ onPaste={(e) => {
+ e.preventDefault();
+ // Paste as plain text to avoid pasting HTML tags
+ document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onClose();
+ }
+ }}
+ />
+ );
+}
+
+// ─── Inline-editable input ────────────────────────────────────────────────────
+
+function InlineInput({
+ value,
+ onChange,
+ className,
+ placeholder,
+ style,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ className?: string;
+ placeholder?: string;
+ style?: React.CSSProperties;
+}) {
+ return (
+
onChange(e.currentTarget.value)}
+ placeholder={placeholder}
+ style={style}
+ />
+ );
+}
+
+// ─── Block renderer components ────────────────────────────────────────────────
+
+function HeadingRenderer({
+ block,
+ onChange,
+}: {
+ block: Extract
;
+ onChange: (b: MdxBlock) => void;
+}) {
+ const level = Math.min(6, Math.max(1, block.level));
+ const hRef = (el: HTMLHeadingElement | null) => {
+ if (el && document.activeElement !== el && el.innerText !== block.text)
+ el.innerText = block.text;
+ };
+ const hProps = {
+ className: "dt-editable",
+ contentEditable: true as const,
+ suppressContentEditableWarning: true,
+ "data-ph": `Heading ${level}`,
+ onInput: (e: React.FormEvent) =>
+ onChange({ ...block, text: getEditText(e.currentTarget) }),
+ onPaste: (e: React.ClipboardEvent) => {
+ e.preventDefault();
+ document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
+ },
+ };
+ return (
+
+
+ {[1, 2, 3, 4, 5, 6].map((l) => (
+
+ ))}
+
+ {level === 1 &&
}
+ {level === 2 &&
}
+ {level === 3 &&
}
+ {level === 4 &&
}
+ {level === 5 &&
}
+ {level === 6 &&
}
+
+ );
+}
+
+function ParagraphRenderer({
+ block,
+ onChange,
+}: {
+ block: Extract;
+ onChange: (b: MdxBlock) => void;
+}) {
+ return (
+ onChange({ ...block, content })}
+ placeholder="Write a paragraph…"
+ />
+ );
+}
+
+function CalloutRenderer({
+ block,
+ onChange,
+}: {
+ block: Extract;
+ onChange: (b: MdxBlock) => void;
+}) {
+ const v = CALLOUT_VARIANTS[block.variant] ?? CALLOUT_VARIANTS.note!;
+ const Icon = v.iconEl;
+ const primaryVariants = ["note", "warning", "tip", "important", "caution"] as const;
+ return (
+
+ {/* Variant selector — hidden by default, shows on block hover */}
+
+ {primaryVariants.map((k) => {
+ const cv = CALLOUT_VARIANTS[k]!;
+ return (
+
+ );
+ })}
+
+
+ {/* Exact same structure as website/components/ui/callout.tsx */}
+
+
+
+
+
+
onChange({ ...block, title: e.currentTarget.value })}
+ placeholder="Optional title…"
+ />
+
+
+
onChange({ ...block, content })}
+ placeholder="Callout body…"
+ />
+
+
+
+
+ );
+}
+
+function CodeRenderer({
+ block,
+ onChange,
+ autoFocusCode = false,
+}: {
+ block: Extract;
+ onChange: (b: MdxBlock) => void;
+ autoFocusCode?: boolean;
+}) {
+ const codeRef = useRef(null);
+
+ useEffect(() => {
+ if (!autoFocusCode) return;
+ const el = codeRef.current;
+ if (!el) return;
+ el.focus();
+ el.setSelectionRange(el.value.length, el.value.length);
+ }, [autoFocusCode]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+function TabsRenderer({
+ block,
+ onChange,
+}: {
+ block: Extract;
+ onChange: (b: MdxBlock) => void;
+}) {
+ const [activeTab, setActiveTab] = useState(0);
+ const tab = block.tabs[activeTab] ?? block.tabs[0];
+
+ return (
+
+
+ {block.tabs.map((t, i) => (
+
setActiveTab(i)}
+ >
+ {
+ e.stopPropagation();
+ setActiveTab(i);
+ }}
+ onChange={(e) => {
+ const tabs = [...block.tabs];
+ tabs[i] = { ...t, label: e.currentTarget.value };
+ onChange({ ...block, tabs });
+ }}
+ style={{ width: `${Math.max(30, t.label.length * 7.5)}px` }}
+ />
+
+
+ ))}
+
+
+ {tab && (
+
+
{
+ const tabs = [...block.tabs];
+ tabs[activeTab] = { ...tab, content };
+ onChange({ ...block, tabs });
+ }}
+ placeholder="Tab content…"
+ />
+
+ )}
+
+ );
+}
+
+function HoverLinkRenderer({
+ block,
+ onChange,
+}: {
+ block: Extract;
+ onChange: (b: MdxBlock) => void;
+}) {
+ return (
+
+ {/* Looks like the actual rendered HoverLink — an inline dashed-underline link */}
+
+ onChange({ ...block, label: e.currentTarget.value })}
+ placeholder="Link label text…"
+ style={{ width: `${Math.max(120, block.label.length * 8)}px` }}
+ />
+
+ {/* Metadata fields — hidden by default, slide in on hover via dt-hl-meta CSS */}
+
+
+ );
+}
+
+function PromptRenderer({
+ block,
+ onChange,
+}: {
+ block: Extract;
+ onChange: (b: MdxBlock) => void;
+}) {
+ return (
+ // Uses the real fd-prompt CSS classes from packages/fumadocs/styles/base.css
+
+
+
+ onChange({ ...block, title: e.currentTarget.value })}
+ placeholder="Prompt title…"
+ />
+
+
+
+
+
+
+
+
+ );
+}
+
+function RawRenderer({
+ block,
+ onChange,
+}: {
+ block: Extract;
+ onChange: (b: MdxBlock) => void;
+}) {
+ return (
+
+
+ );
+}
+
+function BlockContent({ block, onChange }: { block: MdxBlock; onChange: (b: MdxBlock) => void }) {
+ if (block.type === "heading") return ;
+ if (block.type === "paragraph") return ;
+ if (block.type === "callout") return ;
+ if (block.type === "code") return ;
+ if (block.type === "tabs") return ;
+ if (block.type === "hoverlink") return ;
+ if (block.type === "prompt") return ;
+ if (block.type === "raw") return ;
+ return null;
+}
+
+// ─── SVG icons ────────────────────────────────────────────────────────────────
+
+function ChevUp() {
+ return (
+
+ );
+}
+function ChevDown() {
+ return (
+
+ );
+}
+function Trash() {
+ return (
+
+ );
+}
+function Copy() {
+ return (
+
+ );
+}
+function SaveIcon() {
+ return (
+
+ );
+}
+function Plus() {
+ return (
+
+ );
+}
+
+// ─── Insert palette ───────────────────────────────────────────────────────────
+
+function InsertPalette({
+ x,
+ y,
+ onInsert,
+ onClose,
+}: {
+ x: number;
+ y: number;
+ onInsert: (t: MdxBlock["type"]) => void;
+ onClose: () => void;
+}) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ function handler(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
+ }
+ function keyHandler(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("mousedown", handler);
+ document.addEventListener("keydown", keyHandler);
+ return () => {
+ document.removeEventListener("mousedown", handler);
+ document.removeEventListener("keydown", keyHandler);
+ };
+ }, [onClose]);
+
+ const style: React.CSSProperties = {
+ left: Math.min(x, window.innerWidth - 280),
+ top: Math.min(y, window.innerHeight - 320),
+ };
+
+ return (
+
+
Insert block
+
+ {BLOCK_CATALOGUE.map(({ type, label, icon, accent, desc }) => (
+
+ ))}
+
+
+ );
+}
+
+// ─── Inline block editor (overlays directly on heading / paragraph blocks) ────
+
+function InlineBlockEditor({
+ block,
+ rect,
+ el,
+ onChange,
+ onClose,
+ onMove,
+ onDuplicate,
+ onDelete,
+ onSave,
+ saving,
+ index,
+ total,
+}: {
+ block: Extract;
+ rect: DOMRect;
+ el: HTMLElement;
+ onChange: (b: MdxBlock) => void;
+ onClose: () => void;
+ onMove: (dir: -1 | 1) => void;
+ onDuplicate: () => void;
+ onDelete: () => void;
+ onSave: () => void;
+ saving: boolean;
+ index: number;
+ total: number;
+}) {
+ const cs = window.getComputedStyle(el);
+
+ // Cover style: fixed, same bounding box, opaque background so original is hidden
+ const cover: React.CSSProperties = {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ // grow with content, start at the element's natural height
+ minHeight: rect.height,
+ // Copy padding so text sits in the same position
+ paddingTop: cs.paddingTop || 0,
+ paddingBottom: cs.paddingBottom || 0,
+ paddingLeft: cs.paddingLeft || 0,
+ paddingRight: cs.paddingRight || 0,
+ };
+
+ // Shared text style cloned from the real element
+ const text: React.CSSProperties = {
+ fontSize: cs.fontSize,
+ fontWeight: cs.fontWeight,
+ lineHeight: cs.lineHeight,
+ letterSpacing: cs.letterSpacing,
+ fontFamily: cs.fontFamily,
+ color: cs.color,
+ textDecoration: "none",
+ margin: 0,
+ padding: 0,
+ };
+
+ // Mini action toolbar — appears above the cover
+ const toolbarTop = Math.max(54, rect.top - 36);
+
+ return createPortal(
+ <>
+ {/* Opaque cover that hides the original text and hosts the editor */}
+
+ {block.type === "heading" ? (
+ onChange({ ...block, text: e.target.value })}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") onClose();
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onClose();
+ }
+ }}
+ />
+ ) : (
+ onChange({ ...block, content } as MdxBlock)}
+ onClose={onClose}
+ />
+ )}
+
+
+ {/* Mini toolbar above the block */}
+
+ {block.type === "heading" && (
+
+ {([1, 2, 3, 4, 5, 6] as const).map((l) => (
+
+ ))}
+
+
+ )}
+ {block.type === "paragraph" && (
+
+ {(
+ [
+ ["B", "bold", () => execRichTextCommand("bold")],
+ ["I", "italic", () => execRichTextCommand("italic")],
+ [
+ "`",
+ "code",
+ () => {
+ // Wrap selection in via execCommand insertHTML, or surroundContents
+ const sel = window.getSelection();
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
+ const range = sel.getRangeAt(0);
+ const code = document.createElement("code");
+ try {
+ range.surroundContents(code);
+ syncActiveEditable();
+ } catch {
+ execRichTextCommand("insertText", `\`${sel.toString()}\``);
+ }
+ },
+ ],
+ [
+ "↗",
+ "link",
+ () => {
+ const active = document.activeElement;
+ const host =
+ active instanceof HTMLElement && active.isContentEditable
+ ? active
+ : document.querySelector(
+ ".dt-inline-cover [contenteditable='true']",
+ );
+ requestRichTextLink(undefined, host);
+ },
+ ],
+ ] as const
+ ).map(([lbl, title, fn]) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ >,
+ document.body,
+ );
+}
+
+// ─── Floating block editor (appears near the clicked block) ───────────────────
+
+function FloatingBlockEditor({
+ block,
+ rect,
+ index,
+ total,
+ onClose,
+ onChange,
+ onMove,
+ onDuplicate,
+ onDelete,
+ onSave,
+ saving,
+ onInsertAfter,
+}: {
+ block: MdxBlock;
+ rect: DOMRect;
+ index: number;
+ total: number;
+ onClose: () => void;
+ onChange: (b: MdxBlock) => void;
+ onMove: (dir: -1 | 1) => void;
+ onDuplicate: () => void;
+ onDelete: () => void;
+ onSave: () => void;
+ saving: boolean;
+ onInsertAfter: (type: MdxBlock["type"]) => void;
+}) {
+ const ref = useRef(null);
+ const [palette, setPalette] = useState<{ x: number; y: number } | null>(null);
+ const meta = BLOCK_CATALOGUE.find((b) => b.type === block.type);
+
+ // Position: prefer below the block, fall back to above
+ const editorH = 420;
+ const editorW = Math.min(480, window.innerWidth * 0.9);
+ let top = rect.bottom + 10;
+ if (top + editorH > window.innerHeight - 16) top = Math.max(16, rect.top - editorH - 10);
+ let left = rect.left;
+ if (left + editorW > window.innerWidth - 10)
+ left = Math.max(10, window.innerWidth - editorW - 10);
+
+ useEffect(() => {
+ // Delay attaching the outside-click listener so the very click that opened
+ // this editor doesn't immediately close it.
+ let cleanup: (() => void) | undefined;
+ const tid = window.setTimeout(() => {
+ function outside(e: MouseEvent) {
+ const t = e.target as HTMLElement;
+ if (ref.current?.contains(t)) return;
+ if (t.closest(".dt-palette")) return;
+ onClose();
+ }
+ function key(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("mousedown", outside);
+ document.addEventListener("keydown", key);
+ cleanup = () => {
+ document.removeEventListener("mousedown", outside);
+ document.removeEventListener("keydown", key);
+ };
+ }, 120);
+ return () => {
+ window.clearTimeout(tid);
+ cleanup?.();
+ };
+ }, [onClose]);
+
+ return createPortal(
+
+
+
+ {meta?.icon} {meta?.label ?? block.type}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {palette &&
+ createPortal(
+
{
+ onInsertAfter(type);
+ setPalette(null);
+ }}
+ onClose={() => setPalette(null)}
+ />,
+ document.body,
+ )}
+ ,
+ document.body,
+ );
+}
+
+// ─── SelectionToolbar ────────────────────────────────────────────────────────
+// Global component — appears above any selected text inside a contentEditable
+// within the devtools. Shows B / I / S / ` / Link formatting buttons.
+
+function SelectionToolbar() {
+ const [pos, setPos] = useState<{ top: number; centerX: number } | null>(null);
+
+ useEffect(() => {
+ function onSel() {
+ const sel = window.getSelection();
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
+ setPos(null);
+ return;
+ }
+ // Only activate when the selection is anchored inside a contentEditable
+ const anchor = sel.anchorNode;
+ const host = (
+ anchor?.nodeType === Node.TEXT_NODE ? anchor.parentElement : (anchor as Element | null)
+ )?.closest("[contenteditable]");
+ if (!host) {
+ setPos(null);
+ return;
+ }
+ // Inline page-block editors have their own fixed toolbar. Showing the
+ // global selection bubble there can sit over B/I controls and eat clicks.
+ if (host.closest(".dt-inline-cover")) {
+ setPos(null);
+ return;
+ }
+ const r = sel.getRangeAt(0).getBoundingClientRect();
+ if (!r.width && !r.height) {
+ setPos(null);
+ return;
+ }
+ setPos({ top: r.top - 48, centerX: r.left + r.width / 2 });
+ }
+ document.addEventListener("selectionchange", onSel);
+ return () => document.removeEventListener("selectionchange", onSel);
+ }, []);
+
+ if (!pos) return null;
+
+ const top = Math.max(58, pos.top);
+ const left = Math.max(
+ 60,
+ Math.min((typeof window !== "undefined" ? window.innerWidth : 1200) - 200, pos.centerX),
+ );
+
+ function fmt(cmd: string) {
+ execRichTextCommand(cmd);
+ }
+
+ function wrapCode() {
+ const sel = window.getSelection();
+ if (!sel || sel.isCollapsed || !sel.rangeCount) return;
+ const range = sel.getRangeAt(0);
+ const code = document.createElement("code");
+ try {
+ range.surroundContents(code);
+ syncActiveEditable();
+ } catch {
+ execRichTextCommand("insertText", `\`${sel.toString()}\``);
+ }
+ }
+
+ function wrapLink() {
+ requestRichTextLink();
+ }
+
+ return createPortal(
+
+
+
+
+
+
+
+
,
+ document.body,
+ );
+}
+
+// ─── InlineCodeEditor ─────────────────────────────────────────────────────────
+// Covers the clicked code block with an editable version of the same UI.
+
+function InlineCodeEditor({
+ block,
+ rect,
+ onChange,
+ onClose,
+ onMove,
+ onDuplicate,
+ onDelete,
+ onSave,
+ saving,
+ index,
+ total,
+}: {
+ block: Extract;
+ rect: DOMRect;
+ onChange: (b: MdxBlock) => void;
+ onClose: () => void;
+ onMove: (dir: -1 | 1) => void;
+ onDuplicate: () => void;
+ onDelete: () => void;
+ onSave: () => void;
+ saving: boolean;
+ index: number;
+ total: number;
+}) {
+ const toolbarTop = Math.max(54, rect.top - 36);
+
+ const cover: React.CSSProperties = {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ minHeight: rect.height,
+ };
+
+ return createPortal(
+ <>
+ {/* Opaque cover exactly over the code block */}
+
+ {/* Re-use the existing CodeRenderer — it renders an editable figure */}
+
+
+
+
+
+ {/* Mini toolbar floating above */}
+
+
+
+
+
+
+
+
+
+
+ >,
+ document.body,
+ );
+}
+
+// ─── Theme popover ────────────────────────────────────────────────────────────
+
+function ThemePopover({
+ active,
+ onSelect,
+ onClose,
+}: {
+ active: string;
+ onSelect: (t: ThemeDef) => void;
+ onClose: () => void;
+}) {
+ const ref = useRef(null);
+ useEffect(() => {
+ function h(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
+ }
+ function k(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("mousedown", h);
+ document.addEventListener("keydown", k);
+ return () => {
+ document.removeEventListener("mousedown", h);
+ document.removeEventListener("keydown", k);
+ };
+ }, [onClose]);
+
+ return (
+
+ {THEMES.map((t) => (
+
onSelect(t)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") onSelect(t);
+ }}
+ >
+
+
{t.label}
+ {active === t.id &&
✓}
+
+ ))}
+
+ );
+}
+
+// ─── Floating sidebar editor ──────────────────────────────────────────────────
+
+function FloatingSidebarEditor({
+ item,
+ api,
+ onClose,
+}: {
+ item: { href: string; label: string; rect: DOMRect };
+ api: string;
+ onClose: () => void;
+}) {
+ const ref = useRef(null);
+ const [title, setTitle] = useState(item.label);
+ const [saving, setSaving] = useState(false);
+ const [status, setStatus] = useState("");
+
+ // Position near the sidebar item
+ const top = Math.min(item.rect.bottom + 6, window.innerHeight - 180);
+ const left = item.rect.right + 8;
+ const adjustedLeft = left + 300 > window.innerWidth ? Math.max(8, item.rect.left - 316) : left;
+
+ useEffect(() => {
+ let cleanup: (() => void) | undefined;
+ const tid = window.setTimeout(() => {
+ function outside(e: MouseEvent) {
+ if (ref.current?.contains(e.target as Node)) return;
+ onClose();
+ }
+ function key(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("mousedown", outside);
+ document.addEventListener("keydown", key);
+ cleanup = () => {
+ document.removeEventListener("mousedown", outside);
+ document.removeEventListener("keydown", key);
+ };
+ }, 120);
+ return () => {
+ window.clearTimeout(tid);
+ cleanup?.();
+ };
+ }, [onClose]);
+
+ async function save() {
+ setSaving(true);
+ setStatus("Saving…");
+ try {
+ const res = await fetch(createDevToolsUrl(api, "nav-item", item.href), {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
+ body: JSON.stringify({ href: item.href, title }),
+ });
+ const payload = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string };
+ if (!res.ok || !payload.ok)
+ throw new Error("error" in payload && payload.error ? payload.error : "Could not save.");
+ setStatus("Saved!");
+ setTimeout(() => onClose(), 700);
+ } catch (e) {
+ setStatus(e instanceof Error ? e.message : "Could not save.");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return createPortal(
+
+
+
+
+
+ Title
+
+
setTitle(e.currentTarget.value)}
+ placeholder="Page title"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ void save();
+ }
+ }}
+ />
+
+ {status && (
+
+ {status}
+
+ )}
+
+
+
,
+ document.body,
+ );
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+
+export function DocsDevTools({ api, pathname }: DocsDevToolsProps) {
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [page, setPage] = useState(null);
+ const [doc, setDoc] = useState(null);
+ const [draft, setDraft] = useState("");
+ const [status, setStatus] = useState("");
+ // Which block is currently selected for editing, and where the editor floats
+ const [activeBlockId, setActiveBlockId] = useState(null);
+ const [activeRect, setActiveRect] = useState(null);
+ const [themeId, setThemeId] = useState("default");
+ const [savedThemeId, setSavedThemeId] = useState("default");
+ const [themeVars, setThemeVars] = useState>({});
+ const [showThemes, setShowThemes] = useState(false);
+ const [showSource, setShowSource] = useState(false);
+ const [addPalette, setAddPalette] = useState<{ x: number; y: number } | null>(null);
+ const [sidebarItem, setSidebarItem] = useState<{
+ href: string;
+ label: string;
+ rect: DOMRect;
+ } | null>(null);
+ const addBtnRef = useRef(null);
+ const themeStyleRef = useRef(null);
+ // Always-fresh reference to doc so article click handler never captures stale closure
+ const docRef = useRef(null);
+ docRef.current = doc;
+ const mappedDocRef = useRef(null);
+ // Reference to the active DOM element — used for scroll-tracking rect updates
+ const activeElRef = useRef(null);
+ // Pre-built DOM element → loaded block id map.
+ const domBlockMapRef = useRef