forked from cohere-ai/cohere-toolkit
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
frontend: adds preview to html code snippets (cohere-ai#187)
Co-authored-by: Tomeu Cabot <[email protected]> Co-authored-by: Abel Essiane <[email protected]>
- Loading branch information
Showing
6 changed files
with
267 additions
and
2 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
99 changes: 99 additions & 0 deletions
99
src/interfaces/coral_web/src/components/Shared/Markdown/tags/Iframe.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |