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'
|
|
|
|
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'
|
2024-03-20 00:37:31 +00:00
|
|
|
import mention from '@/lib/remark-mention'
|
|
|
|
import sub from '@/lib/remark-sub'
|
2023-12-04 22:07:10 +00:00
|
|
|
import React, { useState, memo, useRef, useCallback, useMemo, useEffect } from 'react'
|
2024-09-13 20:54:30 +00:00
|
|
|
import { slug } from 'github-slugger'
|
2024-03-20 00:37:31 +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'
|
2024-09-06 14:34:44 +00:00
|
|
|
import MediaOrLink from './media-or-link'
|
2024-09-07 17:07:10 +00:00
|
|
|
import { IMGPROXY_URL_REGEXP, parseInternalLinks, decodeProxyUrl } from '@/lib/url'
|
2023-10-03 00:07:05 +00:00
|
|
|
import reactStringReplace from 'react-string-replace'
|
2024-06-25 22:18:21 +00:00
|
|
|
import { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md'
|
2023-12-04 22:07:10 +00:00
|
|
|
import { Button } from 'react-bootstrap'
|
2023-12-21 00:16:34 +00:00
|
|
|
import { useRouter } from 'next/router'
|
2023-12-21 00:54:56 +00:00
|
|
|
import Link from 'next/link'
|
2024-04-08 22:54:39 +00:00
|
|
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
2024-03-06 19:53:46 +00:00
|
|
|
import isEqual from 'lodash/isEqual'
|
2024-05-03 21:39:21 +00:00
|
|
|
import UserPopover from './user-popover'
|
2024-05-15 17:05:50 +00:00
|
|
|
import ItemPopover from './item-popover'
|
2024-09-12 01:10:52 +00:00
|
|
|
import classNames from 'classnames'
|
2022-07-21 22:39:05 +00:00
|
|
|
|
2024-06-25 22:18:21 +00:00
|
|
|
// Explicitely defined start/end tags & which CSS class from text.module.css to apply
|
|
|
|
export const rehypeSuperscript = () => rehypeStyler('<sup>', '</sup>', styles.superscript)
|
|
|
|
export const rehypeSubscript = () => rehypeStyler('<sub>', '</sub>', styles.subscript)
|
|
|
|
|
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-07-23 15:08:43 +00:00
|
|
|
// this is one of the slowest components to render
|
2024-03-06 19:53:46 +00:00
|
|
|
export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, outlawed, topLevel, noFragments }) {
|
2023-12-04 22:07:10 +00:00
|
|
|
const [overflowing, setOverflowing] = useState(false)
|
2023-12-21 00:16:34 +00:00
|
|
|
const router = useRouter()
|
2023-12-04 22:07:10 +00:00
|
|
|
const [show, setShow] = useState(false)
|
|
|
|
const containerRef = useRef(null)
|
|
|
|
|
2023-12-21 00:16:34 +00:00
|
|
|
useEffect(() => {
|
|
|
|
setShow(router.asPath.includes('#'))
|
|
|
|
const handleRouteChange = (url, { shallow }) => {
|
|
|
|
setShow(url.includes('#'))
|
|
|
|
}
|
|
|
|
|
|
|
|
router.events.on('hashChangeStart', handleRouteChange)
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
router.events.off('hashChangeStart', handleRouteChange)
|
|
|
|
}
|
|
|
|
}, [router])
|
|
|
|
|
2023-12-04 22:07:10 +00:00
|
|
|
useEffect(() => {
|
|
|
|
const container = containerRef.current
|
|
|
|
if (!container || overflowing) return
|
|
|
|
|
2023-12-16 00:18:30 +00:00
|
|
|
function checkOverflow () {
|
|
|
|
setOverflowing(container.scrollHeight > window.innerHeight * 2)
|
|
|
|
}
|
|
|
|
|
2023-12-17 19:23:52 +00:00
|
|
|
let resizeObserver
|
|
|
|
if (!overflowing && 'ResizeObserver' in window) {
|
|
|
|
resizeObserver = new window.ResizeObserver(checkOverflow).observe(container)
|
|
|
|
}
|
|
|
|
|
2023-12-16 00:18:30 +00:00
|
|
|
window.addEventListener('resize', checkOverflow)
|
|
|
|
checkOverflow()
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
window.removeEventListener('resize', checkOverflow)
|
2023-12-17 19:23:52 +00:00
|
|
|
resizeObserver?.disconnect()
|
2023-12-16 00:18:30 +00:00
|
|
|
}
|
|
|
|
}, [containerRef.current, setOverflowing])
|
2023-12-04 22:07:10 +00:00
|
|
|
|
2023-11-12 15:04:28 +00:00
|
|
|
const Heading = useCallback(({ children, node, ...props }) => {
|
|
|
|
const [copied, setCopied] = useState(false)
|
2024-03-17 17:24:27 +00:00
|
|
|
const nodeText = toString(node)
|
2024-09-13 20:54:30 +00:00
|
|
|
const id = useMemo(() => noFragments ? undefined : slug(nodeText.replace(/[^\w\-\s]+/gi, '')), [nodeText, noFragments])
|
2023-11-12 15:04:28 +00:00
|
|
|
const h = useMemo(() => {
|
|
|
|
if (topLevel) {
|
|
|
|
return node?.TagName
|
|
|
|
}
|
|
|
|
|
|
|
|
const h = parseInt(node?.tagName?.replace('h', '') || 0)
|
|
|
|
if (h < 4) return `h${h + 3}`
|
|
|
|
|
|
|
|
return 'h6'
|
|
|
|
}, [node, topLevel])
|
|
|
|
const Icon = copied ? Thumb : LinkIcon
|
|
|
|
|
|
|
|
return (
|
|
|
|
<span className={styles.heading}>
|
|
|
|
{React.createElement(h || node?.tagName, { id, ...props }, children)}
|
|
|
|
{!noFragments && topLevel &&
|
|
|
|
<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>}
|
|
|
|
</span>
|
|
|
|
)
|
2024-09-13 20:54:30 +00:00
|
|
|
}, [topLevel, noFragments])
|
2023-11-12 15:04:28 +00:00
|
|
|
|
2023-10-03 00:07:05 +00:00
|
|
|
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
|
|
|
|
2024-09-06 14:34:44 +00:00
|
|
|
const TextMediaOrLink = useCallback(({ node, src, ...props }) => {
|
|
|
|
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src
|
|
|
|
// if outlawed, render the media link as text
|
2024-03-05 01:29:50 +00:00
|
|
|
if (outlawed) {
|
|
|
|
return url
|
|
|
|
}
|
2023-10-03 00:07:05 +00:00
|
|
|
const srcSet = imgproxyUrls?.[url]
|
2024-09-06 14:34:44 +00:00
|
|
|
return <MediaOrLink srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} />
|
2024-03-06 19:53:46 +00:00
|
|
|
}, [imgproxyUrls, topLevel, tab])
|
2023-07-13 00:10:01 +00:00
|
|
|
|
2024-09-06 14:34:44 +00:00
|
|
|
const components = useMemo(() => ({
|
|
|
|
h1: Heading,
|
|
|
|
h2: Heading,
|
|
|
|
h3: Heading,
|
|
|
|
h4: Heading,
|
|
|
|
h5: Heading,
|
|
|
|
h6: Heading,
|
|
|
|
table: Table,
|
|
|
|
p: P,
|
|
|
|
li: props => {
|
|
|
|
return <li {...props} id={props.id && itemId ? `${props.id}-${itemId}` : props.id} />
|
|
|
|
},
|
|
|
|
code: Code,
|
|
|
|
a: ({ node, href, children, ...props }) => {
|
|
|
|
children = children ? Array.isArray(children) ? children : [children] : []
|
|
|
|
// don't allow zoomable images to be wrapped in links
|
|
|
|
if (children.some(e => e?.props?.node?.tagName === 'img')) {
|
|
|
|
return <>{children}</>
|
|
|
|
}
|
2024-03-05 01:29:50 +00:00
|
|
|
|
2024-09-06 14:34:44 +00:00
|
|
|
// if outlawed, render the link as text
|
|
|
|
if (outlawed) {
|
|
|
|
return href
|
|
|
|
}
|
2024-06-03 17:05:31 +00:00
|
|
|
|
2024-09-06 14:34:44 +00:00
|
|
|
// If [text](url) was parsed as <a> and text is not empty and not a link itself,
|
|
|
|
// we don't render it as an image since it was probably a conscious choice to include text.
|
|
|
|
const text = children[0]
|
|
|
|
let url
|
|
|
|
try {
|
|
|
|
url = !href.startsWith('/') && new URL(href)
|
|
|
|
} catch {
|
|
|
|
// ignore invalid URLs
|
|
|
|
}
|
2024-06-03 17:05:31 +00:00
|
|
|
|
2024-09-06 14:34:44 +00:00
|
|
|
const internalURL = process.env.NEXT_PUBLIC_URL
|
|
|
|
if (!!text && !/^https?:\/\//.test(text)) {
|
|
|
|
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
|
|
|
|
return (
|
|
|
|
<Link
|
|
|
|
{...props}
|
|
|
|
id={props.id && itemId ? `${props.id}-${itemId}` : props.id}
|
|
|
|
href={itemId ? `${href}-${itemId}` : href}
|
|
|
|
>{text}
|
|
|
|
</Link>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
if (text.startsWith?.('@')) {
|
|
|
|
// user mention might be within a markdown link like this: [@user foo bar](url)
|
|
|
|
const name = text.replace('@', '').split(' ')[0]
|
|
|
|
return (
|
|
|
|
<UserPopover name={name}>
|
|
|
|
<Link
|
|
|
|
id={props.id}
|
|
|
|
href={href}
|
|
|
|
>
|
|
|
|
{text}
|
|
|
|
</Link>
|
|
|
|
</UserPopover>
|
|
|
|
)
|
|
|
|
} else if (href.startsWith('/') || url?.origin === internalURL) {
|
|
|
|
try {
|
|
|
|
const { linkText } = parseInternalLinks(href)
|
|
|
|
if (linkText) {
|
2023-10-22 16:13:16 +00:00
|
|
|
return (
|
2024-09-06 14:34:44 +00:00
|
|
|
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
|
|
|
<Link href={href}>{text}</Link>
|
|
|
|
</ItemPopover>
|
2023-10-22 18:28:18 +00:00
|
|
|
)
|
|
|
|
}
|
2024-09-06 14:34:44 +00:00
|
|
|
} catch {
|
|
|
|
// ignore errors like invalid URLs
|
|
|
|
}
|
2023-10-22 18:28:18 +00:00
|
|
|
|
2024-09-06 14:34:44 +00:00
|
|
|
return (
|
|
|
|
<Link
|
|
|
|
id={props.id}
|
|
|
|
href={href}
|
|
|
|
>
|
|
|
|
{text}
|
|
|
|
</Link>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
return (
|
|
|
|
// eslint-disable-next-line
|
|
|
|
<a id={props.id} target='_blank' rel={rel ?? UNKNOWN_LINK_REL} href={href}>{text}</a>
|
|
|
|
)
|
|
|
|
}
|
2024-01-24 23:29:52 +00:00
|
|
|
|
2024-09-06 14:34:44 +00:00
|
|
|
try {
|
|
|
|
const { linkText } = parseInternalLinks(href)
|
|
|
|
if (linkText) {
|
|
|
|
return (
|
|
|
|
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
|
|
|
<Link href={href}>{linkText}</Link>
|
|
|
|
</ItemPopover>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
// ignore errors like invalid URLs
|
|
|
|
}
|
2024-05-28 13:18:32 +00:00
|
|
|
|
2024-09-06 14:34:44 +00:00
|
|
|
// assume the link is an image which will fallback to link if it's not
|
|
|
|
return <TextMediaOrLink src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</TextMediaOrLink>
|
|
|
|
},
|
|
|
|
img: TextMediaOrLink
|
2024-09-07 17:07:10 +00:00
|
|
|
}), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink])
|
2024-09-06 14:34:44 +00:00
|
|
|
|
|
|
|
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
|
|
|
|
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
|
|
|
|
|
|
|
|
return (
|
2024-09-12 01:10:52 +00:00
|
|
|
<div className={classNames(styles.text, topLevel && styles.topLevel, show ? styles.textUncontained : overflowing && styles.textContained)} ref={containerRef}>
|
2024-09-06 14:34:44 +00:00
|
|
|
<ReactMarkdown
|
|
|
|
components={components}
|
|
|
|
remarkPlugins={remarkPlugins}
|
|
|
|
rehypePlugins={rehypePlugins}
|
2021-06-27 22:28:30 +00:00
|
|
|
>
|
|
|
|
{children}
|
|
|
|
</ReactMarkdown>
|
2023-12-04 22:07:10 +00:00
|
|
|
{overflowing && !show &&
|
|
|
|
<Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}>
|
|
|
|
show full text
|
|
|
|
</Button>}
|
2021-06-27 22:28:30 +00:00
|
|
|
</div>
|
2021-04-14 23:56:29 +00:00
|
|
|
)
|
2024-03-06 19:53:46 +00:00
|
|
|
}, isEqual)
|