import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react"; import { GRAPH_THEME } from "./graphTheme"; import { isSafeUrl } from "./markdownUrlSafety"; export interface MarkdownContentViewerProps { content?: string | null; className?: string; defaultMode?: "preview" | "source"; } export function MarkdownContentViewer({ content, className, defaultMode = "preview", }: MarkdownContentViewerProps) { const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode); const [copied, setCopied] = useState(false); // Track the content value for which the copied indicator is valid. // When content changes (i.e. the user selects a different node), reset the // copied indicator inline during render rather than in a useEffect — this // avoids a cascading-render lint error and is the React-recommended pattern // for resetting derived visual state on prop changes. const [copiedForContent, setCopiedForContent] = useState(content); if (copiedForContent !== content) { setCopiedForContent(content); if (copied) { // Clear the stale indicator synchronously so the new node's copy button // never shows "Copied" from the previous selection. setCopied(false); } } const copyTimeoutRef = useRef | null>(null); // Clean up any outstanding timeout on unmount. useEffect(() => { return () => { if (copyTimeoutRef.current) { clearTimeout(copyTimeoutRef.current); } }; }, []); const rawContent = typeof content === "string" ? content : ""; const hasContent = rawContent.trim().length > 0; // react-markdown runs the whole remark pipeline synchronously inside its own // render, so without this memo every unrelated re-render of this component -- // clicking Copy, toggling Preview/Source -- re-parses the entire document. // Measured at ~364ms per re-render for a 1000-row GFM table (issue #1118). // Keyed on rawContent so a genuine node change still re-parses exactly once. const renderedMarkdown = useMemo( () => ( {rawContent} ), [rawContent], ); const handleCopy = async () => { if (!hasContent) return; try { await navigator.clipboard.writeText(rawContent); if (copyTimeoutRef.current) { clearTimeout(copyTimeoutRef.current); } setCopied(true); copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500); } catch { // Clipboard write unavailable } }; return (
{hasContent && ( )}
{!hasContent ? (
No content available for this node.
) : activeMode === "source" ? (
            {rawContent}
          
) : (
{renderedMarkdown}
)}
); } /* ─── Markdown rendering config ───────────────────────────────────── */ // Both props are hoisted to module scope so they keep a stable identity across // renders. As inline literals they allocated a fresh plugin array and ~20 fresh // arrow components on every render, which made React treat every mapped tag as a // new element type and remount the entire rendered subtree instead of updating // it (issue #1118). The arrow bodies only read the style constants below at call // time, so declaring the map before them is safe. const REMARK_PLUGINS = [remarkGfm]; const MARKDOWN_COMPONENTS: Components = { // C-1: react-markdown passes a HAST `node` prop (the raw AST // Element) to every custom component override via passNode:true. // In React 19 any unknown prop spreads onto a native element are // serialised as HTML attributes, producing node="[object Object]" // on every rendered link. Fix: destructure `node` by name so it // is explicitly discarded, then spread `...rest` to preserve all // other legitimate HAST/remark-gfm attributes — e.g. the `id`, // `aria-describedby`, `aria-label`, `data-footnote-ref`, // `data-footnote-backref`, and `class` attrs that GFM footnotes // require for correct in-page navigation and accessibility. // // C-2: fragment links (#anchor, GFM footnote backlinks) must // navigate within the current document. External links continue // to use target="_blank" with noopener noreferrer. // // eslint-disable-next-line @typescript-eslint/no-unused-vars a: ({ href, children, title, node: _node, ...rest }) => { if (!isSafeUrl(href)) { return {children}; } // isSafeUrl returning true guarantees href is a non-empty string. const safeHref = href ?? ""; // Fragment links (#section, footnote backlinks like // #user-content-fnref-1) are in-document anchors. Opening them // in a new tab would break GFM footnote back-navigation. const isFragment = safeHref.startsWith("#"); if (isFragment) { return ( {children} ); } return ( {children} ); }, img: ({ src, alt }) => ( Image: {alt || src || "unlabeled"} ), h1: ({ children }) =>

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, h4: ({ children }) =>

{children}

, p: ({ children }) =>

{children}

, ul: ({ children }) => , ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , blockquote: ({ children }) =>
    {children}
    , hr: () =>
    , table: ({ children }) => (
    {children}
    ), thead: ({ children }) => {children}, tbody: ({ children }) => {children}, tr: ({ children }) => {children}, th: ({ children }) => {children}, td: ({ children }) => {children}, pre: ({ children }) =>
    {children}
    , // C-1: discard `node` here too — code elements are custom components // and would otherwise receive node="[object Object]" in the DOM. code: ({ className: codeClass, children }) => { const isInline = !codeClass && typeof children === "string" && !children.includes("\n"); return ( {children} ); }, }; /* ─── Styles ──────────────────────────────────────────────────────── */ const viewerContainerStyle: CSSProperties = { display: "flex", flexDirection: "column", background: "rgba(255, 255, 255, 0.025)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, borderRadius: 12, overflow: "hidden", }; const viewerHeaderStyle: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "6px 10px", background: "rgba(0, 0, 0, 0.2)", borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, }; const tabBtnStyle: CSSProperties = { display: "inline-flex", alignItems: "center", padding: "4px 9px", borderRadius: 6, border: "1px solid transparent", background: "transparent", color: GRAPH_THEME.ui.text.muted, fontSize: 12, fontWeight: 600, cursor: "pointer", transition: "all 150ms ease", }; const activeTabBtnStyle: CSSProperties = { background: GRAPH_THEME.ui.timeline.playheadSoft, border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`, color: GRAPH_THEME.ui.timeline.playhead, }; const copyBtnStyle: CSSProperties = { display: "inline-flex", alignItems: "center", padding: "3px 8px", borderRadius: 6, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, background: "rgba(255, 255, 255, 0.04)", color: GRAPH_THEME.ui.text.subtle, fontSize: 11, cursor: "pointer", }; const viewerBodyStyle: CSSProperties = { padding: 12, maxHeight: 380, overflowY: "auto", }; const emptyTextStyle: CSSProperties = { color: GRAPH_THEME.ui.text.muted, fontSize: 12, lineHeight: 1.5, fontStyle: "italic", }; const sourcePreStyle: CSSProperties = { margin: 0, padding: 10, borderRadius: 8, background: "rgba(0, 0, 0, 0.3)", border: "1px solid rgba(255, 255, 255, 0.05)", overflowX: "auto", }; const sourceCodeStyle: CSSProperties = { fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: 12, lineHeight: 1.6, color: GRAPH_THEME.ui.text.strong, whiteSpace: "pre-wrap", wordBreak: "break-word", userSelect: "text", }; const previewStyle: CSSProperties = { color: GRAPH_THEME.ui.text.body, fontSize: 13, lineHeight: 1.6, wordBreak: "break-word", }; const h1Style: CSSProperties = { fontSize: 16, fontWeight: 700, color: GRAPH_THEME.ui.text.strong, marginTop: 8, marginBottom: 6, paddingBottom: 3, borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, }; const h2Style: CSSProperties = { fontSize: 14, fontWeight: 700, color: GRAPH_THEME.ui.text.strong, marginTop: 8, marginBottom: 4, }; const h3Style: CSSProperties = { fontSize: 13, fontWeight: 600, color: GRAPH_THEME.ui.text.strong, marginTop: 6, marginBottom: 4, }; const h4Style: CSSProperties = { fontSize: 12, fontWeight: 600, color: GRAPH_THEME.ui.text.strong, marginTop: 4, marginBottom: 2, }; const blockquoteStyle: CSSProperties = { margin: "8px 0", padding: "6px 12px", borderLeft: `3px solid ${GRAPH_THEME.ui.timeline.playhead}`, background: "rgba(98, 226, 205, 0.05)", borderRadius: "0 6px 6px 0", color: GRAPH_THEME.ui.text.body, fontStyle: "italic", }; const linkStyle: CSSProperties = { color: "#79c0ff", textDecoration: "underline", textUnderlineOffset: "3px", wordBreak: "break-all", }; const imageBadgeStyle: CSSProperties = { display: "inline-flex", alignItems: "center", padding: "3px 7px", background: "rgba(255, 255, 255, 0.04)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, borderRadius: 6, color: GRAPH_THEME.ui.text.muted, fontSize: 11, margin: "3px 0", }; const inlineCodeStyle: CSSProperties = { fontFamily: "'JetBrains Mono', monospace", fontSize: 12, padding: "2px 5px", borderRadius: 4, background: "rgba(255, 255, 255, 0.07)", color: "#e6edf3", border: "1px solid rgba(255, 255, 255, 0.08)", }; const preBlockStyle: CSSProperties = { margin: "8px 0", padding: 10, borderRadius: 8, background: "rgba(0, 0, 0, 0.35)", border: "1px solid rgba(255, 255, 255, 0.08)", overflowX: "auto", }; const blockCodeStyle: CSSProperties = { fontFamily: "'JetBrains Mono', monospace", fontSize: 12, lineHeight: 1.5, color: "#e6edf3", };