Skip to content

Commit

Permalink
frontend: adds preview to html code snippets (cohere-ai#187)
Browse files Browse the repository at this point in the history
Co-authored-by: Tomeu Cabot <[email protected]>
Co-authored-by: Abel Essiane <[email protected]>
  • Loading branch information
tomtobac and coessiane authored Jun 10, 2024
1 parent 7c65779 commit 3447bdb
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 2 deletions.
66 changes: 66 additions & 0 deletions src/interfaces/coral_web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/interfaces/coral_web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"react-syntax-highlighter": "^15.5.0",
"rehype-highlight": "^7.0.0",
"rehype-katex": "^7.0.0",
"rehype-raw": "^7.0.0",
"remark": "^15.0.1",
"remark-directive": "^3.0.0",
"remark-gfm": "^4.0.0",
Expand Down
9 changes: 8 additions & 1 deletion src/interfaces/coral_web/src/components/MessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import {
MessageType,
isAbortedMessage,
isErroredMessage,
isFulfilledMessage,
isFulfilledOrTypingMessage,
isLoadingMessage,
} from '@/types/message';
import { cn } from '@/utils';
import { replaceCodeBlockWithIframe } from '@/utils/preview';

type Props = {
isLast: boolean;
Expand Down Expand Up @@ -100,13 +102,18 @@ export const MessageContent: React.FC<Props> = ({ isLast, message, onRetry }) =>
} else {
const hasCitations =
isTypingOrFulfilledMessage && message.citations && message.citations.length > 0;
let md = message.text;
if (isFulfilledMessage(message)) {
// replace the code block with an iframe
md = replaceCodeBlockWithIframe(message.originalText);
}
content = (
<>
<Markdown
className={cn({
'text-volcanic-700': isAborted,
})}
text={message.text}
text={md}
customComponents={{
img: MarkdownImage as any,
cite: CitationTextHighlighter as any,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { ComponentPropsWithoutRef } from 'react';
import ReactMarkdown, { Components } from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import remarkDirective from 'remark-directive';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { PluggableList } from 'unified';

import { Text } from '@/components/Shared';
import { Iframe } from '@/components/Shared/Markdown/tags/Iframe';
import { cn } from '@/utils';

import { renderRemarkCites } from './directives/cite';
Expand Down Expand Up @@ -48,7 +50,11 @@ export const getActiveMarkdownPlugins = (
renderTableTools,
];

const rehypePlugins: PluggableList = [[rehypeHighlight, { detect: true, ignoreMissing: true }]];
const rehypePlugins: PluggableList = [
// remarkRaw is a plugin that allows raw HTML in markdown
rehypeRaw,
[rehypeHighlight, { detect: true, ignoreMissing: true }],
];

if (renderLaTex) {
// remarkMath is a plugin that adds support for math
Expand Down Expand Up @@ -105,6 +111,7 @@ export const Markdown = ({
p: P,
// @ts-ignore
references: References,
iframe: Iframe,
...customComponents,
}}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Component, ExtraProps } from 'hast-util-to-jsx-runtime/lib/components';
import { useEffect } from 'react';
import { useRef } from 'react';
import { useState } from 'react';
import { ComponentPropsWithoutRef } from 'react';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/prism-light';
import theme from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark';

import { Text } from '@/components/Shared/Text';
import { cn } from '@/utils';

const MIN_HEIGHT = 600;

/**
* Renders an iframe with a lazy loading mechanism.
*
* The iframe is initially rendered with a `data-src` attribute that points to a blob URL.
* When the iframe is loaded, the `data-src` attribute is replaced with the actual source URL.
* The height of the iframe is adjusted to fit the content.
*/
export const Iframe: Component<ComponentPropsWithoutRef<'iframe'> & { 'data-src': string }> = (
props
) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [option, setOption] = useState<'live' | 'code'>('live');
const [code, setCode] = useState('');
const src = props[`data-src`];

const onload = (e: any) => {
const iframe = e.target;
const root = iframe.contentDocument.documentElement;
const height = (root.offsetHeight || 0) + 16 + 4;
iframe.style.height = Math.min(height, MIN_HEIGHT) + 'px';
};

useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
iframe.addEventListener('load', onload);
return () => {
iframe.removeEventListener('load', onload);
};
}
}, []);

useEffect(() => {
// read the blob URL (src) and extract the text into the code state
if (src) {
fetch(src)
.then((res) => res.text())
.then((text) => {
setCode(text.trim());
});
}
}, [src]);

return (
<div>
<div className="rounded rounded-b-none border border-b-0 border-marble-500 bg-secondary-50 p-2">
<iframe
srcDoc={code}
ref={iframeRef}
className={cn('max-h-[900px] min-h-[150px] w-full resize-y rounded-lg bg-white', {
hidden: option !== 'live',
})}
/>
<pre
className={cn('language-html max-h-[900px] min-h-[150px] bg-[#1d1f21]', {
hidden: option !== 'code',
})}
>
<SyntaxHighlighter style={theme} language="html">
{code}
</SyntaxHighlighter>
</pre>
</div>
<div className="flex justify-end gap-2 rounded rounded-t-none border border-t-0 border-marble-500 bg-secondary-50 px-4 py-2">
<div className="space-x-2 rounded-lg border border-marble-400 bg-white p-[2.5px]">
<button
className={cn('w-[42px] py-2 transition-colors', {
'rounded-lg bg-secondary-300': option === 'live',
})}
onClick={() => setOption('live')}
>
<Text styleAs="caption">App</Text>
</button>
<button
className={cn('w-[42px] py-2 transition-colors', {
'rounded-lg bg-secondary-300': option === 'code',
})}
onClick={() => setOption('code')}
>
<Text styleAs="caption">Code</Text>
</button>
</div>
</div>
</div>
);
};
85 changes: 85 additions & 0 deletions src/interfaces/coral_web/src/utils/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Adds an onError event to all img elements in the html string
* @param html
* @returns
*/
const addOnErrorToImg = (html: string) => {
const regex = /<img([\s\S]+?)>/g;
const imgElements = html.match(regex);
if (imgElements) {
for (let i = 0; i < imgElements.length; i++) {
const imgElement = imgElements[i];
let newImgElement = imgElement;
if (imgElement.endsWith('/>')) {
newImgElement = imgElement.replace('/>', ' onError="onImageError(this)"/>');
} else {
newImgElement = imgElement.replace('>', ' onError="onImageError(this)">');
}
html = html.replace(imgElement, newImgElement);
}
}
html = html + GET_IMAGE_DEF;
return html;
};

const GET_IMAGE_DEF = `
<script>
function onImageError(element) {
element.src = 'https://picsum.photos/' + element.width + '/' + element.height;
element.onerror = null;
}
</script>
`;

/**
* Reconstructs the html from the plain string
* @param rawHTML
* @returns
*/
const getReconstructedHtml = (rawHTML: string) => {
const htmlRegex = /```(?:html)\n([\s\S]*?)(```|$)/;
const jsRegex = /```(?:js)\n([\s\S]*?)```/;
const cssRegex = /```(?:css)\n([\s\S]*?)```/;

const code = rawHTML.match(htmlRegex);

let html = '';
if (code) {
html = code[1];
const cssCode = rawHTML.match(cssRegex);
let css = '';
if (cssCode) {
css = cssCode[1];
html = `<style>${css}</style>` + html;
}

const jsCode = rawHTML.match(jsRegex);
let js = '';
if (jsCode) {
js = jsCode[1];
html = html + `<script>${js}</script>`;
}
}
return html;
};

export const replaceCodeBlockWithIframe = (content: string) => {
const matchingRegex = /```html([\s\S]+)/;
const replacingRegex = /```html([\s\S]+?)(```|$)/;

const match = content.match(matchingRegex);

if (!match) {
return content;
}

const html = addOnErrorToImg(getReconstructedHtml(content));

const blob = new Blob([html], { type: 'text/html' });
const src = URL.createObjectURL(blob);
const iframe = `<iframe data-src="${src}"></iframe>`;

content = content.replace(replacingRegex, iframe);

return content;
};

0 comments on commit 3447bdb

Please sign in to comment.