2021-04-14 23:56:29 +00:00
|
|
|
import styles from './text.module.css'
|
2021-06-27 22:28:30 +00:00
|
|
|
import ReactMarkdown from 'react-markdown'
|
2023-10-22 16:13:16 +00:00
|
|
|
import YouTube from 'react-youtube'
|
2021-06-27 22:28:30 +00:00
|
|
|
import gfm from 'remark-gfm'
|
2023-07-24 21:06:26 +00:00
|
|
|
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter'
|
|
|
|
import atomDark from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark'
|
2021-07-15 19:56:23 +00:00
|
|
|
import mention from '../lib/remark-mention'
|
2022-03-03 22:18:38 +00:00
|
|
|
import sub from '../lib/remark-sub'
|
2023-10-03 00:07:05 +00:00
|
|
|
import React, { useState, memo, useRef, useCallback, useMemo } from 'react'
|
2022-07-17 15:33:55 +00:00
|
|
|
import GithubSlugger from 'github-slugger'
|
2022-10-04 17:43:18 +00:00
|
|
|
import LinkIcon from '../svgs/link.svg'
|
|
|
|
import Thumb from '../svgs/thumb-up-fill.svg'
|
2022-11-15 20:51:55 +00:00
|
|
|
import { toString } from 'mdast-util-to-string'
|
2022-10-04 17:43:18 +00:00
|
|
|
import copy from 'clipboard-copy'
|
2023-10-04 18:47:09 +00:00
|
|
|
import ZoomableImage, { decodeOriginalUrl } from './image'
|
2023-10-01 23:03:52 +00:00
|
|
|
import { IMGPROXY_URL_REGEXP } from '../lib/url'
|
2023-10-03 00:07:05 +00:00
|
|
|
import reactStringReplace from 'react-string-replace'
|
2023-10-23 22:14:37 +00:00
|
|
|
import { rehypeInlineCodeProperty } from '../lib/md'
|
2022-07-21 22:39:05 +00:00
|
|
|
|
2023-10-03 00:07:05 +00:00
|
|
|
export function SearchText ({ text }) {
|
|
|
|
return (
|
|
|
|
<div className={styles.text}>
|
|
|
|
<p className={styles.p}>
|
|
|
|
{reactStringReplace(text, /\*\*\*([^*]+)\*\*\*/g, (match, i) => {
|
2023-10-15 21:13:54 +00:00
|
|
|
return <mark key={`strong-${match}-${i}`}>{match}</mark>
|
2023-10-03 00:07:05 +00:00
|
|
|
})}
|
|
|
|
</p>
|
|
|
|
</div>
|
|
|
|
)
|
2022-02-03 22:01:42 +00:00
|
|
|
}
|
2021-04-14 23:56:29 +00:00
|
|
|
|
2023-10-23 22:14:37 +00:00
|
|
|
function Heading ({ slugger, noFragments, topLevel, children, node, ...props }) {
|
2022-10-04 17:43:18 +00:00
|
|
|
const [copied, setCopied] = useState(false)
|
2023-10-03 00:07:05 +00:00
|
|
|
const id = useMemo(() =>
|
|
|
|
noFragments ? undefined : slugger.slug(toString(node).replace(/[^\w\-\s]+/gi, '')), [node, noFragments, slugger])
|
|
|
|
const h = useMemo(() => {
|
2023-10-23 22:14:37 +00:00
|
|
|
if (topLevel) return node?.TagName
|
|
|
|
|
|
|
|
const h = parseInt(node?.tagName)
|
|
|
|
if (h < 4) return `h${h + 3}`
|
|
|
|
|
|
|
|
return 'h6'
|
|
|
|
}, [node?.tagName, topLevel])
|
2022-10-04 17:43:18 +00:00
|
|
|
const Icon = copied ? Thumb : LinkIcon
|
2022-07-17 15:33:55 +00:00
|
|
|
|
|
|
|
return (
|
2023-07-23 15:08:43 +00:00
|
|
|
<span className={styles.heading}>
|
2022-07-17 15:33:55 +00:00
|
|
|
{React.createElement(h, { id, ...props }, children)}
|
2022-10-04 17:43:18 +00:00
|
|
|
{!noFragments && topLevel &&
|
2022-11-15 20:51:55 +00:00
|
|
|
<a className={`${styles.headingLink} ${copied ? styles.copied : ''}`} href={`#${id}`}>
|
|
|
|
<Icon
|
|
|
|
onClick={() => {
|
|
|
|
const location = new URL(window.location)
|
|
|
|
location.hash = `${id}`
|
|
|
|
copy(location.href)
|
|
|
|
setTimeout(() => setCopied(false), 1500)
|
|
|
|
setCopied(true)
|
|
|
|
}}
|
|
|
|
width={18}
|
|
|
|
height={18}
|
|
|
|
className='fill-grey'
|
|
|
|
/>
|
|
|
|
</a>}
|
2023-07-23 15:08:43 +00:00
|
|
|
</span>
|
2022-07-17 15:33:55 +00:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2023-07-23 15:08:43 +00:00
|
|
|
// this is one of the slowest components to render
|
2023-10-03 00:07:05 +00:00
|
|
|
export default memo(function Text ({ nofollow, imgproxyUrls, children, tab, ...outerProps }) {
|
2022-02-10 22:41:13 +00:00
|
|
|
// all the reactStringReplace calls are to facilitate search highlighting
|
2023-10-03 00:07:05 +00:00
|
|
|
const slugger = useRef(new GithubSlugger())
|
|
|
|
|
|
|
|
const Table = useCallback(({ node, ...props }) =>
|
|
|
|
<span className='table-responsive'>
|
|
|
|
<table className='table table-bordered table-sm' {...props} />
|
|
|
|
</span>, [])
|
2022-07-17 15:33:55 +00:00
|
|
|
|
2023-10-03 00:07:05 +00:00
|
|
|
const Code = useCallback(({ node, inline, className, children, style, ...props }) => {
|
|
|
|
return inline
|
|
|
|
? (
|
2023-10-06 23:51:38 +00:00
|
|
|
<code className={className} {...props}>
|
2023-10-03 00:07:05 +00:00
|
|
|
{children}
|
|
|
|
</code>
|
|
|
|
)
|
|
|
|
: (
|
2023-10-06 23:51:38 +00:00
|
|
|
<SyntaxHighlighter style={atomDark} language='text' PreTag='div' {...props}>
|
2023-10-03 00:07:05 +00:00
|
|
|
{children}
|
|
|
|
</SyntaxHighlighter>
|
|
|
|
)
|
|
|
|
}, [])
|
2022-07-13 23:00:48 +00:00
|
|
|
|
2023-10-23 22:14:37 +00:00
|
|
|
const P = useCallback(({ children, node, ...props }) => <div className={styles.p} {...props}>{children}</div>, [])
|
2023-10-03 00:07:05 +00:00
|
|
|
|
|
|
|
const Img = useCallback(({ node, src, ...props }) => {
|
|
|
|
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
|
|
|
|
const srcSet = imgproxyUrls?.[url]
|
|
|
|
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} {...props} {...outerProps} />
|
|
|
|
}, [imgproxyUrls, outerProps, tab])
|
2023-07-13 00:10:01 +00:00
|
|
|
|
2021-04-14 23:56:29 +00:00
|
|
|
return (
|
2021-06-27 22:28:30 +00:00
|
|
|
<div className={styles.text}>
|
|
|
|
<ReactMarkdown
|
|
|
|
components={{
|
2023-10-23 22:14:37 +00:00
|
|
|
heading: (props) => <Heading {...props} {...outerProps} slugger={slugger.current} />,
|
2023-10-03 00:07:05 +00:00
|
|
|
table: Table,
|
|
|
|
p: P,
|
|
|
|
code: Code,
|
2022-02-10 22:41:13 +00:00
|
|
|
a: ({ node, href, children, ...props }) => {
|
2023-10-15 20:43:06 +00:00
|
|
|
children = children ? Array.isArray(children) ? children : [children] : []
|
2023-10-03 00:07:05 +00:00
|
|
|
// don't allow zoomable images to be wrapped in links
|
2023-10-15 20:43:06 +00:00
|
|
|
if (children.some(e => e?.props?.node?.tagName === 'img')) {
|
2022-08-10 15:06:31 +00:00
|
|
|
return <>{children}</>
|
|
|
|
}
|
|
|
|
|
2023-10-03 18:05:04 +00:00
|
|
|
// If [text](url) was parsed as <a> and text is not empty and not a link itself,
|
2023-10-18 14:50:33 +00:00
|
|
|
// we don't render it as an image since it was probably a conscious choice to include text.
|
2023-10-15 20:43:06 +00:00
|
|
|
const text = children[0]
|
2023-10-03 18:05:04 +00:00
|
|
|
if (!!text && !/^https?:\/\//.test(text)) {
|
2023-10-22 16:13:16 +00:00
|
|
|
return (
|
2023-10-22 18:28:18 +00:00
|
|
|
<a target='_blank' rel={`noreferrer ${nofollow ? 'nofollow' : ''} noopener`} href={href}>{text}</a>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
// if the link is to a youtube video, render the video
|
|
|
|
const youtube = href.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)((?:\?|&)(?:t|start)=(?<start>\d+))?/i)
|
|
|
|
if (youtube?.groups?.id) {
|
|
|
|
return (
|
|
|
|
<div style={{ maxWidth: outerProps.topLevel ? '640px' : '320px', paddingRight: '15px', margin: '0.5rem 0' }}>
|
|
|
|
<YouTube
|
|
|
|
videoId={youtube.groups.id} className={styles.youtubeContainer} opts={{
|
|
|
|
playerVars: {
|
|
|
|
start: youtube?.groups?.start
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</div>
|
2023-10-22 16:13:16 +00:00
|
|
|
)
|
2023-10-03 18:05:04 +00:00
|
|
|
}
|
|
|
|
|
2023-10-03 00:07:05 +00:00
|
|
|
// assume the link is an image which will fallback to link if it's not
|
|
|
|
return <Img src={href} nofollow={nofollow} {...props}>{children}</Img>
|
2022-07-13 23:00:48 +00:00
|
|
|
},
|
2023-10-03 00:07:05 +00:00
|
|
|
img: Img
|
2021-06-27 22:28:30 +00:00
|
|
|
}}
|
2023-10-03 00:07:05 +00:00
|
|
|
remarkPlugins={[gfm, mention, sub]}
|
2023-10-23 22:14:37 +00:00
|
|
|
rehypePlugins={[rehypeInlineCodeProperty]}
|
2021-06-27 22:28:30 +00:00
|
|
|
>
|
|
|
|
{children}
|
|
|
|
</ReactMarkdown>
|
|
|
|
</div>
|
2021-04-14 23:56:29 +00:00
|
|
|
)
|
2023-07-23 15:08:43 +00:00
|
|
|
})
|