mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-11 14:10:15 -07:00
Share Blocks
Note: there's one dependency to ../../app/chat inside
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { IconButton, Tooltip } from '@mui/joy';
|
||||
|
||||
import { CodePenIcon } from '~/common/components/icons/3rdparty/CodePenIcon';
|
||||
import { Brand } from '~/common/app.config';
|
||||
|
||||
|
||||
// CodePen is a web-based HTML, CSS, and JavaScript code editor
|
||||
const _languages = ['html', 'css', 'javascript', 'json', 'typescript'];
|
||||
|
||||
export function isCodePenSupported(language: string | null, isSVG: boolean) {
|
||||
return isSVG || (!!language && _languages.includes(language));
|
||||
}
|
||||
|
||||
const handleOpenInCodePen = (code: string, language: string) => {
|
||||
// CodePen has 3 editors: HTML, CSS, JS - we decide here where to put the code
|
||||
const hasCSS = language === 'css';
|
||||
const hasJS = language ? ['javascript', 'json', 'typescript'].includes(language) : false;
|
||||
const hasHTML = !hasCSS && !hasJS; // use HTML as fallback if an unanticipated frontend language is used
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.action = 'https://codepen.io/pen/define';
|
||||
form.method = 'POST';
|
||||
form.target = '_blank';
|
||||
|
||||
const payload = {
|
||||
title: `${Brand.Title.Base} Code - ${new Date().toISOString()}`, // eg "GPT 2021-08-31T15:00:00.000Z"
|
||||
css: hasCSS ? code : '',
|
||||
html: hasHTML ? code : '',
|
||||
js: hasJS ? code : '',
|
||||
editors: `${hasHTML ? 1 : 0}${hasCSS ? 1 : 0}${hasJS ? 1 : 0}`, // eg '101' for HTML, JS
|
||||
};
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'data';
|
||||
input.value = JSON.stringify(payload);
|
||||
form.appendChild(input);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
};
|
||||
|
||||
|
||||
export function ButtonCodePen(props: { code: string, language: string }): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip title='Open in CodePen' variant='solid'>
|
||||
<IconButton variant='outlined' color='neutral' onClick={() => handleOpenInCodePen(props.code, props.language)}>
|
||||
<CodePenIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { IconButton, Tooltip } from '@mui/joy';
|
||||
|
||||
|
||||
// JSFiidle is a web-based HTML, CSS, and JavaScript code editor
|
||||
const _languages = ['html', 'css', 'javascript', 'json', 'typescript'];
|
||||
|
||||
export function isJSFiddleSupported(language: string | null, code: string) {
|
||||
return !!language && _languages.includes(language) && code?.length > 10;
|
||||
}
|
||||
|
||||
const handleOpenInJsFiddle = (code: string, language: string) => {
|
||||
// heuristics to assing the code to one of the blocks
|
||||
const isHTML = language === 'html';
|
||||
const isCSS = language === 'css';
|
||||
const isJSorUnknown = !isHTML && !isCSS;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.action = 'https://jsfiddle.net/api/post/library/pure/';
|
||||
form.method = 'POST';
|
||||
form.target = '_blank'; // Open in a new tab
|
||||
|
||||
// Dynamically determine what to populate based on language or content type
|
||||
const inputHtml = document.createElement('input');
|
||||
inputHtml.type = 'hidden';
|
||||
inputHtml.name = 'html'; // For HTML content
|
||||
inputHtml.value = isHTML ? code : '';
|
||||
form.appendChild(inputHtml);
|
||||
|
||||
const inputCss = document.createElement('input');
|
||||
inputCss.type = 'hidden';
|
||||
inputCss.name = 'css'; // For CSS content
|
||||
inputCss.value = isCSS ? code : '';
|
||||
form.appendChild(inputCss);
|
||||
|
||||
const inputJs = document.createElement('input');
|
||||
inputJs.type = 'hidden';
|
||||
inputJs.name = 'js'; // For JavaScript content
|
||||
inputJs.value = isJSorUnknown ? code : '';
|
||||
form.appendChild(inputJs);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
};
|
||||
|
||||
|
||||
export function ButtonJsFiddle(props: { code: string, language: string }): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip title='Open in JSFiddle' variant='solid'>
|
||||
<IconButton onClick={() => handleOpenInJsFiddle(props.code, props.language)}>
|
||||
JS
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { IconButton, Tooltip } from '@mui/joy';
|
||||
|
||||
import { StackBlitzIcon } from '~/common/components/icons/3rdparty/StackBlitzIcon';
|
||||
import { Brand } from '~/common/app.config';
|
||||
|
||||
|
||||
const _languages = [
|
||||
'typescript',
|
||||
'javascript', 'json',
|
||||
'html', 'css',
|
||||
// 'python',
|
||||
];
|
||||
|
||||
// Mapping of languages to StackBlitz templates
|
||||
const languageToTemplateMapping: { [language: string]: string } = {
|
||||
typescript: 'typescript',
|
||||
javascript: 'javascript', json: 'javascript',
|
||||
html: 'html', css: 'html',
|
||||
// python: 'secret-python', // webcontainers? secret-python? python?
|
||||
};
|
||||
|
||||
// Mapping of languages to their primary file names in StackBlitz
|
||||
const languageToFileExtensionMapping: { [language: string]: string } = {
|
||||
typescript: 'index.ts',
|
||||
javascript: 'index.js', json: 'data.json',
|
||||
html: 'index.html', css: 'style.css',
|
||||
// python: 'main.py',
|
||||
};
|
||||
|
||||
|
||||
export function isStackBlitzSupported(language: string | null) {
|
||||
return !!language && _languages.includes(language);
|
||||
}
|
||||
|
||||
const handleOpenInStackBlitz = (code: string, language: string, title?: string) => {
|
||||
|
||||
const template = languageToTemplateMapping[language] || 'javascript'; // Fallback to 'javascript'
|
||||
const fileName = languageToFileExtensionMapping[language] || 'index.js'; // Fallback to 'index.js'
|
||||
|
||||
const projectDetails = {
|
||||
files: { [fileName]: code },
|
||||
template: template,
|
||||
description: `${Brand.Title.Common} file created on ${new Date().toISOString()}`,
|
||||
title: language == 'python' ? 'Python Starter' : title,
|
||||
} as const;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.action = 'https://stackblitz.com/run';
|
||||
form.method = 'POST';
|
||||
form.target = '_blank';
|
||||
|
||||
const addField = (name: string, value: string) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
};
|
||||
|
||||
Object.keys(projectDetails.files).forEach((filePath) => {
|
||||
addField(`project[files][${filePath}]`, projectDetails.files[filePath]);
|
||||
});
|
||||
|
||||
addField('project[description]', projectDetails.description);
|
||||
addField('project[template]', projectDetails.template);
|
||||
!!projectDetails.title && addField('project[title]', projectDetails.title);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
};
|
||||
|
||||
|
||||
export function ButtonStackBlitz(props: { code: string, language: string, title?: string }): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip title='Open in StackBlitz' variant='solid'>
|
||||
<IconButton variant='outlined' color='neutral' onClick={() => handleOpenInStackBlitz(props.code, props.language, props.title)}>
|
||||
<StackBlitzIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, ButtonGroup, IconButton, Sheet, Tooltip, Typography } from '@mui/joy';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import FitScreenIcon from '@mui/icons-material/FitScreen';
|
||||
import HtmlIcon from '@mui/icons-material/Html';
|
||||
import SchemaIcon from '@mui/icons-material/Schema';
|
||||
import ShapeLineOutlinedIcon from '@mui/icons-material/ShapeLineOutlined';
|
||||
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
|
||||
import type { CodeBlock } from '../blocks';
|
||||
import { ButtonCodePen, isCodePenSupported } from './ButtonCodePen';
|
||||
import { ButtonJsFiddle, isJSFiddleSupported } from './ButtonJSFiddle';
|
||||
import { ButtonStackBlitz, isStackBlitzSupported } from './ButtonStackBlitz';
|
||||
import { heuristicIsHtml, IFrameComponent } from '../RenderHtml';
|
||||
import { patchSvgString, RenderCodeMermaid } from './RenderCodeMermaid';
|
||||
|
||||
|
||||
async function fetchPlantUmlSvg(plantUmlCode: string): Promise<string | null> {
|
||||
// fetch the PlantUML SVG
|
||||
let text: string = '';
|
||||
try {
|
||||
// Dynamically import the PlantUML encoder - it's a large library that slows down app loading
|
||||
const { encode: plantUmlEncode } = await import('plantuml-encoder');
|
||||
|
||||
// retrieve and manually adapt the SVG, to remove the background
|
||||
const encodedPlantUML: string = plantUmlEncode(plantUmlCode);
|
||||
const response = await fetch(`https://www.plantuml.com/plantuml/svg/${encodedPlantUML}`);
|
||||
text = await response.text();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
// validate/extract the SVG
|
||||
const start = text.indexOf('<svg ');
|
||||
const end = text.indexOf('</svg>');
|
||||
if (start < 0 || end <= start)
|
||||
throw new Error('Could not render PlantUML');
|
||||
const svg = text
|
||||
.slice(start, end + 6) // <svg ... </svg>
|
||||
.replace('background:#FFFFFF;', ''); // transparent background
|
||||
|
||||
// check for syntax errors
|
||||
if (svg.includes('>Syntax Error?</text>'))
|
||||
throw new Error('syntax issue (it happens!). Please regenerate or change generator model.');
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
|
||||
export const overlayButtonsSx: SxProps = {
|
||||
position: 'absolute', top: 0, right: 0, zIndex: 10,
|
||||
display: 'flex', flexDirection: 'row', gap: 1,
|
||||
opacity: 0, transition: 'opacity 0.2s',
|
||||
// '& > button': {
|
||||
// backgroundColor: 'background.level2',
|
||||
// backdropFilter: 'blur(12px)',
|
||||
// },
|
||||
};
|
||||
|
||||
|
||||
interface RenderCodeBaseProps {
|
||||
codeBlock: CodeBlock,
|
||||
isMobile?: boolean,
|
||||
noCopyButton?: boolean,
|
||||
optimizeLightweight?: boolean,
|
||||
sx?: SxProps,
|
||||
}
|
||||
|
||||
interface RenderCodeImplProps extends RenderCodeBaseProps {
|
||||
highlightCode: (inferredCodeLanguage: string | null, blockCode: string) => string,
|
||||
inferCodeLanguage: (blockTitle: string, code: string) => string | null,
|
||||
}
|
||||
|
||||
function RenderCodeImpl(props: RenderCodeImplProps) {
|
||||
|
||||
// state
|
||||
const [fitScreen, setFitScreen] = React.useState(!!props.isMobile);
|
||||
const [showHTML, setShowHTML] = React.useState(false);
|
||||
const [showMermaid, setShowMermaid] = React.useState(true);
|
||||
const [showPlantUML, setShowPlantUML] = React.useState(true);
|
||||
const [showSVG, setShowSVG] = React.useState(true);
|
||||
|
||||
// derived props
|
||||
const {
|
||||
codeBlock: { blockTitle, blockCode, complete: blockComplete },
|
||||
highlightCode, inferCodeLanguage,
|
||||
optimizeLightweight,
|
||||
} = props;
|
||||
|
||||
// heuristic for language, and syntax highlight
|
||||
const { highlightedCode, inferredCodeLanguage } = React.useMemo(() => {
|
||||
const inferredCodeLanguage = inferCodeLanguage(blockTitle, blockCode);
|
||||
const highlightedCode = highlightCode(inferredCodeLanguage, blockCode);
|
||||
return { highlightedCode, inferredCodeLanguage };
|
||||
}, [inferCodeLanguage, blockTitle, blockCode, highlightCode]);
|
||||
|
||||
|
||||
// heuristics for specialized rendering
|
||||
|
||||
const isHTML = heuristicIsHtml(blockCode);
|
||||
const renderHTML = isHTML && showHTML;
|
||||
|
||||
const isMermaid = blockTitle === 'mermaid' && blockComplete;
|
||||
const renderMermaid = isMermaid && showMermaid;
|
||||
|
||||
const isPlantUML =
|
||||
(blockCode.startsWith('@startuml') && blockCode.endsWith('@enduml'))
|
||||
|| (blockCode.startsWith('@startmindmap') && blockCode.endsWith('@endmindmap'))
|
||||
|| (blockCode.startsWith('@startsalt') && blockCode.endsWith('@endsalt'))
|
||||
|| (blockCode.startsWith('@startwbs') && blockCode.endsWith('@endwbs'))
|
||||
|| (blockCode.startsWith('@startgantt') && blockCode.endsWith('@endgantt'));
|
||||
|
||||
let renderPlantUML = isPlantUML && showPlantUML;
|
||||
const { data: plantUmlHtmlData, error: plantUmlError } = useQuery({
|
||||
enabled: renderPlantUML,
|
||||
queryKey: ['plantuml', blockCode],
|
||||
queryFn: () => fetchPlantUmlSvg(blockCode),
|
||||
staleTime: 24 * 60 * 60 * 1000, // 1 day
|
||||
});
|
||||
renderPlantUML = renderPlantUML && (!!plantUmlHtmlData || !!plantUmlError);
|
||||
|
||||
const isSVG = blockCode.startsWith('<svg') && blockCode.endsWith('</svg>');
|
||||
const renderSVG = isSVG && showSVG;
|
||||
const canScaleSVG = renderSVG && blockCode.includes('viewBox="');
|
||||
|
||||
|
||||
const canCodePen = blockComplete && isCodePenSupported(inferredCodeLanguage, isSVG);
|
||||
const canJSFiddle = blockComplete && isJSFiddleSupported(inferredCodeLanguage, blockCode);
|
||||
const canStackBlitz = blockComplete && isStackBlitzSupported(inferredCodeLanguage);
|
||||
|
||||
|
||||
const handleCopyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(blockCode, 'Code');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'relative', /* for overlay buttons to stick properly */
|
||||
}}>
|
||||
|
||||
{/* Code render */}
|
||||
<Box
|
||||
component='code'
|
||||
className={`language-${inferredCodeLanguage || 'unknown'}`}
|
||||
sx={{
|
||||
fontWeight: 500, whiteSpace: 'pre', // was 'break-spaces' before we implemented per-block scrolling
|
||||
mx: 0, p: 1.5, // this block gets a thicker border
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
minWidth: 160,
|
||||
'&:hover > .overlay-buttons': { opacity: 1 },
|
||||
...(props.sx || {}),
|
||||
}}>
|
||||
|
||||
{/* Markdown Title (File/Type) */}
|
||||
{blockTitle != inferredCodeLanguage && blockTitle.includes('.') && (
|
||||
<Sheet sx={{ boxShadow: 'sm', borderRadius: 'sm', mb: 1 }}>
|
||||
<Typography level='title-sm' sx={{ px: 1, py: 0.5 }}>
|
||||
{blockTitle}
|
||||
{/*{inferredCodeLanguage}*/}
|
||||
</Typography>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Renders HTML, or inline SVG, inline plantUML rendered, or highlighted code */}
|
||||
{renderHTML
|
||||
? <IFrameComponent htmlString={blockCode} />
|
||||
: renderMermaid
|
||||
? <RenderCodeMermaid mermaidCode={blockCode} fitScreen={fitScreen} />
|
||||
: <Box component='div'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
renderSVG
|
||||
? (patchSvgString(fitScreen, blockCode) || 'No SVG code')
|
||||
: renderPlantUML
|
||||
? (patchSvgString(fitScreen, plantUmlHtmlData) || (plantUmlError as string) || 'No PlantUML rendering.')
|
||||
: highlightedCode,
|
||||
}}
|
||||
sx={{
|
||||
...(renderSVG ? { lineHeight: 0 } : {}),
|
||||
...(renderPlantUML ? { textAlign: 'center' } : {}),
|
||||
}}
|
||||
/>}
|
||||
|
||||
{/* Buttons */}
|
||||
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, p: 0.5 }}>
|
||||
{isHTML && (
|
||||
<Tooltip title={optimizeLightweight ? null : renderHTML ? 'Hide' : 'Show Web Page'}>
|
||||
<IconButton variant={renderHTML ? 'solid' : 'soft'} color='danger' onClick={() => setShowHTML(!showHTML)}>
|
||||
<HtmlIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isMermaid && (
|
||||
<Tooltip title={optimizeLightweight ? null : renderMermaid ? 'Show Code' : 'Render Mermaid'}>
|
||||
<IconButton variant={renderMermaid ? 'solid' : 'soft'} onClick={() => setShowMermaid(!showMermaid)}>
|
||||
<SchemaIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPlantUML && (
|
||||
<Tooltip title={optimizeLightweight ? null : renderPlantUML ? 'Show Code' : 'Render PlantUML'}>
|
||||
<IconButton variant={renderPlantUML ? 'solid' : 'soft'} onClick={() => setShowPlantUML(!showPlantUML)}>
|
||||
<SchemaIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isSVG && (
|
||||
<Tooltip title={optimizeLightweight ? null : renderSVG ? 'Show Code' : 'Render SVG'}>
|
||||
<IconButton variant={renderSVG ? 'solid' : 'soft'} onClick={() => setShowSVG(!showSVG)}>
|
||||
<ShapeLineOutlinedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{((isMermaid && showMermaid) || (isPlantUML && showPlantUML) || (isSVG && showSVG && canScaleSVG)) && (
|
||||
<Tooltip title={optimizeLightweight ? null : fitScreen ? 'Original Size' : 'Fit Screen'}>
|
||||
<IconButton variant={fitScreen ? 'solid' : 'soft'} onClick={() => setFitScreen(on => !on)}>
|
||||
<FitScreenIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(canJSFiddle || canCodePen || canStackBlitz) && (
|
||||
<ButtonGroup aria-label='Open code in external editors' sx={{ cornerRadius: 'md' }}>
|
||||
{canJSFiddle && <ButtonJsFiddle code={blockCode} language={inferredCodeLanguage!} />}
|
||||
{canCodePen && <ButtonCodePen code={blockCode} language={inferredCodeLanguage!} />}
|
||||
{canStackBlitz && <ButtonStackBlitz code={blockCode} title={blockTitle} language={inferredCodeLanguage!} />}
|
||||
</ButtonGroup>
|
||||
)}
|
||||
{props.noCopyButton !== true && (
|
||||
<Tooltip title={optimizeLightweight ? null : 'Copy Code'}>
|
||||
<IconButton variant='soft' onClick={handleCopyToClipboard}>
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Dynamically import the heavy prism functions
|
||||
const RenderCodeDynamic = React.lazy(async () => {
|
||||
|
||||
// Dynamically import the code highlight functions
|
||||
const { highlightCode, inferCodeLanguage } = await import('./codePrism');
|
||||
|
||||
return {
|
||||
default: (props: RenderCodeBaseProps) =>
|
||||
<RenderCodeImpl highlightCode={highlightCode} inferCodeLanguage={inferCodeLanguage} {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
export function RenderCode(props: RenderCodeBaseProps) {
|
||||
return (
|
||||
<React.Suspense fallback={<Box component='code' sx={{ p: 1.5, display: 'block', ...props.sx }} />}>
|
||||
<RenderCodeDynamic {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export const RenderCodeMemo = React.memo(RenderCode);
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as React from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { appTheme } from '~/common/app.theme';
|
||||
import { isBrowser } from '~/common/util/pwaUtils';
|
||||
|
||||
|
||||
/**
|
||||
* We are loading Mermaid from the CDN (and spending all the work to dynamically load it
|
||||
* and strong type it), because the Mermaid dependencies (npm i mermaid) are too heavy
|
||||
* and would slow down development for everyone.
|
||||
*
|
||||
* If you update this file, also make sure the interfaces/type definitions and initialization
|
||||
* options are updated accordingly.
|
||||
*/
|
||||
const MERMAID_CDN_FILE: string = 'https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js';
|
||||
|
||||
|
||||
interface MermaidAPI {
|
||||
initialize: (config: any) => void;
|
||||
render: (id: string, text: string, svgContainingElement?: Element) => Promise<{ svg: string, bindFunctions?: (element: Element) => void }>;
|
||||
}
|
||||
|
||||
// extend the Window interface, to allow for the mermaid API to be found
|
||||
declare global {
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
interface Window {
|
||||
mermaid: MermaidAPI;
|
||||
}
|
||||
}
|
||||
|
||||
interface MermaidAPIStore {
|
||||
mermaidAPI: MermaidAPI | null,
|
||||
loadingError: string | null,
|
||||
}
|
||||
|
||||
const useMermaidStore = create<MermaidAPIStore>()(
|
||||
() => ({
|
||||
mermaidAPI: null,
|
||||
loadingError: null,
|
||||
}),
|
||||
);
|
||||
|
||||
let loadingStarted: boolean = false;
|
||||
let loadingError: string | null = null;
|
||||
|
||||
|
||||
function loadMermaidFromCDN() {
|
||||
if (isBrowser && !loadingStarted) {
|
||||
loadingStarted = true;
|
||||
const script = document.createElement('script');
|
||||
script.src = MERMAID_CDN_FILE;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
useMermaidStore.setState({
|
||||
mermaidAPI: initializeMermaid(window.mermaid),
|
||||
loadingError: null,
|
||||
});
|
||||
};
|
||||
script.onerror = () => {
|
||||
useMermaidStore.setState({
|
||||
mermaidAPI: null,
|
||||
loadingError: `Script load error for ${script.src}`,
|
||||
});
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeMermaid(mermaidAPI: MermaidAPI): MermaidAPI {
|
||||
mermaidAPI.initialize({
|
||||
startOnLoad: false,
|
||||
|
||||
// gfx options
|
||||
fontFamily: appTheme.fontFamily.code,
|
||||
altFontFamily: appTheme.fontFamily.body,
|
||||
|
||||
// style configuration
|
||||
htmlLabels: true,
|
||||
securityLevel: 'loose',
|
||||
theme: 'forest',
|
||||
|
||||
// per-chart configuration
|
||||
mindmap: { useMaxWidth: false },
|
||||
flowchart: { useMaxWidth: false },
|
||||
sequence: { useMaxWidth: false },
|
||||
timeline: { useMaxWidth: false },
|
||||
class: { useMaxWidth: false },
|
||||
state: { useMaxWidth: false },
|
||||
pie: { useMaxWidth: false },
|
||||
er: { useMaxWidth: false },
|
||||
gantt: { useMaxWidth: false },
|
||||
gitGraph: { useMaxWidth: false },
|
||||
});
|
||||
return mermaidAPI;
|
||||
}
|
||||
|
||||
function useMermaidLoader() {
|
||||
const { mermaidAPI } = useMermaidStore();
|
||||
React.useEffect(() => {
|
||||
if (!mermaidAPI)
|
||||
loadMermaidFromCDN();
|
||||
}, [mermaidAPI]);
|
||||
return { mermaidAPI, isSuccess: !!mermaidAPI, isLoading: loadingStarted, error: loadingError };
|
||||
}
|
||||
|
||||
|
||||
export function RenderCodeMermaid(props: { mermaidCode: string, fitScreen: boolean }) {
|
||||
|
||||
// state
|
||||
const [_svgCode, setSvgCode] = React.useState<string | null>(null);
|
||||
const hasUnmounted = React.useRef(false);
|
||||
const mermaidContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// external state
|
||||
const { mermaidAPI, error: mermaidError } = useMermaidLoader();
|
||||
|
||||
|
||||
// [effect] re-render on code changes
|
||||
React.useEffect(() => {
|
||||
|
||||
if (!mermaidAPI)
|
||||
return;
|
||||
|
||||
const updateSvgCode = () => {
|
||||
const elementId = `mermaid-${Math.random().toString(36).substring(2, 9)}`;
|
||||
mermaidAPI
|
||||
.render(elementId, props.mermaidCode, mermaidContainerRef.current!)
|
||||
.then(({ svg }) => {
|
||||
if (mermaidContainerRef.current && !hasUnmounted.current) {
|
||||
setSvgCode(svg);
|
||||
// bindFunctions?.(mermaidContainerRef.current);
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
console.warn('The AI-generated Mermaid code is invalid, please try again. Details below:\n >>', error.message),
|
||||
);
|
||||
};
|
||||
|
||||
// strict-mode de-bounce, plus watch for unmounts
|
||||
hasUnmounted.current = false;
|
||||
const timeout = setTimeout(updateSvgCode, 0);
|
||||
return () => {
|
||||
hasUnmounted.current = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [mermaidAPI, props.mermaidCode]);
|
||||
|
||||
|
||||
// render errors when loading Mermaid. for syntax errors, the Error SVG will be rendered in-place
|
||||
if (mermaidError)
|
||||
return <div>Error: {mermaidError}</div>;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component='div'
|
||||
ref={mermaidContainerRef}
|
||||
dangerouslySetInnerHTML={{ __html: patchSvgString(props.fitScreen, _svgCode) || 'Loading Diagram...' }}
|
||||
/>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
export function patchSvgString(fitScreen: boolean, svgCode?: string | null): string | null {
|
||||
return fitScreen ? svgCode?.replace('<svg ', `<svg style="width: 100%; height: 100%; object-fit: contain" `) || null : svgCode || null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Prism from 'prismjs';
|
||||
|
||||
// per-language JS plugins
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-mermaid';
|
||||
import 'prismjs/components/prism-plant-uml';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
|
||||
// NOTE: must match Prism components imports
|
||||
const hPrismLanguages = ['bash', 'css', 'java', 'javascript', 'json', 'markdown', 'mermaid', 'plant-uml', 'python', 'typescript'];
|
||||
|
||||
const hFileExtensionsMap: { [key: string]: string } = {
|
||||
cs: 'csharp', html: 'html', java: 'java', js: 'javascript', json: 'json', jsx: 'javascript',
|
||||
md: 'markdown', mmd: 'mermaid', py: 'python', sh: 'bash', ts: 'typescript', tsx: 'typescript', xml: 'xml',
|
||||
};
|
||||
|
||||
const hCodeIncipitMap: { starts: string[], language: string }[] = [
|
||||
{ starts: ['<!DOCTYPE html', '<html'], language: 'html' },
|
||||
{ starts: ['<'], language: 'xml' },
|
||||
{ starts: ['from '], language: 'python' },
|
||||
{ starts: ['import ', 'export '], language: 'typescript' }, // or python
|
||||
{ starts: ['interface ', 'function '], language: 'typescript' }, // ambiguous
|
||||
{ starts: ['package '], language: 'java' },
|
||||
{ starts: ['using '], language: 'csharp' },
|
||||
{ starts: ['@startuml', '@startmindmap', '@startsalt', '@startwbs', '@startgantt'], language: 'plant-uml' },
|
||||
];
|
||||
|
||||
|
||||
export function inferCodeLanguage(blockTitle: string, code: string): string | null {
|
||||
|
||||
// if we have a block title, use it to infer the language
|
||||
if (blockTitle) {
|
||||
// single word: assume it's the syntax highlight language
|
||||
if (!blockTitle.includes('.'))
|
||||
return hFileExtensionsMap.hasOwnProperty(blockTitle) ? hFileExtensionsMap[blockTitle] : blockTitle;
|
||||
|
||||
// file extension: map back to a language
|
||||
const extension = blockTitle.split('.').pop();
|
||||
if (extension && hFileExtensionsMap.hasOwnProperty(extension))
|
||||
return hFileExtensionsMap[extension];
|
||||
}
|
||||
|
||||
// or, based on the first line of code, return the language
|
||||
for (const codeIncipit of hCodeIncipitMap)
|
||||
if (codeIncipit.starts.some((start) => code.startsWith(start)))
|
||||
return codeIncipit.language;
|
||||
|
||||
// or, use Prism with language tokenization to and-detect the language
|
||||
// FIXME: this is a very poor way to detect the language, as it's tokenizing it in any language
|
||||
// and getting the one with the most tokens - which may as well be the wrong one
|
||||
let detectedLanguage: string | null = null;
|
||||
let maxTokens = 0;
|
||||
hPrismLanguages.forEach((language) => {
|
||||
const grammar = Prism.languages[language];
|
||||
// Load the specified language if it's not loaded yet
|
||||
// NOTE: this is commented out because it inflates the size of the bundle by 200k
|
||||
// if (!Prism.languages[language]) {
|
||||
// try {
|
||||
// require(`prismjs/components/prism-${language}`);
|
||||
// } catch (e) {
|
||||
// console.warn(`Prism language '${language}' not found, falling back to 'typescript'`);
|
||||
// }
|
||||
// }
|
||||
const tokens = Prism.tokenize(code, grammar);
|
||||
const tokenCount = tokens.filter((token) => typeof token !== 'string').length;
|
||||
if (tokenCount > maxTokens) {
|
||||
maxTokens = tokenCount;
|
||||
detectedLanguage = language;
|
||||
}
|
||||
});
|
||||
return detectedLanguage;
|
||||
}
|
||||
|
||||
export function highlightCode(inferredCodeLanguage: string | null, blockCode: string): string {
|
||||
// NOTE: to save power, we could skip highlighting until the block is complete (future feature)
|
||||
const safeHighlightLanguage = inferredCodeLanguage || 'typescript';
|
||||
return Prism.highlight(
|
||||
blockCode,
|
||||
Prism.languages[safeHighlightLanguage] || Prism.languages.typescript,
|
||||
safeHighlightLanguage,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user