import styles from './text.module.css'
import ReactMarkdown from 'react-markdown'
import gfm from 'remark-gfm'
import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter'
import atomDark from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark'
import mention from '@/lib/remark-mention'
import sub from '@/lib/remark-sub'
import React, { useState, memo, useRef, useCallback, useMemo, useEffect } from 'react'
import { slug } from 'github-slugger'
import LinkIcon from '@/svgs/link.svg'
import Thumb from '@/svgs/thumb-up-fill.svg'
import { toString } from 'mdast-util-to-string'
import copy from 'clipboard-copy'
import MediaOrLink from './media-or-link'
import { IMGPROXY_URL_REGEXP, parseInternalLinks, decodeProxyUrl } from '@/lib/url'
import reactStringReplace from 'react-string-replace'
import { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md'
import { Button } from 'react-bootstrap'
import { useRouter } from 'next/router'
import Link from 'next/link'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import isEqual from 'lodash/isEqual'
import UserPopover from './user-popover'
import ItemPopover from './item-popover'
import classNames from 'classnames'
// Explicitely defined start/end tags & which CSS class from text.module.css to apply
export const rehypeSuperscript = () => rehypeStyler('', ' ', styles.superscript)
export const rehypeSubscript = () => rehypeStyler('', ' ', styles.subscript)
export function SearchText ({ text }) {
return (
{reactStringReplace(text, /\*\*\*([^*]+)\*\*\*/g, (match, i) => {
return {match}
})}
)
}
// this is one of the slowest components to render
export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, outlawed, topLevel, noFragments }) {
const [overflowing, setOverflowing] = useState(false)
const router = useRouter()
const [show, setShow] = useState(false)
const containerRef = useRef(null)
useEffect(() => {
setShow(router.asPath.includes('#'))
const handleRouteChange = (url, { shallow }) => {
setShow(url.includes('#'))
}
router.events.on('hashChangeStart', handleRouteChange)
return () => {
router.events.off('hashChangeStart', handleRouteChange)
}
}, [router])
useEffect(() => {
const container = containerRef.current
if (!container || overflowing) return
function checkOverflow () {
setOverflowing(container.scrollHeight > window.innerHeight * 2)
}
let resizeObserver
if (!overflowing && 'ResizeObserver' in window) {
resizeObserver = new window.ResizeObserver(checkOverflow).observe(container)
}
window.addEventListener('resize', checkOverflow)
checkOverflow()
return () => {
window.removeEventListener('resize', checkOverflow)
resizeObserver?.disconnect()
}
}, [containerRef.current, setOverflowing])
const Heading = useCallback(({ children, node, ...props }) => {
const [copied, setCopied] = useState(false)
const nodeText = toString(node)
const id = useMemo(() => noFragments ? undefined : slug(nodeText.replace(/[^\w\-\s]+/gi, '')), [nodeText, noFragments])
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 (
{React.createElement(h || node?.tagName, { id, ...props }, children)}
{!noFragments && topLevel &&
{
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'
/>
}
)
}, [topLevel, noFragments])
const Table = useCallback(({ node, ...props }) =>
, [])
const Code = useCallback(({ node, inline, className, children, style, ...props }) => {
return inline
? (
{children}
)
: (
{children}
)
}, [])
const P = useCallback(({ children, node, ...props }) => {children}
, [])
const TextMediaOrLink = useCallback(({ node, src, ...props }) => {
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src
// if outlawed, render the media link as text
if (outlawed) {
return url
}
const srcSet = imgproxyUrls?.[url]
return
}, [imgproxyUrls, topLevel, tab])
const components = useMemo(() => ({
h1: Heading,
h2: Heading,
h3: Heading,
h4: Heading,
h5: Heading,
h6: Heading,
table: Table,
p: P,
li: props => {
return
},
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}>
}
// if outlawed, render the link as text
if (outlawed) {
return href
}
// If [text](url) was parsed as 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
}
const internalURL = process.env.NEXT_PUBLIC_URL
if (!!text && !/^https?:\/\//.test(text)) {
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
return (
{text}
)
}
if (text.startsWith?.('@')) {
// user mention might be within a markdown link like this: [@user foo bar](url)
const name = text.replace('@', '').split(' ')[0]
return (
{text}
)
} else if (href.startsWith('/') || url?.origin === internalURL) {
try {
const { linkText } = parseInternalLinks(href)
if (linkText) {
return (
{text}
)
}
} catch {
// ignore errors like invalid URLs
}
return (
{text}
)
}
return (
// eslint-disable-next-line
{text}
)
}
try {
const { linkText } = parseInternalLinks(href)
if (linkText) {
return (
{linkText}
)
}
} catch {
// ignore errors like invalid URLs
}
// assume the link is an image which will fallback to link if it's not
return {children}
},
img: TextMediaOrLink
}), [outlawed, rel, itemId, Code, P, Heading, Table, TextMediaOrLink])
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
return (
{children}
{overflowing && !show &&
setShow(true)}>
show full text
}
)
}, isEqual)