import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { Check, Code2, Copy, Eye, ExternalLink, Image as ImageIcon, Loader2, Pencil, RefreshCw, X, } from "lucide-react"; import { GRAPH_THEME } from "./graphTheme"; import type { MarkdownApplyResult } from "./markdownResourceClient"; import type { MarkdownResourceRef } from "./markdownEditorState"; import { isSafeUrl } from "./markdownUrlSafety"; import { useMarkdownEditor } from "./useMarkdownEditor"; export interface MarkdownContentViewerProps { content?: string | null; resource?: MarkdownResourceRef; onApplied?: (result: MarkdownApplyResult) => void; onDirtyChange?: (dirty: boolean) => void; className?: string; defaultMode?: "preview" | "source"; } // Exported for unit-testing the roving-tabindex navigation logic without a DOM. // Given the ordered list of tab modes and the currently focused mode, returns // the mode that should receive focus for a given keyboard key. Returns null if // the key is not a navigation key so callers can handle the default case. // eslint-disable-next-line react-refresh/only-export-components export function resolveTabNavigation( current: "preview" | "source", key: string, ): "preview" | "source" | null { const order = ["preview", "source"] as const; const idx = order.indexOf(current); switch (key) { case "ArrowRight": return order[(idx + 1) % order.length]; case "ArrowLeft": return order[(idx - 1 + order.length) % order.length]; case "Home": return order[0]; case "End": return order[order.length - 1]; default: return null; } } export function MarkdownContentViewer({ content, resource, onApplied, onDirtyChange, className, defaultMode = "preview", }: MarkdownContentViewerProps) { const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode); const [copied, setCopied] = useState(false); const modeBeforeEditRef = useRef<"preview" | "source">(defaultMode); const resourceKey = resource ? `${resource.kind}:${resource.id}` : ""; const [activeResourceKey, setActiveResourceKey] = useState(resourceKey); const editor = useMarkdownEditor({ resource, onApplied, onDirtyChange }); const { session, error, dirty, editing, saving, loading, } = editor; if (activeResourceKey !== resourceKey) { setActiveResourceKey(resourceKey); setCopied(false); setActiveMode(defaultMode); } // Track the content value for which the copied indicator is valid. const [copiedForContent, setCopiedForContent] = useState(content); if (copiedForContent !== content) { setCopiedForContent(content); if (copied) setCopied(false); } // useId (not hardcoded strings) so the ids stay unique if more than one viewer // is ever mounted at once — the same pattern GraphWorkspace uses for its search // combobox. Hardcoded ids would collide silently in that case. const baseId = useId(); const previewTabId = `${baseId}-tab-preview`; const sourceTabId = `${baseId}-tab-source`; const panelId = `${baseId}-panel`; // Roving tabindex: the tablist is one Tab stop and arrows move focus within it. // Focus is tracked separately from selection because activation is manual (see // handleTabKeyDown), so a tab can hold focus without being the selected one. // // focusedMode is stored in a ref rather than state so that moving focus with // arrow keys does NOT trigger a React re-render. A re-render here is expensive: // react-markdown@10 has no internal memoisation and calls processor.parse() + // processor.runSync() unconditionally on every render — measured at 385ms for a // 1000-row GFM table and 1.4s at 2000 rows (#1118). Using a ref means arrow-key // navigation is free of Markdown re-parses while still keeping the DOM tabIndex // attributes correct via direct mutation (the same pattern used by WAI-ARIA APG // keyboard examples for roving tabindex). // // The JSX tabIndex props use activeMode (not the ref) to satisfy the // react-hooks/refs lint rule that bars ref reads during render. JSX provides the // correct value on initial render and after selectMode() calls (which always keep // focusedModeRef.current === activeMode at React render boundaries). A // useLayoutEffect (see below) corrects any JSX overwrite that occurs when focus // and selection temporarily differ during arrow navigation. const focusedModeRef = useRef<"preview" | "source">(defaultMode); const previewTabRef = useRef(null); const sourceTabRef = useRef(null); const focusTab = (mode: "preview" | "source") => { focusedModeRef.current = mode; // Imperatively update tabIndex on both buttons so the roving tabindex // DOM state is correct without scheduling a React re-render. if (previewTabRef.current) previewTabRef.current.tabIndex = mode === "preview" ? 0 : -1; if (sourceTabRef.current) sourceTabRef.current.tabIndex = mode === "source" ? 0 : -1; (mode === "preview" ? previewTabRef : sourceTabRef).current?.focus(); }; const selectMode = (mode: "preview" | "source") => { // Keep the ref in sync before setActiveMode so the upcoming re-render reads // the correct focusedModeRef.current when evaluating JSX tabIndex props. focusedModeRef.current = mode; setActiveMode(mode); }; // Manual activation (APG permits it, and here it is required): arrows move // focus only, Enter/Space activates via the native button click. Automatic // activation would re-run the full markdown parse on every arrow keypress — // measured at 385ms for a 1000-row GFM table and 1.4s at 2000 rows (#1118). const handleTabKeyDown = (event: KeyboardEvent) => { const next = resolveTabNavigation(focusedModeRef.current, event.key); if (next === null) return; event.preventDefault(); focusTab(next); }; const copyTimeoutRef = useRef(undefined); useEffect(() => { return () => { clearTimeout(copyTimeoutRef.current); }; }, []); // When the viewed resource/node changes, reset focusedModeRef to match the // incoming defaultMode. The render-phase setActiveMode(defaultMode) above // resets React state, but refs are not state and must be updated separately. // useLayoutEffect fires synchronously before paint so the ref is correct before // the no-deps tabIndex-correction effect (declared next) reads it. // Using resourceKey as the dep means this runs exactly once per resource change, // immediately after the render that detected the change. useLayoutEffect(() => { focusedModeRef.current = defaultMode; }, [resourceKey, defaultMode]); // After every render, restore the DOM tabIndex to match focusedModeRef.current. // This is necessary because the JSX tabIndex props derive from activeMode, which // is correct for initial render and for renders triggered by selectMode(). However, // when focus and selection differ (i.e. after arrow-key navigation, before Enter/Space), // any unrelated re-render (copy-button click, parent update, etc.) will reconcile JSX // tabIndex={activeMode === X} and overwrite the imperative tabIndex values set by // focusTab(). useLayoutEffect fires synchronously after React's DOM mutations, before // paint, so it corrects any such overwrite before the user sees it. It does not // schedule another render — the two property writes are pure DOM mutations. // No deps array: intentional. The correction must run after every render, not just mount. // SSR-safe: useLayoutEffect is silently skipped on the server; the JSX tabIndex from // activeMode provides the correct initial value (focusedModeRef.current === activeMode // at mount). Strict Mode: runs twice on remount — both runs write the same values, // no state mutation, no render triggered. useLayoutEffect(() => { if (previewTabRef.current) { previewTabRef.current.tabIndex = focusedModeRef.current === "preview" ? 0 : -1; } if (sourceTabRef.current) { sourceTabRef.current.tabIndex = focusedModeRef.current === "source" ? 0 : -1; } }); const rawContent = editor.editing ? editor.session?.draft ?? "" : (typeof content === "string" ? content : ""); const previewContent = useMemo(() => { if (!editor.editing) return rawContent; const lines = rawContent.split(/\r?\n/); if (lines[0] !== "---") return rawContent; const closingIndex = lines.findIndex((line, index) => index > 0 && line === "---"); return closingIndex < 0 ? rawContent : lines.slice(closingIndex + 1).join("\n").replace(/^\n/, ""); }, [editor.editing, rawContent]); const hasContent = rawContent.trim().length > 0; const renderedMarkdown = useMemo( () => ( {previewContent} ), [previewContent], ); const handleCopy = async () => { if (!hasContent) return; try { await navigator.clipboard.writeText(rawContent); clearTimeout(copyTimeoutRef.current); setCopied(true); copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500); } catch { // Clipboard write unavailable. } }; const handleEdit = async () => { modeBeforeEditRef.current = activeMode; selectMode("source"); if (!await editor.beginEdit()) { selectMode(modeBeforeEditRef.current); } }; const handleCancel = () => { editor.discard(); selectMode(modeBeforeEditRef.current); }; const handleApply = async () => { if (await editor.save()) { selectMode("preview"); } }; return (
{hasContent && ( )} {resource && !editing && !loading ? ( ) : null} {loading ? ( ) : null} {editing ? ( <> ) : null}
{error ? ( ) : null} {/* Both tabs point aria-controls at this one panel: only the active view is ever rendered inside (edit/preview/source/empty), so per-tab panel ids would leave the inactive tab referencing an element not in the DOM. Wrapping all branches — including empty state and editing textarea — keeps every aria-controls reference resolvable at all times. tabIndex=0 because the panel is a scroll container (viewerBodyStyle caps its height), so keyboard users need to be able to focus and scroll it. aria-busy signals to assistive tech that the content is loading/saving. */}
{activeMode === "source" && editing ? (