// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { CopyButton } from "@/app/element/copybutton"; import { createContentBlockPlugin } from "@/app/element/markdown-contentblock-plugin"; import { MarkdownContentBlockType, resolveRemoteFile, resolveSrcSet, transformBlocks, } from "@/app/element/markdown-util"; import { boundNumber, 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 { openLink } from "../store/global"; import { IconButton } from "./iconbutton"; import "./markdown.scss"; 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, resolveOpts, }: { props: React.HTMLAttributes & { srcSet?: string; media?: string; }; resolveOpts: MarkdownResolveOpts; }) => { const [resolvedSrcSet, setResolvedSrcSet] = useState(props.srcSet); const [resolving, setResolving] = useState(true); useEffect(() => { const resolvePath = async () => { const resolved = await resolveSrcSet(props.srcSet, resolveOpts); setResolvedSrcSet(resolved); setResolving(false); }; resolvePath(); }, [props.srcSet]); if (resolving) { return null; } return ; }; interface WaveBlockProps { blockkey: string; blockmap: Map; } const WaveBlock: React.FC = (props) => { const { blockkey, blockmap } = props; const block = blockmap.get(blockkey); if (block == null) { return null; } const sizeInKB = Math.round((block.content.length / 1024) * 10) / 10; const displayName = block.id.replace(/^"|"$/g, ""); return (
{displayName} {sizeInKB} KB
); }; const MarkdownImg = ({ props, resolveOpts, }: { props: React.ImgHTMLAttributes; resolveOpts: MarkdownResolveOpts; }) => { const [resolvedSrc, setResolvedSrc] = useState(props.src); const [resolvedSrcSet, setResolvedSrcSet] = useState(props.srcSet); const [resolvedStr, setResolvedStr] = useState(null); const [resolving, setResolving] = useState(true); useEffect(() => { 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 [resolvedSrc, resolvedSrcSet] = await Promise.all([ resolveRemoteFile(props.src, resolveOpts), resolveSrcSet(props.srcSet, resolveOpts), ]); setResolvedSrc(resolvedSrc); setResolvedSrcSet(resolvedSrcSet); setResolvedStr(null); setResolving(false); }; resolveFn(); }, [props.src, props.srcSet]); 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; rehype?: boolean; fontSizeOverride?: number; fixedFontSizeOverride?: number; }; const Markdown = ({ text, textAtom, showTocAtom, style, className, resolveOpts, fontSizeOverride, fixedFontSizeOverride, scrollable = true, rehype = 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()); text = textAtomValue ?? text; const transformedOutput = transformBlocks(text); const transformedText = transformedOutput.content; const contentBlocksMap = transformedOutput.blocks; 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) => ( ), p: (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) => ( ), }; markdownComponents["waveblock"] = (props: any) => ; const toc = useMemo(() => { if (showToc && tocRef.current.length > 0) { return tocRef.current.map((item) => { return ( setFocusedHeading(item.href)} > {item.value} ); }); } }, [showToc, tocRef]); let rehypePlugins = null; if (rehype) { rehypePlugins = [ rehypeRaw, rehypeHighlight, () => rehypeSanitize({ ...defaultSchema, attributes: { ...defaultSchema.attributes, span: [ ...(defaultSchema.attributes?.span || []), // Allow all class names starting with `hljs-`. ["className", /^hljs-./], ["srcset"], ["media"], ["type"], // Alternatively, to allow only certain class names: // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] ], waveblock: [["blockkey"]], }, tagNames: [...(defaultSchema.tagNames || []), "span", "waveblock", "picture", "source"], }), () => rehypeSlug({ prefix: idPrefix }), ]; } const remarkPlugins: any = [ remarkGfm, [RemarkFlexibleToc, { tocRef: tocRef.current }], [createContentBlockPlugin, { blocks: contentBlocksMap }], ]; const ScrollableMarkdown = () => { return ( {transformedText} ); }; const NonScrollableMarkdown = () => { return (
{transformedText}
); }; const mergedStyle = { ...style }; if (fontSizeOverride != null) { mergedStyle["--markdown-font-size"] = `${boundNumber(fontSizeOverride, 6, 64)}px`; } if (fixedFontSizeOverride != null) { mergedStyle["--markdown-fixed-font-size"] = `${boundNumber(fixedFontSizeOverride, 6, 64)}px`; } return (
{scrollable ? : } {toc && (

Table of Contents

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