UNPKG

react-markdown-code-highlighter

Version:

`react-markdown-code-highlighter` is a flexible [React](https://react.dev) component for rendering Markdown with syntax-highlighted code blocks using [highlight.js](https://highlightjs.org/). It is designed for use in chat systems and AI assistants like C

350 lines 13.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const jsx_runtime_1 = require("react/jsx-runtime"); const react_1 = require("react"); const index_js_1 = __importDefault(require("../components/HighReactMarkdown/index.js")); const classnames_1 = __importDefault(require("classnames")); const constant_js_1 = require("../constant.js"); const MarkdownCMD = (0, react_1.forwardRef)(({ interval = 30, isClosePrettyTyped = false, onEnd, onStart, onTypedChar, theme }, ref) => { /** Current content to be typed */ const charsRef = (0, react_1.useRef)([]); /** * Whether typing is completely over * If typing is completely over, the typing effect will not be triggered again */ const isWholeTypedEndRef = (0, react_1.useRef)(false); /** Already typed characters */ const typedCharsRef = (0, react_1.useRef)(undefined); /** Whether the component is unmounted */ const isUnmountRef = (0, react_1.useRef)(false); /** Whether typing is in progress */ const isTypedRef = (0, react_1.useRef)(false); /** Typing end callback */ const onEndRef = (0, react_1.useRef)(onEnd); onEndRef.current = onEnd; /** Typing start callback */ const onStartRef = (0, react_1.useRef)(onStart); onStartRef.current = onStart; /** Typing character callback */ const onTypedCharRef = (0, react_1.useRef)(onTypedChar); onTypedCharRef.current = onTypedChar; /** Typing timer */ const timerRef = (0, react_1.useRef)(null); /** * Stable paragraphs * Stable paragraphs are those that have been typed and will not change again */ const [stableParagraphs, setStableParagraphs] = (0, react_1.useState)([]); /** Current paragraph */ const [currentParagraph, setCurrentParagraph] = (0, react_1.useState)(undefined); /** Current paragraph reference */ const currentParagraphRef = (0, react_1.useRef)(undefined); currentParagraphRef.current = currentParagraph; /** Clear typing timer */ const clearTimer = () => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } isTypedRef.current = false; }; (0, react_1.useEffect)(() => { isUnmountRef.current = false; return () => { isUnmountRef.current = true; }; }, []); /** Thinking paragraphs */ const thinkingParagraphs = (0, react_1.useMemo)(() => stableParagraphs.filter((paragraph) => paragraph.answerType === 'thinking'), [stableParagraphs]); /** Answer paragraphs */ const answerParagraphs = (0, react_1.useMemo)(() => stableParagraphs.filter((paragraph) => paragraph.answerType === 'answer'), [stableParagraphs]); /** * Record typed characters * @param char Current character * @returns */ const recordTypedChars = (char) => { let prevStr = ''; if (!typedCharsRef.current || typedCharsRef.current.answerType !== char.answerType) { typedCharsRef.current = { typedContent: char.content, answerType: char.answerType, prevStr: '', }; } else { prevStr = typedCharsRef.current.typedContent; typedCharsRef.current.typedContent += char.content; typedCharsRef.current.prevStr = prevStr; } return { prevStr, nextStr: typedCharsRef.current?.typedContent || '', }; }; /** * Trigger typing start callback * @param char Current character */ const triggerOnStart = (char) => { const onStartFn = onStartRef.current; if (!onStartFn) { return; } const { prevStr } = recordTypedChars(char); onStartRef.current?.({ currentIndex: prevStr.length, currentChar: char.content, answerType: char.answerType, prevStr, }); }; /** * Trigger typing end callback */ const triggerOnEnd = () => { const onEndFn = onEndRef.current; if (!onEndFn) { return; } onEndFn({ str: typedCharsRef.current?.typedContent, answerType: typedCharsRef.current?.answerType, }); }; /** * Trigger typing character callback * @param char Current character * @param isStartPoint Whether it is the start of typing (the first character) */ const triggerOnTypedChar = (char, isStartPoint = false) => { const onTypedCharFn = onTypedCharRef.current; if (!isStartPoint) { recordTypedChars(char); } if (!onTypedCharFn) { return; } onTypedCharFn({ currentIndex: typedCharsRef.current?.prevStr.length || 0, currentChar: char.content, answerType: char.answerType, prevStr: typedCharsRef.current?.prevStr || '', }); }; /** Start typing task */ const startTypedTask = () => { if (isTypedRef.current) { return; } const chars = charsRef.current; /** Stop typing */ const stopTyped = () => { isTypedRef.current = false; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } triggerOnEnd(); }; /** Type next character */ const nextTyped = () => { if (chars.length === 0) { stopTyped(); return; } timerRef.current = setTimeout(startTyped, interval); }; /** * Start typing * @param isStartPoint Whether it is the start of typing */ function startTyped(isStartPoint = false) { if (isUnmountRef.current) { return; } isTypedRef.current = true; const char = chars.shift(); if (char === undefined) { stopTyped(); return; } if (isStartPoint) { triggerOnStart(char); triggerOnTypedChar(char, isStartPoint); } else { triggerOnTypedChar(char); } const currentParagraph = currentParagraphRef.current; /** If encountered, need to handle as two paragraphs */ if (char.content === '\n\n') { if (currentParagraph) { setStableParagraphs((prev) => { const newParagraphs = [...prev]; if (currentParagraph) { newParagraphs.push({ ...currentParagraph, isTyped: false }); } newParagraphs.push({ content: '', isTyped: false, type: 'br', answerType: char.answerType, }); return newParagraphs; }); setCurrentParagraph(undefined); } else { setStableParagraphs((prev) => { const newParagraphs = [...prev]; newParagraphs.push({ content: '', isTyped: false, type: 'br', answerType: char.answerType, }); return newParagraphs; }); } nextTyped(); return; } // Handle current paragraph let _currentParagraph = currentParagraph; const newCurrentParagraph = { content: '', isTyped: false, type: 'text', answerType: char.answerType, }; if (!_currentParagraph) { // If there is no current paragraph, set as current paragraph _currentParagraph = newCurrentParagraph; } else if (currentParagraph && currentParagraph?.answerType !== char.answerType) { // If the current paragraph and character answerType are different, handle as two paragraphs setStableParagraphs((prev) => { const newParagraphs = [...prev]; newParagraphs.push({ ...currentParagraph, isTyped: false }); return newParagraphs; }); _currentParagraph = newCurrentParagraph; setCurrentParagraph(_currentParagraph); } setCurrentParagraph((prev) => { return { ..._currentParagraph, content: (prev?.content || '') + char.content, isTyped: true, }; }); nextTyped(); } startTyped(true); }; (0, react_1.useImperativeHandle)(ref, () => ({ /** * Add content * @param content Content {string} * @param answerType Answer type {AnswerType} */ push: (content, answerType) => { if (isWholeTypedEndRef.current) { if (constant_js_1.__DEV__) { console.warn('Typing is already complete, unable to add new content'); } return; } // If there are two \n, treat them as one character, and merge multiple \n into one \n const fencedContent = processFencedCodeBlocks(content).content; const charsGroup = fencedContent.split('\n\n'); charsGroup.forEach((chars, index) => { if (isClosePrettyTyped) { charsRef.current.push({ content: chars, answerType }); } else { charsRef.current.push(...chars.split('').map((char) => ({ content: char, answerType }))); } if (index !== charsGroup.length - 1) { charsRef.current.push({ content: '\n\n', answerType }); } }); if (!isTypedRef.current) { startTypedTask(); } }, /** * Clear typing task */ clear: () => { clearTimer(); charsRef.current = []; setStableParagraphs([]); setCurrentParagraph(undefined); isWholeTypedEndRef.current = false; }, /** * Manually trigger typing end */ triggerWholeEnd: () => { isWholeTypedEndRef.current = true; if (!isTypedRef.current) { triggerOnEnd(); } }, })); const getParagraphs = (paragraphs, answerType) => { return ((0, jsx_runtime_1.jsxs)("div", { className: `ds-markdown-paragraph ds-typed-${answerType}`, children: [paragraphs.map((paragraph, index) => { if (paragraph.type === 'br') { return null; } return ((0, jsx_runtime_1.jsx)(index_js_1.default, { theme: theme, children: paragraph.content || '' }, index)); }), currentParagraph?.answerType === answerType && ((0, jsx_runtime_1.jsx)(index_js_1.default, { theme: theme, children: currentParagraph.content || '' }, currentParagraph.content))] })); }; /** * Detect fenced code blocks in markdown content and replace '\n\n' with '\n \n' inside them. * Returns the modified content and an array of code block ranges. * @param content The markdown string */ const processFencedCodeBlocks = (content) => { const lines = content.split('\n'); const blocks = []; let inCodeBlock = false; let codeBlockStart = -1; let codeBlockFence = ''; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); if (fenceMatch) { if (!inCodeBlock) { inCodeBlock = true; codeBlockStart = i; codeBlockFence = fenceMatch[1]; } else if (line.trim().startsWith(codeBlockFence)) { inCodeBlock = false; blocks.push({ start: codeBlockStart, end: i, fence: codeBlockFence }); codeBlockStart = -1; codeBlockFence = ''; } } // If in code block, replace any empty line after this line if (inCodeBlock && i < lines.length - 1 && lines[i + 1] === '') { lines[i + 1] = ' '; } } // After processing, join lines back const processedContent = lines.join('\n'); return { content: processedContent, blocks }; }; return ((0, jsx_runtime_1.jsxs)("div", { className: (0, classnames_1.default)({ 'ds-markdown': true, apple: true, }), children: [(thinkingParagraphs.length > 0 || currentParagraph?.answerType === 'thinking') && (0, jsx_runtime_1.jsx)("div", { className: "ds-markdown-thinking", children: getParagraphs(thinkingParagraphs, 'thinking') }), (answerParagraphs.length > 0 || currentParagraph?.answerType === 'answer') && (0, jsx_runtime_1.jsx)("div", { className: "ds-markdown-answer", children: getParagraphs(answerParagraphs, 'answer') })] })); }); exports.default = MarkdownCMD; //# sourceMappingURL=index.js.map