// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CopyButton } from "@/app/element/copybutton"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getWebServerEndpoint } from "@/util/endpoints"; import { isBlank, makeConnRoute, useAtomValueSafe } from "@/util/util"; import { clsx } from "clsx"; import { Atom } from "jotai"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import { useEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown, { Components } from "react-markdown"; import rehypeHighlight from "rehype-highlight"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import rehypeSlug from "rehype-slug"; import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc"; import remarkGfm from "remark-gfm"; import { remarkAlert } from "remark-github-blockquote-alert"; import { openLink } from "../store/global"; import { IconButton } from "./iconbutton"; import "./markdown.less"; const Link = ({ setFocusedHeading, props, }: { props: React.AnchorHTMLAttributes; setFocusedHeading: (href: string) => void; }) => { const onClick = (e: React.MouseEvent) => { e.preventDefault(); if (props.href.startsWith("#")) { setFocusedHeading(props.href); } else { openLink(props.href); } }; return ( {props.children} ); }; const Heading = ({ props, hnum }: { props: React.HTMLAttributes; hnum: number }) => { return (
{props.children}
); }; const Code = ({ className, children }: { className: string; children: React.ReactNode }) => { return {children}; }; type CodeBlockProps = { children: React.ReactNode; onClickExecute?: (cmd: string) => void; }; const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => { const getTextContent = (children: any): string => { if (typeof children === "string") { return children; } else if (Array.isArray(children)) { return children.map(getTextContent).join(""); } else if (children.props && children.props.children) { return getTextContent(children.props.children); } return ""; }; const handleCopy = async (e: React.MouseEvent) => { let textToCopy = getTextContent(children); textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline await navigator.clipboard.writeText(textToCopy); }; const handleExecute = (e: React.MouseEvent) => { let textToCopy = getTextContent(children); textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline if (onClickExecute) { onClickExecute(textToCopy); return; } }; return (
            {children}
            
{onClickExecute && ( )}
); }; const MarkdownSource = (props: React.HTMLAttributes) => { return null; }; const MarkdownImg = ({ props, resolveOpts, }: { props: React.ImgHTMLAttributes; resolveOpts: MarkdownResolveOpts; }) => { const [resolvedSrc, setResolvedSrc] = useState(props.src); const [resolvedStr, setResolvedStr] = useState(null); const [resolving, setResolving] = useState(true); useEffect(() => { if (props.src.startsWith("http://") || props.src.startsWith("https://")) { setResolving(false); setResolvedSrc(props.src); setResolvedStr(null); return; } if (props.src.startsWith("data:image/")) { setResolving(false); setResolvedSrc(props.src); setResolvedStr(null); return; } if (resolveOpts == null) { setResolving(false); setResolvedSrc(null); setResolvedStr(`[img:${props.src}]`); return; } const resolveFn = async () => { const route = makeConnRoute(resolveOpts.connName); const fileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [resolveOpts.baseDir, props.src], { route: route, }); const usp = new URLSearchParams(); usp.set("path", fileInfo.path); if (!isBlank(resolveOpts.connName)) { usp.set("connection", resolveOpts.connName); } const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?" + usp.toString(); setResolvedSrc(streamingUrl); setResolvedStr(null); setResolving(false); }; resolveFn(); }, [props.src]); if (resolving) { return null; } if (resolvedStr != null) { return {resolvedStr}; } if (resolvedSrc != null) { return ; } return [img]; }; type MarkdownProps = { text?: string; textAtom?: Atom | Atom>; showTocAtom?: Atom; style?: React.CSSProperties; className?: string; onClickExecute?: (cmd: string) => void; resolveOpts?: MarkdownResolveOpts; scrollable?: boolean; }; const Markdown = ({ text, textAtom, showTocAtom, style, className, resolveOpts, scrollable = true, onClickExecute, }: MarkdownProps) => { const textAtomValue = useAtomValueSafe(textAtom); const tocRef = useRef([]); const showToc = useAtomValueSafe(showTocAtom) ?? false; const contentsOsRef = useRef(null); const [focusedHeading, setFocusedHeading] = useState(null); // Ensure uniqueness of ids between MD preview instances. const [idPrefix] = useState(crypto.randomUUID()); useEffect(() => { if (focusedHeading && contentsOsRef.current && contentsOsRef.current.osInstance()) { const { viewport } = contentsOsRef.current.osInstance().elements(); const heading = document.getElementById(idPrefix + focusedHeading.slice(1)); if (heading) { const headingBoundingRect = heading.getBoundingClientRect(); const viewportBoundingRect = viewport.getBoundingClientRect(); const headingTop = headingBoundingRect.top - viewportBoundingRect.top; viewport.scrollBy({ top: headingTop }); } } }, [focusedHeading]); const markdownComponents: Partial = { a: (props: React.HTMLAttributes) => ( ), h1: (props: React.HTMLAttributes) => , h2: (props: React.HTMLAttributes) => , h3: (props: React.HTMLAttributes) => , h4: (props: React.HTMLAttributes) => , h5: (props: React.HTMLAttributes) => , h6: (props: React.HTMLAttributes) => , img: (props: React.HTMLAttributes) => , source: (props: React.HTMLAttributes) => , code: Code, pre: (props: React.HTMLAttributes) => ( ), }; const toc = useMemo(() => { if (showToc && tocRef.current.length > 0) { return tocRef.current.map((item) => { return ( setFocusedHeading(item.href)} > {item.value} ); }); } }, [showToc, tocRef]); text = textAtomValue ?? text; const ScrollableMarkdown = () => { return ( rehypeSanitize({ ...defaultSchema, attributes: { ...defaultSchema.attributes, span: [ ...(defaultSchema.attributes?.span || []), // Allow all class names starting with `hljs-`. ["className", /^hljs-./], // Alternatively, to allow only certain class names: // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] ], }, tagNames: [...(defaultSchema.tagNames || []), "span"], }), () => rehypeSlug({ prefix: idPrefix }), ]} components={markdownComponents} > {text} ); }; const NonScrollableMarkdown = () => { return (
rehypeSanitize({ ...defaultSchema, attributes: { ...defaultSchema.attributes, span: [ ...(defaultSchema.attributes?.span || []), // Allow all class names starting with `hljs-`. ["className", /^hljs-./], // Alternatively, to allow only certain class names: // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] ], }, tagNames: [...(defaultSchema.tagNames || []), "span"], }), () => rehypeSlug({ prefix: idPrefix }), ]} components={markdownComponents} > {text}
); }; return (
{scrollable ? : } {toc && (

Table of Contents

{toc}
)}
); }; export { Markdown };