diff --git a/examples/next/app/docs/page.mdx b/examples/next/app/docs/page.mdx index a2adb210..0ca4233d 100644 --- a/examples/next/app/docs/page.mdx +++ b/examples/next/app/docs/page.mdx @@ -5,9 +5,7 @@ description: A comprehensive authentication and authorization framework for Type icon: book --- -# Introduction - -Better Auth is a framework-agnostic, universal authentication and authorization framework for TypeScript. It provides a comprehensive set of features out of the box and includes a plugin ecosystem that simplifies adding advanced functionalities. +# Introduction intro You are reading the example docs entry page as an implementation agent. @@ -21,24 +19,16 @@ Prefer markdown reads over HTML scraping. For Next.js, `/docs.md`, `/docs/ page-level machine-readable entry points when reported by the spec. -## Features +###### Features: -- **Framework Agnostic** — Support for most popular frameworks -- **Email & Password** — Built-in support for secure email and password authentication -- **Account & Session Management** — Manage user accounts and sessions with ease -- **Built-In Rate Limiter** — Built-in rate limiter with custom rules -- **Automatic Database Management** — Automatic database management and migrations -- **Social Sign-on** — Multiple social sign-on providers -- **Plugin Ecosystem** — Even more capabilities with plugins +Better Auth is a framework-agnostic, universal authentication and authorization framework for TypeScript. It provides a comprehensive set of features out of the box and includes a plugin ecosystem that simplifies adding advanced functionalities. **lets do it.** someone here. --- -## Quick Start - Get up and running in under 5 minutes: ```bash title="terminal" -npm install better-auth +npm install demo - hello world ``` ```ts title="auth.ts" @@ -46,7 +36,7 @@ import { betterAuth } from "better-auth"; export const auth = betterAuth({ database: { - provider: "postgresql", + provider: "psg", url: process.env.DATABASE_URL, }, emailAndPassword: { @@ -57,8 +47,38 @@ export const auth = betterAuth({ --- -## Next Steps +## Next Steps: - Read the [Installation](/docs/installation) guide - Follow the [Get Started](/docs/getting-started) guide - Explore [Concepts](/docs/concepts) + + + Read the related guide + + + +Use this docs page to implement the feature. + + + +Write a helpful callout. + + + + + ```bash + npm install my-package + ``` + + + + ```bash + pnpm add my-package + ``` + + + + Content + + diff --git a/examples/next/docs.config.tsx b/examples/next/docs.config.tsx index f3358c1c..1b3562e2 100644 --- a/examples/next/docs.config.tsx +++ b/examples/next/docs.config.tsx @@ -76,6 +76,7 @@ const searchConfig: DocsSearchConfig | undefined = export default defineDocs({ entry: "docs", + devTools: true, ...(searchConfig ? { search: searchConfig } : {}), observability: { console: "debug", diff --git a/packages/docs/src/define-docs.ts b/packages/docs/src/define-docs.ts index 4425fb77..920ab89a 100644 --- a/packages/docs/src/define-docs.ts +++ b/packages/docs/src/define-docs.ts @@ -32,6 +32,7 @@ export function defineDocs(config: DocsConfig): DocsConfig { ai: config.ai, ordering: config.ordering, metadata: config.metadata, + devTools: config.devTools, og: config.og, changelog: config.changelog, apiReference: config.apiReference, diff --git a/packages/docs/src/index.ts b/packages/docs/src/index.ts index 676152ae..2dc7fe4f 100644 --- a/packages/docs/src/index.ts +++ b/packages/docs/src/index.ts @@ -206,6 +206,7 @@ export type { DocsNav, DocsTheme, DocsMetadata, + DevToolsConfig, OGConfig, OpenGraphImage, PageOpenGraph, diff --git a/packages/docs/src/types.ts b/packages/docs/src/types.ts index 0a18aeab..96ecf8ac 100644 --- a/packages/docs/src/types.ts +++ b/packages/docs/src/types.ts @@ -238,6 +238,19 @@ export interface DocsMetadata { twitterCard?: "summary" | "summary_large_image"; } +export interface DevToolsConfig { + /** + * Enables the built-in visual docs editor in development. + * + * When enabled, the docs UI renders a floating action button that can edit + * the current MDX page and apply changes back to the source file through the + * local docs API. + * + * @default false + */ + enabled?: boolean; +} + export interface OGConfig { enabled?: boolean; type?: "static" | "dynamic"; @@ -2618,6 +2631,13 @@ export interface DocsConfig { agent?: DocsAgentConfig; /** SEO metadata - separate from theme */ metadata?: DocsMetadata; + /** + * Built-in visual docs editor. + * + * Use `devTools: true` during local development to edit MDX pages with the + * floating docs editor and write changes back to the page source file. + */ + devTools?: boolean | DevToolsConfig; /** Open Graph image handling */ og?: OGConfig; } diff --git a/packages/fumadocs/src/colorful/index.ts b/packages/fumadocs/src/colorful/index.ts index 04bdd0c5..173d7d20 100644 --- a/packages/fumadocs/src/colorful/index.ts +++ b/packages/fumadocs/src/colorful/index.ts @@ -37,7 +37,6 @@ const ColorfulUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" }, }, }; diff --git a/packages/fumadocs/src/command-grid/index.ts b/packages/fumadocs/src/command-grid/index.ts index 061889dd..592eb3a7 100644 --- a/packages/fumadocs/src/command-grid/index.ts +++ b/packages/fumadocs/src/command-grid/index.ts @@ -41,7 +41,6 @@ const CommandGridUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" as const }, }, }; diff --git a/packages/fumadocs/src/concrete/index.ts b/packages/fumadocs/src/concrete/index.ts index d9500d48..f058d5f2 100644 --- a/packages/fumadocs/src/concrete/index.ts +++ b/packages/fumadocs/src/concrete/index.ts @@ -41,7 +41,6 @@ const ConcreteUIDefaults = { Callout: { variant: "outline", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "View doc", showIndicator: false }, - Tabs: { style: "default" as const }, }, }; diff --git a/packages/fumadocs/src/darkbold/index.ts b/packages/fumadocs/src/darkbold/index.ts index ef83ff07..b1ee02e8 100644 --- a/packages/fumadocs/src/darkbold/index.ts +++ b/packages/fumadocs/src/darkbold/index.ts @@ -37,7 +37,6 @@ const DarkBoldUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" }, }, }; diff --git a/packages/fumadocs/src/darksharp/index.ts b/packages/fumadocs/src/darksharp/index.ts index 63154d13..bdcb8248 100644 --- a/packages/fumadocs/src/darksharp/index.ts +++ b/packages/fumadocs/src/darksharp/index.ts @@ -56,7 +56,6 @@ const DarksharpUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" }, }, }; diff --git a/packages/fumadocs/src/default/index.ts b/packages/fumadocs/src/default/index.ts index a8af5ad5..73e5a9a3 100644 --- a/packages/fumadocs/src/default/index.ts +++ b/packages/fumadocs/src/default/index.ts @@ -55,7 +55,6 @@ const DefaultUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" }, }, }; diff --git a/packages/fumadocs/src/devtools.tsx b/packages/fumadocs/src/devtools.tsx new file mode 100644 index 00000000..27140451 --- /dev/null +++ b/packages/fumadocs/src/devtools.tsx @@ -0,0 +1,4289 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type MdxBlock = + | { id: string; type: "heading"; level: number; text: string } + | { id: string; type: "paragraph"; content: string } + | { id: string; type: "callout"; variant: string; title: string; content: string } + | { id: string; type: "code"; language: string; title: string; code: string } + | { id: string; type: "tabs"; tabs: Array<{ label: string; content: string }> } + | { + id: string; + type: "hoverlink"; + href: string; + title: string; + description: string; + label: string; + } + | { id: string; type: "prompt"; title: string; content: string } + | { id: string; type: "raw"; content: string }; + +interface ParsedMdxDocument { + prefix: string; + blocks: MdxBlock[]; +} + +interface DevToolsPagePayload { + requestedPath: string; + relativePath: string; + content: string; + lastModified: string; +} + +interface DocsDevToolsProps { + api: string; + pathname: string; +} + +// ─── ID generation ──────────────────────────────────────────────────────────── + +let idCounter = 0; +function createId(prefix = "block"): string { + idCounter += 1; + return `${prefix}-${Date.now().toString(36)}-${idCounter.toString(36)}`; +} + +// ─── Parse utilities ────────────────────────────────────────────────────────── + +function normalizeSource(s: string) { + return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +function splitPrefix(source: string): { prefix: string; body: string } { + const lines = normalizeSource(source).split("\n"); + const pre: string[] = []; + let i = 0; + if (lines[i]?.trim() === "---") { + pre.push(lines[i]); + i++; + while (i < lines.length) { + pre.push(lines[i]); + const done = lines[i]?.trim() === "---"; + i++; + if (done) break; + } + } + while (i < lines.length) { + const line = lines[i] ?? ""; + const t = line.trim(); + if (t === "") { + pre.push(line); + i++; + continue; + } + if (/^(import|export)\b/.test(t)) { + pre.push(line); + i++; + while ( + i < lines.length && + lines[i]?.trim() !== "" && + !/[;}]$/.test(lines[i - 1]?.trim() ?? "") + ) { + pre.push(lines[i] ?? ""); + i++; + } + continue; + } + break; + } + return { prefix: pre.join("\n").trimEnd(), body: lines.slice(i).join("\n").trim() }; +} + +function getAttribute(src: string, name: string): string { + const m = src.match(new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`)); + return m?.[1] ?? m?.[2] ?? ""; +} + +function parseStringArray(src: string) { + return src + .split(",") + .map((v) => v.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); +} + +function captureUntil(lines: string[], start: number, closing: RegExp) { + const out: string[] = []; + for (let i = start; i < lines.length; i++) { + out.push(lines[i] ?? ""); + if (closing.test(lines[i] ?? "")) return { content: out.join("\n"), end: i + 1 }; + } + return { content: out.join("\n"), end: lines.length }; +} + +function isSpecialStart(line: string) { + const t = line.trim(); + return /^#{1,6}\s+/.test(t) || t.startsWith("```") || /^<[A-Z][\w.:-]*(\s|>|\/>)/.test(t); +} + +function parseCodeBlock(lines: string[], start: number): { block: MdxBlock; end: number } { + const opening = lines[start]?.trim() ?? "```"; + const info = opening.replace(/^```/, "").trim(); + const title = getAttribute(info, "title"); + const language = info.split(/\s+/, 1)[0]?.replace(/title=.*/, "") ?? ""; + const content: string[] = []; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if ((lines[i] ?? "").trim().startsWith("```")) { + end = i + 1; + break; + } + content.push(lines[i] ?? ""); + } + return { + block: { id: createId("code"), type: "code", language, title, code: content.join("\n") }, + end, + }; +} + +function parseCalloutBlock(raw: string): MdxBlock { + const op = raw.split("\n", 1)[0] ?? ""; + const inner = raw + .replace(/^]*>\s*/s, "") + .replace(/\s*<\/Callout>\s*$/s, "") + .trim(); + return { + id: createId("callout"), + type: "callout", + variant: getAttribute(op, "type") || "info", + title: getAttribute(op, "title"), + content: inner || "Write the callout content here.", + }; +} + +function parsePromptBlock(raw: string): MdxBlock { + const op = raw.split("\n", 1)[0] ?? ""; + const inner = raw + .replace(/^]*>\s*/s, "") + .replace(/\s*<\/Prompt>\s*$/s, "") + .trim(); + return { + id: createId("prompt"), + type: "prompt", + title: getAttribute(op, "title") || "Prompt", + content: inner || getAttribute(op, "prompt") || "Write the prompt here.", + }; +} + +function parseHoverLinkBlock(raw: string): MdxBlock { + const op = raw.split("\n", 1)[0] ?? ""; + const label = raw + .replace(/^]*>\s*/s, "") + .replace(/\s*<\/HoverLink>\s*$/s, "") + .trim(); + return { + id: createId("hoverlink"), + type: "hoverlink", + href: getAttribute(op, "href") || "#", + title: getAttribute(op, "title") || "Linked page", + description: getAttribute(op, "description") || "Describe the linked page.", + label: label || "Open the linked page", + }; +} + +function parseTabsBlock(raw: string): MdxBlock { + const op = raw.split("\n", 1)[0] ?? ""; + const im = op.match(/items\s*=\s*\{\s*\[([^\]]*)\]\s*\}/); + const items = im ? parseStringArray(im[1] ?? "") : []; + const tabs: Array<{ label: string; content: string }> = []; + const tp = /]*>([\s\S]*?)<\/Tab>/g; + let m: RegExpExecArray | null; + while ((m = tp.exec(raw)) !== null) + tabs.push({ label: m[1] ?? "Tab", content: (m[2] ?? "").trim() || "Tab content" }); + if (tabs.length === 0) + for (const item of items.length > 0 ? items : ["First", "Second"]) + tabs.push({ label: item, content: "Tab content" }); + return { id: createId("tabs"), type: "tabs", tabs }; +} + +function parseRawJsxBlock(lines: string[], start: number): { block: MdxBlock; end: number } { + const firstLine = lines[start] ?? ""; + if (firstLine.trim().endsWith("/>")) + return { block: { id: createId("raw"), type: "raw", content: firstLine }, end: start + 1 }; + const tag = firstLine.trim().match(/^<([A-Z][\w.:-]*)\b/)?.[1]; + if (tag) { + const closing = new RegExp(``); + const cap = captureUntil(lines, start, closing); + return { block: { id: createId("raw"), type: "raw", content: cap.content }, end: cap.end }; + } + return { block: { id: createId("raw"), type: "raw", content: firstLine }, end: start + 1 }; +} + +function parseMdxDocument(source: string): ParsedMdxDocument { + const { prefix, body } = splitPrefix(source); + const lines = body.split("\n"); + const blocks: MdxBlock[] = []; + let i = 0; + while (i < lines.length) { + const line = lines[i] ?? ""; + const t = line.trim(); + if (t === "") { + i++; + continue; + } + const h = t.match(/^(#{1,6})\s+(.+)$/); + if (h) { + blocks.push({ + id: createId("heading"), + type: "heading", + level: h[1]?.length ?? 2, + text: h[2] ?? "Heading", + }); + i++; + continue; + } + if (t.startsWith("```")) { + const p = parseCodeBlock(lines, i); + blocks.push(p.block); + i = p.end; + continue; + } + if (t.startsWith("/); + blocks.push(parseCalloutBlock(c.content)); + i = c.end; + continue; + } + if (t.startsWith("/); + blocks.push(parseTabsBlock(c.content)); + i = c.end; + continue; + } + if (t.startsWith("/); + blocks.push(parsePromptBlock(c.content)); + i = c.end; + continue; + } + if (t.startsWith("/); + blocks.push(parseHoverLinkBlock(c.content)); + i = c.end; + continue; + } + if (/^<[A-Z][\w.:-]*(\s|>|\/>)/.test(t)) { + const p = parseRawJsxBlock(lines, i); + blocks.push(p.block); + i = p.end; + continue; + } + const para: string[] = [line]; + i++; + while (i < lines.length) { + const next = lines[i] ?? ""; + if (next.trim() === "" || isSpecialStart(next)) break; + para.push(next); + i++; + } + blocks.push({ id: createId("paragraph"), type: "paragraph", content: para.join("\n").trim() }); + } + if (blocks.length === 0) + blocks.push({ id: createId("paragraph"), type: "paragraph", content: "Start writing…" }); + return { prefix, blocks }; +} + +// ─── Serialize ──────────────────────────────────────────────────────────────── + +function q(v: string) { + return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} +function ind(v: string, n = 2) { + const p = " ".repeat(n); + return v + .split("\n") + .map((l) => (l.trim() ? `${p}${l}` : l)) + .join("\n"); +} + +function serializeBlock(block: MdxBlock): string { + if (block.type === "heading") + return `${"#".repeat(Math.min(6, Math.max(1, block.level)))} ${block.text.trim() || "Heading"}`; + if (block.type === "paragraph") return block.content.trim(); + if (block.type === "callout") { + const t = block.title.trim() ? ` title="${q(block.title.trim())}"` : ""; + return `\n${block.content.trim()}\n`; + } + if (block.type === "code") { + const t = block.title.trim() ? ` title="${q(block.title.trim())}"` : ""; + return `\`\`\`${block.language.trim()}${t}\n${block.code.replace(/\s+$/g, "")}\n\`\`\``; + } + if (block.type === "tabs") { + const tabs = block.tabs.length > 0 ? block.tabs : [{ label: "First", content: "Tab content" }]; + const items = tabs.map((t) => `"${q(t.label || "Tab")}"`).join(", "); + const rendered = tabs + .map( + (t) => + ` \n${ind(t.content.trim() || "Tab content", 4)}\n `, + ) + .join("\n\n"); + return `\n${rendered}\n`; + } + if (block.type === "hoverlink") + return `\n${ind(block.label || "Open the linked page", 2)}\n`; + if (block.type === "prompt") { + const t = block.title.trim() ? ` title="${q(block.title.trim())}"` : ""; + return `\n${block.content.trim() || "Write the prompt here."}\n`; + } + return block.content.trim(); +} + +function serializeMdxDocument(doc: ParsedMdxDocument): string { + const body = doc.blocks.map(serializeBlock).filter(Boolean).join("\n\n"); + return [doc.prefix.trim(), body.trim()].filter(Boolean).join("\n\n").trimEnd() + "\n"; +} + +function createBlock(type: MdxBlock["type"]): MdxBlock { + if (type === "heading") return { id: createId("heading"), type, level: 2, text: "New heading" }; + if (type === "callout") + return { + id: createId("callout"), + type, + variant: "note", + title: "", + content: "Write a helpful callout.", + }; + if (type === "code") + return { + id: createId("code"), + type, + language: "ts", + title: "example.ts", + code: "export const value = true;", + }; + if (type === "tabs") + return { + id: createId("tabs"), + type, + tabs: [ + { label: "npm", content: "```bash\nnpm install my-package\n```" }, + { label: "pnpm", content: "```bash\npnpm add my-package\n```" }, + ], + }; + if (type === "hoverlink") + return { + id: createId("hoverlink"), + type, + href: "/docs", + title: "Related guide", + description: "A useful related documentation page.", + label: "Read the related guide", + }; + if (type === "prompt") + return { + id: createId("prompt"), + type, + title: "Try this prompt", + content: "Use this docs page to implement the feature.", + }; + if (type === "raw") return { id: createId("raw"), type, content: "" }; + return { id: createId("paragraph"), type: "paragraph", content: "Write a paragraph." }; +} + +function createDevToolsUrl( + api: string, + mode: "page" | "publish" | "theme" | "nav-item", + pathname: string, +) { + const url = new URL(api, window.location.origin); + url.searchParams.set("devtools", mode); + if (mode === "page") url.searchParams.set("path", pathname); + return `${url.pathname}${url.search}`; +} + +// ─── DOM → block-id map ────────────────────────────────────────────────────── +// Built once after the MDX doc is loaded. Each direct child of [data-dt-content] +// is assigned to exactly one parsed block id. We use per-type cursors so that: +// • h1-h6 elements → heading blocks (text match preferred, cursor fallback) +// • p / ul / ol / hr / blockquote → paragraph blocks (our parser treats all +// of these as paragraphs — lists, HR, blockquotes are all parsed as paragraph) +// • figure → code blocks +// • div/aside/section → callout / tabs / prompt / hoverlink / raw +// identified by DOM attributes/classes, never by position alone + +function paragraphRenderedElementCount(content: string): number { + const lines = content + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + let count = 0; + let prev: "normal" | "ul" | "ol" | "blockquote" | "hr" | null = null; + + for (const line of lines) { + const kind = /^[-*+]\s+/.test(line) + ? "ul" + : /^\d+[.)]\s+/.test(line) + ? "ol" + : /^>\s?/.test(line) + ? "blockquote" + : /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line) + ? "hr" + : "normal"; + + if (kind !== prev || kind === "hr") count += 1; + prev = kind; + } + + return Math.max(1, count); +} + +function buildDomBlockMap(contentEl: HTMLElement, blocks: MdxBlock[]): Map { + const children = Array.from(contentEl.children) as HTMLElement[]; + const map = new Map(); + + // Group parser blocks by type, keeping their parsed order. + const byType: Record = {}; + blocks.forEach((block) => { + (byType[block.type] ??= []).push(block); + }); + + const cursors: Record = {}; + const paragraphDomIds = blocks.flatMap((block) => + block.type === "paragraph" + ? Array.from({ length: paragraphRenderedElementCount(block.content) }, () => block.id) + : [], + ); + let paragraphDomCursor = 0; + + function nextParagraph(): string | null { + const id = paragraphDomIds[paragraphDomCursor]; + paragraphDomCursor += 1; + return id ?? null; + } + + function next(type: string): string | null { + const list = byType[type]; + if (!list) return null; + const c = cursors[type] ?? 0; + if (c >= list.length) return null; + cursors[type] = c + 1; + return list[c]!.id; + } + + for (const el of children) { + const tag = el.tagName.toLowerCase(); + let blockId: string | null = null; + + if (/^h[1-6]$/.test(tag)) { + // Prefer text match so invisible-render components (Agent etc.) don't shift indices + const headings = byType["heading"]; + const c = cursors["heading"] ?? 0; + const elText = (el.textContent ?? "").trim(); + const ahead = headings?.slice(c) ?? []; + const matchOff = ahead.findIndex( + (block) => (block as Extract).text.trim() === elText, + ); + if (matchOff >= 0) { + blockId = ahead[matchOff]!.id; + cursors["heading"] = c + matchOff + 1; + } else { + blockId = next("heading"); + } + } else if ( + tag === "p" || + tag === "ul" || + tag === "ol" || + tag === "hr" || + tag === "blockquote" + ) { + // All rendered as paragraph blocks by our parser + blockId = nextParagraph(); + } else if (tag === "figure") { + blockId = next("code"); + } else if (tag === "div" || tag === "aside" || tag === "section" || tag === "nav") { + // Detect component type from DOM shape + const role = el.getAttribute("role") ?? ""; + const cls = el.className ?? ""; + if (role === "note" || cls.includes("callout")) { + blockId = next("callout"); + } else if (el.querySelector("[role=tablist], [data-radix-collection-item]")) { + blockId = next("tabs"); + } else if (el.querySelector(".fd-prompt-header") || cls.includes("fd-prompt")) { + blockId = next("prompt"); + } else if ( + el.querySelector("a[href]") && + (byType["hoverlink"]?.length ?? 0) > (cursors["hoverlink"] ?? 0) + ) { + blockId = next("hoverlink"); + } else { + blockId = next("raw"); + } + } + + if (blockId !== null) map.set(el, blockId); + } + + return map; +} + +// ─── Catalogues ─────────────────────────────────────────────────────────────── + +const BLOCK_CATALOGUE: Array<{ + type: MdxBlock["type"]; + label: string; + icon: string; + accent: string; + desc: string; +}> = [ + { type: "paragraph", label: "Paragraph", icon: "¶", accent: "#6366f1", desc: "Plain text" }, + { type: "heading", label: "Heading", icon: "H", accent: "#7c3aed", desc: "H1 – H6" }, + { type: "callout", label: "Callout", icon: "◈", accent: "#f59e0b", desc: "Note / warning" }, + { type: "code", label: "Code", icon: "", accent: "#10b981", desc: "Fenced block" }, + { type: "tabs", label: "Tabs", icon: "⊟", accent: "#3b82f6", desc: "Tabbed content" }, + { type: "hoverlink", label: "HoverLink", icon: "↗", accent: "#ec4899", desc: "Card link" }, + { type: "prompt", label: "Prompt", icon: "◉", accent: "#06b6d4", desc: "Copyable prompt" }, + { type: "raw", label: "Raw MDX", icon: "{}", accent: "#64748b", desc: "Custom component" }, +]; + +interface ThemeDef { + id: string; + label: string; + accent: string; + bg: string; + fg: string; + border: string; + muted: string; + dark: boolean; +} + +const THEMES: ThemeDef[] = [ + { + id: "default", + label: "Default", + accent: "#6366f1", + bg: "#ffffff", + fg: "#0f172a", + border: "#e2e8f0", + muted: "#f1f5f9", + dark: false, + }, + { + id: "darksharp", + label: "Dark Sharp", + accent: "#e2e8f0", + bg: "#0a0a0a", + fg: "#f8fafc", + border: "#1e293b", + muted: "#111827", + dark: true, + }, + { + id: "ledger", + label: "Ledger", + accent: "#5f6cf6", + bg: "#f6f8fb", + fg: "#30364a", + border: "#dbe3ef", + muted: "#eef3fb", + dark: false, + }, + { + id: "concrete", + label: "Concrete", + accent: "#171717", + bg: "#f5f5f4", + fg: "#171717", + border: "#d4d0cb", + muted: "#e7e5e4", + dark: false, + }, + { + id: "hardline", + label: "Hardline", + accent: "#ef4444", + bg: "#fafaf9", + fg: "#0c0a09", + border: "#e7e5e4", + muted: "#f5f5f4", + dark: false, + }, + { + id: "greentree", + label: "Green Tree", + accent: "#22c55e", + bg: "#f0fdf4", + fg: "#052e16", + border: "#bbf7d0", + muted: "#dcfce7", + dark: false, + }, + { + id: "shiny", + label: "Shiny", + accent: "#ec4899", + bg: "#0f0a1a", + fg: "#f0e6ff", + border: "#3b1f5e", + muted: "#1a0e30", + dark: true, + }, + { + id: "colorful", + label: "Colorful", + accent: "#f59e0b", + bg: "#fffbeb", + fg: "#1c1917", + border: "#fde68a", + muted: "#fef3c7", + dark: false, + }, + { + id: "pixel-border", + label: "Pixel Border", + accent: "#3b82f6", + bg: "#0f172a", + fg: "#f1f5f9", + border: "#1e3a5f", + muted: "#1e293b", + dark: true, + }, + { + id: "command-grid", + label: "Command Grid", + accent: "#8b5cf6", + bg: "#fafafa", + fg: "#18181b", + border: "#e4e4e7", + muted: "#f4f4f5", + dark: false, + }, + { + id: "darkbold", + label: "Dark Bold", + accent: "#ffffff", + bg: "#0a0a0a", + fg: "#ededed", + border: "#333333", + muted: "#1a1a1a", + dark: true, + }, +]; + +// Matches the actual Callout component variant styles exactly +const CALLOUT_VARIANTS: Record< + string, + { + iconEl: () => React.ReactElement; + iconClass: string; + titleClass: string; + border: string; + bg: string; + label: string; + } +> = { + note: { + iconEl: () => ( + + ), + iconClass: "text-black/50 dark:text-white/50", + titleClass: "text-black/70 dark:text-white/70", + border: + "border-black/10 dark:border-white/10 border-l-4 border-l-black/25 dark:border-l-white/25", + bg: "bg-black/[0.03] dark:bg-white/[0.025]", + label: "Note", + }, + warning: { + iconEl: () => ( + + ), + iconClass: "text-black/55 dark:text-white/55", + titleClass: "text-black/75 dark:text-white/75", + border: + "border-black/10 dark:border-white/10 border-l-4 border-l-black/35 dark:border-l-white/35", + bg: "bg-black/[0.04] dark:bg-white/[0.035]", + label: "Warning", + }, + tip: { + iconEl: () => ( + + ), + iconClass: "text-black/50 dark:text-white/50", + titleClass: "text-black/70 dark:text-white/70", + border: + "border-black/10 dark:border-white/10 border-l-4 border-l-black/30 dark:border-l-white/30", + bg: "bg-black/[0.03] dark:bg-white/[0.025]", + label: "Tip", + }, + important: { + iconEl: () => ( + + ), + iconClass: "text-black/55 dark:text-white/55", + titleClass: "text-black/75 dark:text-white/75", + border: + "border-black/10 dark:border-white/10 border-l-4 border-l-black/35 dark:border-l-white/35", + bg: "bg-black/[0.04] dark:bg-white/[0.035]", + label: "Important", + }, + caution: { + iconEl: () => ( + + ), + iconClass: "text-black/60 dark:text-white/60", + titleClass: "text-black/80 dark:text-white/80", + border: + "border-black/15 dark:border-white/15 border-l-4 border-l-black/40 dark:border-l-white/40", + bg: "bg-black/[0.05] dark:bg-white/[0.045]", + label: "Caution", + }, + // parser aliases + info: { + iconEl: () => ( + + ), + iconClass: "text-black/50 dark:text-white/50", + titleClass: "text-black/70 dark:text-white/70", + border: + "border-black/10 dark:border-white/10 border-l-4 border-l-black/25 dark:border-l-white/25", + bg: "bg-black/[0.03] dark:bg-white/[0.025]", + label: "Info", + }, + error: { + iconEl: () => ( + + ), + iconClass: "text-black/60 dark:text-white/60", + titleClass: "text-black/80 dark:text-white/80", + border: + "border-black/15 dark:border-white/15 border-l-4 border-l-black/40 dark:border-l-white/40", + bg: "bg-black/[0.05] dark:bg-white/[0.045]", + label: "Error", + }, +}; + +// ─── CSS ────────────────────────────────────────────────────────────────────── + +const CSS = ` +nextjs-portal{pointer-events:none!important} + +/* ─ reset inside devtools ─ */ +.dt,.dt *{box-sizing:border-box;-webkit-font-smoothing:antialiased} +.dt button,.dt input,.dt select,.dt textarea{font-family:inherit} + +/* ─ trigger ─ */ +.dt-trigger{ + position:fixed;left:16px;bottom:16px;z-index:2147483000; + height:34px;padding:0 13px 0 10px; + border:1px solid rgba(255,255,255,0.13);border-radius:999px; + background:rgba(12,12,16,0.93);color:#fff; + box-shadow:0 8px 28px rgba(0,0,0,0.45);backdrop-filter:blur(16px); + display:inline-flex;align-items:center;gap:7px; + font-size:12px;font-weight:700;cursor:pointer; + transition:transform .12s,box-shadow .12s; +} +.dt-trigger:hover{transform:translateY(-1px);box-shadow:0 12px 36px rgba(0,0,0,0.55)} +.dt-dot{ + width:7px;height:7px;border-radius:50%; + background:var(--color-fd-primary,#6366f1); + box-shadow:0 0 0 3px color-mix(in srgb,var(--color-fd-primary,#6366f1) 22%,transparent); + animation:dtpulse 2.4s ease-in-out infinite; +} +@keyframes dtpulse{0%,100%{opacity:1}50%{opacity:.45}} + +/* ─ toolbar ─ */ +.dt-bar{ + position:fixed;top:0;left:0;right:0;height:50px;z-index:2147483002; + border-bottom:1px solid rgba(255,255,255,0.07); + background:rgba(9,9,13,0.95);backdrop-filter:blur(20px); + display:flex;align-items:center;gap:0;pointer-events:auto; +} +.dt-bar-logo{ + display:flex;align-items:center;gap:8px; + padding:0 14px;height:100%; + border-right:1px solid rgba(255,255,255,0.07);flex-shrink:0; +} +.dt-bar-emblem{ + width:22px;height:22px;border-radius:5px; + background:linear-gradient(135deg,#6366f1,#8b5cf6); + display:grid;place-items:center;font-size:10px;font-weight:900;color:#fff; +} +.dt-bar-wordmark{font-size:12.5px;font-weight:750;color:#fff;white-space:nowrap} +.dt-bar-file{ + flex:1;min-width:0;padding:0 14px; + display:flex;flex-direction:column;gap:1px; +} +.dt-bar-path{font-size:11.5px;color:rgba(255,255,255,0.45);font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dt-bar-status{font-size:10.5px;color:rgba(255,255,255,0.28);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dt-bar-actions{ + display:flex;align-items:center;gap:5px; + padding:0 10px;height:100%; + border-left:1px solid rgba(255,255,255,0.07);flex-shrink:0; +} + +/* ─ toolbar buttons ─ */ +.dt-btn{ + height:28px;padding:0 10px; + border:1px solid rgba(255,255,255,0.11);border-radius:6px; + background:rgba(255,255,255,0.06);color:rgba(255,255,255,0.8); + font-size:11.5px;font-weight:650;cursor:pointer; + display:inline-flex;align-items:center;gap:5px; + transition:border-color .12s,background .12s,color .12s;white-space:nowrap; +} +.dt-btn:hover{border-color:rgba(255,255,255,0.22);background:rgba(255,255,255,0.11);color:#fff} +.dt-btn:disabled{opacity:.32;cursor:not-allowed} +.dt-btn[data-primary]{background:var(--color-fd-primary,#6366f1);border-color:var(--color-fd-primary,#6366f1);color:#fff} +.dt-btn[data-primary]:hover{filter:brightness(1.1)} +.dt-btn[data-active]{background:rgba(255,255,255,0.13);border-color:rgba(255,255,255,0.2);color:#fff} +.dt-btn-sep{width:1px;height:18px;background:rgba(255,255,255,0.08);margin:0 2px;flex-shrink:0} + +/* ─ theme popover ─ */ +.dt-theme-pop{ + position:fixed;top:54px;right:10px;z-index:2147483003; + width:220px; + border:1px solid rgba(255,255,255,0.1);border-radius:10px; + background:rgba(10,10,14,0.97);backdrop-filter:blur(20px); + padding:6px; + box-shadow:0 16px 48px rgba(0,0,0,0.55); +} +.dt-theme-item{ + display:flex;align-items:center;gap:9px; + padding:7px 9px;border-radius:7px;cursor:pointer; + transition:background .1s; +} +.dt-theme-item:hover{background:rgba(255,255,255,0.07)} +.dt-theme-item[data-active]{background:rgba(255,255,255,0.1)} +.dt-theme-dots{display:flex;gap:3px;flex-shrink:0} +.dt-theme-dot{width:10px;height:10px;border-radius:50%} +.dt-theme-name{font-size:12px;font-weight:600;color:rgba(255,255,255,0.8);flex:1} +.dt-theme-tick{color:#6366f1;font-size:12px;flex-shrink:0} + +/* ─ article editing mode — only outlines, zero layout change ─ */ +/* Uses html attribute (not article attribute) to avoid React reconciliation mismatch */ +html[data-docs-devtools-open="true"] [data-dt-content] > *:not([data-dt-ui]){ + cursor:pointer; + outline:2px dashed transparent; + outline-offset:5px; + transition:outline-color .12s; +} +html[data-docs-devtools-open="true"] [data-dt-content] > *:not([data-dt-ui]):hover{ + outline-color:color-mix(in srgb,var(--color-fd-primary,#6366f1) 38%,transparent); +} +html[data-docs-devtools-open="true"] [data-dt-content] > [data-dt-deleted="true"]{ + display:none!important; +} + +/* ─ inline mini toolbar (appears above heading / paragraph editors) ─ */ +.dt-inline-toolbar{ + position:fixed;z-index:2147483002; + display:inline-flex;align-items:center;gap:4px; + background:color-mix(in srgb,var(--color-fd-background,#fff) 94%,#0f172a 6%); + color:var(--color-fd-foreground,#0f172a); + backdrop-filter:blur(16px); + border:1px solid color-mix(in srgb,var(--color-fd-border,#e2e8f0) 75%,transparent); + border-radius:10px; + padding:4px; + box-shadow:0 18px 42px rgba(15,23,42,0.18),0 1px 0 rgba(255,255,255,0.55) inset; +} +.dark .dt-inline-toolbar{ + background:rgba(9,9,13,0.94); + color:#fff; + border-color:rgba(255,255,255,0.12); + box-shadow:0 18px 42px rgba(0,0,0,0.42),0 1px 0 rgba(255,255,255,0.08) inset; +} +.dt-inline-toolbar-group{display:inline-flex;align-items:center;gap:3px} +.dt-inline-tb-btn{ + min-width:28px;height:26px;padding:0 7px; + border:1px solid transparent; + background:transparent; + color:var(--color-fd-muted-foreground,#64748b); + font-size:11px;font-weight:750;cursor:pointer; + display:grid;place-items:center;border-radius:7px; + transition:color .1s,background .1s,border-color .1s,transform .1s; +} +.dt-inline-tb-btn:hover{ + color:var(--color-fd-foreground,#0f172a); + background:color-mix(in srgb,var(--color-fd-primary,#6366f1) 9%,transparent); + border-color:color-mix(in srgb,var(--color-fd-primary,#6366f1) 28%,transparent); +} +.dark .dt-inline-tb-btn{color:rgba(255,255,255,0.76)} +.dark .dt-inline-tb-btn:hover{color:#fff;background:rgba(255,255,255,0.1);border-color:rgba(255,255,255,0.12)} +.dt-inline-tb-btn:disabled{opacity:.25;cursor:not-allowed} +.dt-inline-tb-btn[data-active]{background:var(--color-fd-primary,#6366f1);color:#fff} +.dt-inline-tb-btn[data-danger]:hover{color:#ef4444;background:rgba(239,68,68,0.12)} +.dt-inline-tb-sep{width:1px;height:18px;background:var(--color-fd-border,#e2e8f0);margin:0 2px;flex-shrink:0} +.dark .dt-inline-tb-sep{background:rgba(255,255,255,0.12)} +.dt-inline-save-btn{ + display:inline-flex;align-items:center;gap:5px; + height:26px;padding:0 9px; +} +.dt-inline-save-btn span{font-size:11px;font-weight:750} + +/* ─ sidebar editing mode ─ */ +html[data-docs-devtools-open="true"] #nd-sidebar a{ + cursor:pointer; + position:relative; +} +html[data-docs-devtools-open="true"] #nd-sidebar a::after{ + content:"✎"; + position:absolute; + right:4px; + top:50%; + transform:translateY(-50%); + font-size:10px; + opacity:0; + transition:opacity .1s; + color:var(--color-fd-primary,#6366f1); +} +html[data-docs-devtools-open="true"] #nd-sidebar a:hover::after{ + opacity:0.5; +} +/* Active block highlight rendered as a React-controlled fixed overlay (no DOM mutation) */ +.dt-active-highlight{ + position:fixed;pointer-events:none;z-index:2147483000; + outline:2px dashed var(--color-fd-primary,#6366f1); + outline-offset:5px;border-radius:3px; +} + +/* Inline editor overlay — sits over the original block for direct text editing */ +.dt-inline-cover{ + position:fixed;z-index:2147483001; + background:var(--color-fd-background,#fff); + box-sizing:border-box; + border-radius:6px; + box-shadow:0 0 0 6px var(--color-fd-background,#fff); +} +.dt-inline-input,.dt-inline-ta{ + display:block;width:100%;background:transparent; + border:none;outline:none;resize:none; + padding:0;margin:0; + font:inherit;color:inherit; +} +/* WYSIWYG inline formatting inside the paragraph overlay */ +.dt-inline-cover strong{font-weight:bold} +.dt-inline-cover em{font-style:italic} +.dt-inline-cover code{ + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:0.875em; + background:rgba(0,0,0,0.07); + padding:0.1em 0.3em;border-radius:3px; +} +.dt-inline-cover a,.dt-editable a{ + color:var(--color-fd-primary,#6366f1); + text-decoration:underline; + text-underline-offset:2px; + cursor:pointer; +} +.dt-inline-cover p,.dt-editable p{margin:0} +.dt-inline-cover ul,.dt-inline-cover ol,.dt-editable ul,.dt-editable ol{ + margin:.35em 0 0; + padding-left:1.35em; +} +.dt-inline-cover li,.dt-editable li{margin:.15em 0} +/* Remove figure margin when CodeRenderer is placed inside the inline cover */ +.dt-inline-cover .dt-code-fig{margin:0!important} + +/* ─ floating block editor (appears near clicked block) ─ */ +.dt-float-editor{ + position:fixed;z-index:2147483003; + background:var(--color-fd-background,#fff); + border:1px solid color-mix(in srgb,var(--color-fd-border,#e2e8f0) 86%,transparent); + border-radius:10px; + box-shadow:0 22px 60px rgba(15,23,42,0.18),0 2px 10px rgba(15,23,42,0.08); + width:min(480px,90vw); + max-height:72vh; + overflow-y:auto; + display:flex;flex-direction:column; +} +.dt-float-editor-hdr{ + display:flex;align-items:center;gap:4px; + padding:9px 10px 7px; + border-bottom:1px solid var(--color-fd-border,#e2e8f0); + flex-shrink:0; + position:sticky;top:0; + background:var(--color-fd-background,#fff); + z-index:1; +} +.dt-float-editor-type{ + font-size:10px;font-weight:850;text-transform:uppercase;letter-spacing:0; + display:inline-flex;align-items:center;gap:3px;flex-shrink:0; +} +.dt-float-editor-body{padding:12px 14px} + +/* ─ separator ─ */ +.dt-float-sep{width:1px;height:14px;background:var(--color-fd-border,#e2e8f0);margin:0 2px;flex-shrink:0} + +/* ─ float action button ─ */ +.dt-float-btn{ + width:26px;height:24px;border:1px solid transparent;background:transparent; + color:var(--color-fd-muted-foreground,#64748b);cursor:pointer; + display:grid;place-items:center;border-radius:7px; + transition:color .1s,background .1s,border-color .1s; +} +.dt-float-btn:hover{ + color:var(--color-fd-foreground,#0f172a); + background:var(--color-fd-muted,#f1f5f9); + border-color:var(--color-fd-border,#e2e8f0); +} +.dt-float-btn:disabled{opacity:.25;cursor:not-allowed} +.dt-float-btn[data-danger]:hover{color:#ef4444;background:rgba(239,68,68,0.08)} +.dt-float-save-btn{ + height:24px;padding:0 10px; + border:1px solid var(--color-fd-primary,#6366f1); + background:var(--color-fd-primary,#6366f1); + color:#fff;border-radius:7px; + font-size:11px;font-weight:750; + display:inline-flex;align-items:center;gap:5px;cursor:pointer; + transition:filter .1s,opacity .1s; +} +.dt-float-save-btn:hover{filter:brightness(1.06)} +.dt-float-save-btn:disabled{opacity:.45;cursor:not-allowed;filter:none} + +/* ─ insert (+) button ─ */ +.dt-insert-btn{ + width:20px;height:20px;border-radius:50%; + border:1.5px solid var(--color-fd-border,#e2e8f0); + background:var(--color-fd-background,#fff); + color:var(--color-fd-muted-foreground,#94a3b8); + cursor:pointer; + display:grid;place-items:center; + transition:border-color .12s,color .12s,transform .12s,box-shadow .12s; +} +.dt-insert-btn:hover{ + border-color:var(--color-fd-primary,#6366f1); + color:var(--color-fd-primary,#6366f1); + transform:scale(1.15); + box-shadow:0 0 0 4px color-mix(in srgb,var(--color-fd-primary,#6366f1) 12%,transparent); +} + +/* ─ insert menu palette ─ */ +.dt-palette{ + position:fixed;z-index:2147483004; + width:260px; + border:1px solid var(--color-fd-border,#e2e8f0); + border-radius:10px; + background:var(--color-fd-background,#fff); + box-shadow:0 12px 40px rgba(0,0,0,0.15); + padding:6px;overflow:hidden; +} +.dt-palette-title{ + font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em; + color:var(--color-fd-muted-foreground,#64748b); + padding:4px 8px 6px; +} +.dt-palette-grid{display:grid;grid-template-columns:1fr 1fr;gap:3px} +.dt-palette-item{ + display:flex;align-items:center;gap:8px; + padding:8px 9px;border-radius:7px; + cursor:pointer;border:none;background:transparent;text-align:left; + transition:background .1s;width:100%; +} +.dt-palette-item:hover{background:var(--color-fd-muted,#f1f5f9)} +.dt-palette-icon{ + width:26px;height:26px;border-radius:6px; + display:grid;place-items:center;font-size:11px;font-weight:900; + flex-shrink:0; +} +.dt-palette-label{font-size:11.5px;font-weight:650;color:var(--color-fd-foreground,#0f172a);line-height:1.2} +.dt-palette-desc{font-size:10px;color:var(--color-fd-muted-foreground,#64748b);margin-top:1px} + +/* ─ contenteditable prose ─ */ +.dt-editable{ + outline:none;white-space:pre-wrap;overflow-wrap:anywhere; + caret-color:var(--color-fd-primary,#6366f1); + margin:0; +} +.dt-editable:empty::before{ + content:attr(data-ph); + color:color-mix(in srgb,var(--color-fd-muted-foreground,#94a3b8) 55%,transparent); + pointer-events:none; +} +.dt-editable::selection{background:color-mix(in srgb,var(--color-fd-primary,#6366f1) 20%,transparent)} + +/* ─ formatting mini-bar ─ */ +.dt-fmt-bar{ + display:flex;gap:3px;margin-bottom:8px; + opacity:0;transition:opacity .13s,height .13s;padding:0;height:0;overflow:hidden; + pointer-events:none; +} +.dt-float-editor-body .dt-fmt-bar{ + opacity:1;height:auto;pointer-events:auto; + border:1px solid var(--color-fd-border,#e2e8f0); + border-radius:8px; + padding:4px; + background:color-mix(in srgb,var(--color-fd-muted,#f1f5f9) 58%,transparent); +} +.dt-fmt-btn{ + height:26px;min-width:28px;padding:0 8px; + border:1px solid transparent;border-radius:6px; + background:transparent;color:var(--color-fd-muted-foreground,#64748b); + font-size:11px;font-weight:780;cursor:pointer; + transition:color .1s,border-color .1s,background .1s; +} +.dt-fmt-btn:hover{ + color:var(--color-fd-foreground,#0f172a); + border-color:var(--color-fd-primary,#6366f1); + background:color-mix(in srgb,var(--color-fd-primary,#6366f1) 6%,transparent); +} + +/* ─ heading level row ─ */ +.dt-level-row{display:flex;gap:3px;margin-bottom:6px;opacity:0;transition:opacity .13s;pointer-events:none;height:0;overflow:hidden} +.dt-float-editor-body .dt-level-row{opacity:1;pointer-events:auto;height:auto} +.dt-level-btn{ + width:26px;height:21px;border-radius:4px; + border:1px solid var(--color-fd-border,#e2e8f0); + background:transparent;color:var(--color-fd-muted-foreground,#64748b); + font-size:10px;font-weight:800;cursor:pointer; + display:grid;place-items:center; + transition:color .1s,background .1s,border-color .1s; +} +.dt-level-btn:hover,.dt-level-btn[data-active]{ + color:var(--color-fd-primary,#6366f1); + border-color:var(--color-fd-primary,#6366f1); + background:color-mix(in srgb,var(--color-fd-primary,#6366f1) 8%,transparent); +} + +/* ─ callout variant selector ─ */ +.dt-variant-row{display:flex;gap:3px;margin-bottom:7px;opacity:0;pointer-events:none;height:0;overflow:hidden;transition:opacity .13s} +.dt-float-editor-body .dt-variant-row{opacity:1;pointer-events:auto;height:auto} +.dt-variant-btn{ + height:20px;padding:0 7px;border-radius:4px; + border:1px solid var(--color-fd-border,#e2e8f0); + background:transparent;color:var(--color-fd-muted-foreground,#64748b); + font-size:10px;font-weight:700;cursor:pointer; + transition:color .1s,background .1s,border-color .1s; +} +.dt-variant-btn[data-active],.dt-variant-btn:hover{ + color:var(--color-fd-foreground,#0f172a); + border-color:var(--color-fd-border,#e2e8f0); + background:var(--color-fd-muted,#f1f5f9); +} + +/* ─ hoverlink (inline dashed-link + hover metadata panel) ─ */ +.dt-hl-trigger{ + display:inline;border:none;background:transparent;padding:0;margin:0; + color:var(--color-fd-foreground,currentColor);cursor:text; + text-decoration:underline;text-decoration-style:dashed; + text-decoration-thickness:0.08em;text-underline-offset:0.22em; + text-decoration-color:color-mix(in srgb,var(--color-fd-foreground,currentColor) 46%,transparent); + font:inherit;line-height:inherit;appearance:none; +} +.dt-hl-meta{ + display:none;flex-direction:column;gap:4px; + margin-top:8px;padding:7px 10px; + border-radius:6px;border:1px solid var(--color-fd-border,#e2e8f0); + background:var(--color-fd-muted,#f1f5f9); +} +.dt-float-editor-body .dt-hl-meta{display:flex} +.dt-hl-row{display:flex;align-items:center;gap:7px} +.dt-hl-lbl{font-size:9.5px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--color-fd-muted-foreground,#64748b);width:52px;flex-shrink:0} +.dt-hl-inp{background:transparent;border:none;outline:none;font-size:11.5px;color:var(--color-fd-foreground,#0f172a);flex:1;font-family:inherit} + +/* ─ prompt (uses fd-prompt classes from base.css, just override editable inputs) ─ */ +.fd-prompt input.fd-prompt-title-edit,.fd-prompt textarea.fd-prompt-body-edit{ + background:transparent;border:none;outline:none;font:inherit;color:inherit; + width:100%;resize:none;padding:0;margin:0; +} + +/* ─ raw block ─ */ +.dt-raw-shell{ + border-radius:8px; + border:1.5px dashed color-mix(in srgb,var(--color-fd-border,#e2e8f0) 80%,transparent); + padding:10px 12px; + background:color-mix(in srgb,var(--color-fd-muted,#f1f5f9) 40%,transparent); +} + +/* ─ code block (matches fumadocs CodeBlock
) ─ */ +.dt-code-fig{ + margin:1rem 0; + background:var(--color-fd-card,#f8fafc); + border-radius:0.75rem; + border:1px solid var(--color-fd-border,#e2e8f0); + box-shadow:0 1px 2px rgba(0,0,0,0.06); + overflow:hidden; + font-size:0.875rem; + position:relative; +} +.dt-code-hdr{ + display:flex;align-items:center;gap:0.5rem; + height:2.375rem; + border-bottom:1px solid var(--color-fd-border,#e2e8f0); + padding:0 1rem; + color:var(--color-fd-muted-foreground,#64748b); +} +.dt-code-lang{ + background:transparent;border:none;outline:none; + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:0.75rem;font-weight:600; + color:var(--color-fd-muted-foreground,#64748b); + width:52px;flex-shrink:0; +} +.dt-code-sep-v{width:1px;height:0.75rem;background:var(--color-fd-border,#e2e8f0);flex-shrink:0} +.dt-code-file{ + background:transparent;border:none;outline:none; + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:0.75rem; + color:var(--color-fd-muted-foreground,#64748b); + flex:1;min-width:0; +} +.dt-code-body{padding:1rem} +.dt-code-fig figcaption{margin-top:0!important;font-size:inherit!important;color:inherit!important;line-height:inherit!important} +.dt-code-ta{ + width:100%;min-height:80px;resize:vertical; + background:transparent;border:none;outline:none; + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:0.875rem;line-height:1.65; + color:var(--color-fd-foreground,#0f172a); + display:block; +} + +/* ─ source textarea ─ */ +.dt-source-ta{ + width:100%;min-height:200px; + border:1px solid var(--color-fd-border,#e2e8f0);border-radius:9px; + padding:16px;outline:none;resize:vertical; + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:13px;line-height:1.65; + background:color-mix(in srgb,var(--color-fd-muted,#f1f5f9) 50%,var(--color-fd-background,#fff)); + color:var(--color-fd-foreground,#0f172a); +} + +/* ─ empty state ─ */ +.dt-empty{ + padding:32px 24px;text-align:center; + border:1.5px dashed color-mix(in srgb,var(--color-fd-border,#e2e8f0) 70%,transparent); + border-radius:12px; +} +.dt-empty-title{font-size:14px;font-weight:700;color:var(--color-fd-muted-foreground,#64748b);margin-bottom:4px} +.dt-empty-sub{font-size:12px;color:color-mix(in srgb,var(--color-fd-muted-foreground,#94a3b8) 75%,transparent)} + +@media(max-width:780px){ + .dt-bar-wordmark{display:none} + .dt-btn span{display:none} +} + +/* ─ selection toolbar (shows above selected text in any contentEditable) ─ */ +.dt-sel-toolbar{ + position:fixed;z-index:2147483010; + display:inline-flex;align-items:center;gap:1px; + background:rgba(9,9,13,0.97);backdrop-filter:blur(18px); + border:1px solid rgba(255,255,255,0.14);border-radius:9px; + padding:3px 5px; + box-shadow:0 6px 24px rgba(0,0,0,0.55); + transform:translateX(-50%); + pointer-events:auto; + white-space:nowrap; +} +/* small arrow pointing down */ +.dt-sel-toolbar::after{ + content:""; + position:absolute;top:100%;left:50%;transform:translateX(-50%); + border:5px solid transparent; + border-top-color:rgba(9,9,13,0.97); +} +.dt-sel-btn{ + height:26px;padding:0 8px; + border:none;background:transparent;color:rgba(255,255,255,0.82); + font-size:12.5px;font-weight:700;cursor:pointer; + border-radius:6px; + display:inline-flex;align-items:center;gap:3px; + transition:color .1s,background .1s; + font-family:inherit; +} +.dt-sel-btn:hover{color:#fff;background:rgba(255,255,255,0.13)} +.dt-sel-btn[data-active]{background:var(--color-fd-primary,#6366f1);color:#fff} +.dt-sel-sep{width:1px;height:15px;background:rgba(255,255,255,0.14);margin:0 3px;flex-shrink:0} + +/* ─ link editor popover ─ */ +.dt-link-pop{ + position:fixed;z-index:2147483011; + width:min(320px,calc(100vw - 24px)); + border:1px solid color-mix(in srgb,var(--color-fd-border,#e2e8f0) 86%,transparent); + border-radius:10px; + background:var(--color-fd-background,#fff); + box-shadow:0 20px 54px rgba(15,23,42,0.22),0 2px 10px rgba(15,23,42,0.08); + padding:10px; +} +.dt-link-pop-row{display:grid;gap:4px;margin-bottom:8px} +.dt-link-pop-label{font-size:10px;font-weight:750;color:var(--color-fd-muted-foreground,#64748b)} +.dt-link-pop-input{ + height:30px;width:100%; + border:1px solid var(--color-fd-border,#e2e8f0); + border-radius:7px; + background:var(--color-fd-background,#fff); + color:var(--color-fd-foreground,#0f172a); + padding:0 9px;font:inherit;font-size:12px;outline:none; +} +.dt-link-pop-input:focus{ + border-color:var(--color-fd-primary,#6366f1); + box-shadow:0 0 0 3px color-mix(in srgb,var(--color-fd-primary,#6366f1) 14%,transparent); +} +.dt-link-pop-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px} +.dt-link-pop-btn{ + height:28px;padding:0 10px;border-radius:7px; + border:1px solid var(--color-fd-border,#e2e8f0); + background:transparent;color:var(--color-fd-foreground,#0f172a); + font-size:12px;font-weight:700;cursor:pointer; +} +.dt-link-pop-btn:hover{background:var(--color-fd-muted,#f1f5f9)} +.dt-link-pop-btn[data-primary]{background:var(--color-fd-primary,#6366f1);border-color:var(--color-fd-primary,#6366f1);color:#fff} +`; + +// ─── ContentEditable helper ─────────────────────────────────────────────────── + +function getEditText(el: HTMLElement) { + return el.innerText.replace(/\n\n$/g, "").replace(/ /g, " "); +} + +function getEditSel(el: HTMLElement, fallback: number): { start: number; end: number } { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return { start: fallback, end: fallback }; + if (!el.contains(sel.anchorNode) || !el.contains(sel.focusNode)) + return { start: fallback, end: fallback }; + const range = sel.getRangeAt(0); + const before = range.cloneRange(); + before.selectNodeContents(el); + before.setEnd(range.startContainer, range.startOffset); + const start = before.toString().length; + return { start, end: start + range.toString().length }; +} + +function setEditSel(el: HTMLElement, start: number, end: number) { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + let offset = 0; + let sn: Text | null = null; + let en: Text | null = null; + let so = 0; + let eo = 0; + let node = walker.nextNode() as Text | null; + while (node) { + const len = (node.textContent ?? "").length; + const next = offset + len; + if (!sn && start <= next) { + sn = node; + so = Math.max(0, start - offset); + } + if (!en && end <= next) { + en = node; + eo = Math.max(0, end - offset); + break; + } + offset = next; + node = walker.nextNode() as Text | null; + } + if (!sn || !en) return; + const range = document.createRange(); + range.setStart(sn, so); + range.setEnd(en, eo); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); +} + +function syncEditable(el: HTMLElement) { + el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "formatSetBlockText" })); +} + +function syncActiveEditable() { + const active = document.activeElement; + if (active instanceof HTMLElement && active.isContentEditable) { + syncEditable(active); + } +} + +function execRichTextCommand(command: string, value?: string) { + document.execCommand(command, false, value); + syncActiveEditable(); +} + +type LinkEditorRequest = { + host: HTMLElement; + range: Range; + anchor: HTMLAnchorElement | null; + label: string; + url: string; + rect: DOMRect; +}; + +let closeActiveLinkEditor: (() => void) | null = null; + +function getContentEditableHost(node: Node | null): HTMLElement | null { + const el = node instanceof Element ? node : node?.parentElement; + const host = el?.closest("[contenteditable]"); + return host instanceof HTMLElement ? host : null; +} + +function getClosestAnchor(node: Node | null): HTMLAnchorElement | null { + const el = node instanceof Element ? node : node?.parentElement; + const anchor = el?.closest("a[href]"); + return anchor instanceof HTMLAnchorElement ? anchor : null; +} + +function fallbackRectFor(el: HTMLElement): DOMRect { + const rect = el.getBoundingClientRect(); + if (rect.width || rect.height) return rect; + return new DOMRect(16, 72, 280, 32); +} + +function rangeBelongsToHost(range: Range, host: HTMLElement) { + return host.contains(range.startContainer) && host.contains(range.endContainer); +} + +function fallbackRangeFor(host: HTMLElement) { + const fallback = document.createRange(); + fallback.selectNodeContents(host); + fallback.collapse(false); + return fallback; +} + +function requestRichTextLink(anchorRect?: DOMRect, fallbackHost?: HTMLElement | null) { + const sel = window.getSelection(); + const active = document.activeElement; + const activeHost = active instanceof HTMLElement && active.isContentEditable ? active : null; + const host = + getContentEditableHost(sel?.anchorNode ?? null) ?? + getContentEditableHost(sel?.focusNode ?? null) ?? + fallbackHost ?? + activeHost; + if (!host) return; + + const selectedRange = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null; + const range = + selectedRange && rangeBelongsToHost(selectedRange, host) + ? selectedRange + : fallbackRangeFor(host); + + const anchor = + getClosestAnchor(sel?.anchorNode ?? null) ?? getClosestAnchor(sel?.focusNode ?? null); + const hostAnchor = anchor && host.contains(anchor) ? anchor : null; + const label = hostAnchor?.textContent ?? sel?.toString() ?? ""; + const url = hostAnchor?.getAttribute("href") ?? ""; + const selectionRect = range.getBoundingClientRect(); + const rect = + anchorRect ?? + (hostAnchor ? fallbackRectFor(hostAnchor) : null) ?? + (selectionRect.width || selectionRect.height ? selectionRect : fallbackRectFor(host)); + + const request = { host, range, anchor: hostAnchor, label, url, rect }; + showLinkEditorPopover(request); +} + +function insertRichTextLink(host: HTMLElement, range: Range, url: string, selectedText: string) { + const href = url.trim(); + if (!href) return; + const label = selectedText.trim() || href; + + host.focus(); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.textContent = label; + range.deleteContents(); + range.insertNode(anchor); + range.setStartAfter(anchor); + range.setEndAfter(anchor); + sel?.removeAllRanges(); + sel?.addRange(range); + syncActiveEditable(); +} + +function closeLinkEditorPopover() { + closeActiveLinkEditor?.(); + closeActiveLinkEditor = null; +} + +function placeLinkEditorPopover(pop: HTMLElement, rect: DOMRect) { + const gap = 8; + const width = pop.offsetWidth || 320; + const height = pop.offsetHeight || 132; + const left = Math.max( + 12, + Math.min(window.innerWidth - width - 12, rect.left + rect.width / 2 - width / 2), + ); + const aboveTop = rect.top - height - gap; + const belowTop = rect.bottom + gap; + const top = + aboveTop >= 12 ? aboveTop : Math.min(Math.max(12, belowTop), window.innerHeight - height - 12); + + pop.style.top = `${top}px`; + pop.style.left = `${left}px`; +} + +function showLinkEditorPopover(request: LinkEditorRequest) { + closeLinkEditorPopover(); + + const pop = document.createElement("div"); + pop.className = "dt dt-link-pop"; + pop.style.visibility = "hidden"; + + const labelRow = document.createElement("label"); + labelRow.className = "dt-link-pop-row"; + const labelText = document.createElement("span"); + labelText.className = "dt-link-pop-label"; + labelText.textContent = "Label"; + const labelInput = document.createElement("input"); + labelInput.className = "dt-link-pop-input"; + labelInput.placeholder = "Link text"; + labelInput.value = request.label; + labelRow.append(labelText, labelInput); + + const urlRow = document.createElement("label"); + urlRow.className = "dt-link-pop-row"; + const urlText = document.createElement("span"); + urlText.className = "dt-link-pop-label"; + urlText.textContent = "URL"; + const urlInput = document.createElement("input"); + urlInput.className = "dt-link-pop-input"; + urlInput.placeholder = "https://example.com"; + urlInput.value = request.url; + urlRow.append(urlText, urlInput); + + const actions = document.createElement("div"); + actions.className = "dt-link-pop-actions"; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "dt-link-pop-btn"; + cancel.textContent = "Cancel"; + const apply = document.createElement("button"); + apply.type = "button"; + apply.className = "dt-link-pop-btn"; + apply.dataset.primary = ""; + apply.textContent = "Apply"; + actions.append(cancel, apply); + pop.append(labelRow, urlRow, actions); + + function cleanup() { + pop.remove(); + document.removeEventListener("mousedown", outside); + document.removeEventListener("keydown", key); + closeActiveLinkEditor = null; + } + + function save() { + const href = urlInput.value.trim(); + if (!href) return; + + if (request.anchor && request.host.contains(request.anchor)) { + request.anchor.href = href; + request.anchor.textContent = labelInput.value.trim() || href; + syncEditable(request.host); + } else { + insertRichTextLink(request.host, request.range, href, labelInput.value || request.label); + } + cleanup(); + } + + function outside(e: MouseEvent) { + if (pop.contains(e.target as Node)) return; + cleanup(); + } + + function key(e: KeyboardEvent) { + if (e.key === "Escape") cleanup(); + if (e.key === "Enter" && document.activeElement === urlInput) { + e.preventDefault(); + save(); + } + } + + cancel.addEventListener("click", cleanup); + apply.addEventListener("click", save); + document.body.appendChild(pop); + placeLinkEditorPopover(pop, request.rect); + pop.style.visibility = ""; + closeActiveLinkEditor = cleanup; + window.setTimeout(() => { + document.addEventListener("mousedown", outside); + document.addEventListener("keydown", key); + labelInput.focus(); + labelInput.select(); + }, 0); +} + +// ─── ProseEditor (contentEditable with formatting) ──────────────────────────── + +function ProseEditor({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el || document.activeElement === el) return; + if (html2md(el.innerHTML) !== value) el.innerHTML = md2html(value); + }, [value]); + + function focusEditor() { + const el = ref.current; + if (!el) return; + el.focus(); + } + + function wrapCode() { + focusEditor(); + 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()}\``); + } + } + + return ( +
+
+ {( + [ + ["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("")}`, + ); + 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 ( +
    +
    + onChange({ ...block, language: e.currentTarget.value })} + placeholder="ts" + /> +
    +
    + onChange({ ...block, title: e.currentTarget.value })} + placeholder="filename.ts" + /> +
    +
    +
    +