← Files DataARCHIVED FILE

templates/data-app/base/src/components/RichMarkdown.jsx

7.2 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

import {
  $convertFromMarkdownString, $convertToMarkdownString, BOLD_ITALIC_STAR, BOLD_STAR, CHECK_LIST,
  HEADING, INLINE_CODE, ITALIC_STAR, LINK, ORDERED_LIST, QUOTE, UNORDERED_LIST,
} from "@lexical/markdown";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { LinkNode } from "@lexical/link";
import { ListItemNode, ListNode } from "@lexical/list";
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
import { COMMAND_PRIORITY_HIGH, KEY_ESCAPE_COMMAND } from "lexical";
import React, { useEffect, useMemo, useState } from "react";
import Markdown from "react-markdown";

import { useDataAppShell } from "../DataAppContext.jsx";
import { sourcePreviewForHref } from "../source-preview.js";
import { RichTextFormatToolbar } from "./RichTextFormatToolbar.jsx";
import { SourcePreviewLink } from "./SourcePreviewLink.jsx";
import { Button } from "./ui.jsx";

const transformers = [
  HEADING, QUOTE, CHECK_LIST, UNORDERED_LIST, ORDERED_LIST, BOLD_ITALIC_STAR, BOLD_STAR,
  ITALIC_STAR, INLINE_CODE, LINK,
];
const theme = {
  heading: { h1: "markdown-h1", h2: "markdown-h2", h3: "markdown-h3", h4: "markdown-h4" },
  link: "markdown-link",
  list: { listitem: "markdown-list-item", ol: "markdown-list", ul: "markdown-list" },
  quote: "markdown-quote",
  text: { bold: "markdown-bold", italic: "markdown-italic", code: "markdown-code" },
};

// Match the checklist syntax emitted by Lexical, leaving ordinary Markdown
// (including escaped markers and inline code) to react-markdown's parser.
function remarkChecklists() {
  return (tree, file) => {
    function visit(node) {
      const paragraph = node.type === "listItem" ? node.children?.[0] : null;
      const text = paragraph?.type === "paragraph" ? paragraph.children?.[0] : null;
      const marker = text?.type === "text" && /^\[([ xX])\](?:[ \t]+|$)/u.exec(text.value);
      if (marker && typeof text.position?.start.offset === "number"
        && String(file.value).slice(text.position.start.offset).startsWith(marker[0])) {
        node.checked = marker[1].toLowerCase() === "x";
        text.value = text.value.slice(marker[0].length);
      }
      node.children?.forEach(visit);
    }
    visit(tree);
  };
}
const markdownPlugins = [remarkChecklists];

function EditorEvents({ onChange, onCancel }) {
  const [editor] = useLexicalComposerContext();

  useEffect(() => editor.registerCommand(KEY_ESCAPE_COMMAND, () => {
    onCancel();
    return true;
  }, COMMAND_PRIORITY_HIGH), [editor, onCancel]);

  return <OnChangePlugin ignoreSelectionChange onChange={(state) => state.read(() => {
    onChange($convertToMarkdownString(transformers).trim());
  })} />;
}

export function RichMarkdown({ value, editing, onEditingChange, onSave, label = "Markdown text" }) {
  const [draft, setDraft] = useState(value);
  const initialConfig = useMemo(() => ({
    namespace: "DataAppMarkdown",
    nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode],
    theme,
    onError(error) { throw error; },
    editorState: () => $convertFromMarkdownString(value, transformers),
  }), [value]);

  function cancel() {
    setDraft(value);
    onEditingChange(false);
  }

  if (!editing) {
    return <div className="notes" role="button" tabIndex={0} onDoubleClick={() => onEditingChange(true)}
      onKeyDown={(event) => { if (event.key === "Enter") onEditingChange(true); }}>
      <Markdown remarkPlugins={markdownPlugins}>{value}</Markdown>
    </div>;
  }

  return (
    <div className="markdown-editor">
      <LexicalComposer initialConfig={initialConfig} key={value}>
        <RichTextPlugin contentEditable={<ContentEditable className="markdown-editable" aria-label={label} />}
          placeholder={<span className="markdown-placeholder">Write Markdown…</span>}
          ErrorBoundary={LexicalErrorBoundary} />
        <HistoryPlugin />
        <ListPlugin />
        <CheckListPlugin />
        <LinkPlugin />
        <RichTextFormatToolbar />
        <MarkdownShortcutPlugin transformers={transformers} />
        <EditorEvents onChange={setDraft} onCancel={cancel} />
      </LexicalComposer>
      <div className="actions">
        <Button onClick={() => { onSave(draft); onEditingChange(false); }}>Save</Button>
        <Button variant="ghost" onClick={cancel}>Cancel</Button>
      </div>
    </div>
  );
}

export function RichNarrative({ id, value, sourcePreviews, className = "", label = "Narrative", ...attributes }) {
  const { canEdit, mode, narrativeEdits, setNarrativeEdit } = useDataAppShell();
  if (typeof id !== "string" || !id.trim()) {
    throw new Error("RichNarrative requires a non-empty stable narrative id.");
  }
  const authoredMarkdown = narrativeEdits[id] ?? value;
  const markdown = typeof authoredMarkdown === "string"
    ? authoredMarkdown.replace(/\\r\\n|\\n/g, "\n")
    : authoredMarkdown;
  const initialConfig = useMemo(() => ({
    namespace: `DataAppNarrative:${id}`,
    nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode],
    theme,
    onError(error) { throw error; },
    editorState: () => $convertFromMarkdownString(markdown, transformers),
  }), [id, markdown]);
  const editing = canEdit && mode === "edit";
  const markdownComponents = useMemo(() => sourcePreviews ? {
    a: ({ node: _node, href, children, ...props }) => {
      const preview = sourcePreviewForHref(sourcePreviews, href);
      return preview ? <SourcePreviewLink {...props} preview={preview}>{children}</SourcePreviewLink>
        : <a {...props} href={href}>{children}</a>;
    },
  } : undefined, [sourcePreviews]);

  return <div {...attributes} className={["rich-narrative", className].filter(Boolean).join(" ")}
    data-rich-narrative data-editable-narrative data-editable-id={id}>
    {editing ? <LexicalComposer initialConfig={initialConfig} key={id}>
      <RichTextPlugin contentEditable={<ContentEditable className="report-rich-editable" aria-label={label} />}
        placeholder={<span className="markdown-placeholder">Write narrative…</span>}
        ErrorBoundary={LexicalErrorBoundary} />
      <HistoryPlugin />
      <ListPlugin />
      <CheckListPlugin />
      <LinkPlugin />
      <RichTextFormatToolbar />
      <MarkdownShortcutPlugin transformers={transformers} />
      <OnChangePlugin ignoreSelectionChange onChange={(state) => state.read(() => {
        const next = $convertToMarkdownString(transformers).trim();
        if (next !== markdown) setNarrativeEdit(id, next);
      })} />
    </LexicalComposer> : <div className="rich-narrative-content"><Markdown remarkPlugins={markdownPlugins} components={markdownComponents}>{markdown}</Markdown></div>}
  </div>;
}

SHA-256: d0afa8e119b0cc3cad380757bb80d6180effee84caa380f415c94d6db8fdb43f