diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f74d147e44..ab517e1b44 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,7 +7,7 @@ import { ClerkProvider } from '@clerk/nextjs' import { Root } from '~/components/layout/root/Root' import { ClerkZhCN } from '~/i18n/cherk-cn' import { defineMetadata } from '~/lib/define-metadata' -import { sansFont } from '~/lib/fonts' +import { sansFont, serifFont } from '~/lib/fonts' import { getQueryClient } from '~/utils/query-client.server' import { Providers } from '../providers/root' @@ -80,7 +80,7 @@ export default async function RootLayout(props: Props) { diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index d6b6473853..b6e057482e 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -7,13 +7,13 @@ import { useParams } from 'next/navigation' import { PageDataHolder } from '~/components/common/PageHolder' import { useSetHeaderMetaInfo } from '~/components/layout/header/internal/hooks' import { Loading } from '~/components/ui/loading' +import { Markdown } from '~/components/ui/markdown' import { Toc, TocAutoScroll } from '~/components/widgets/toc' import { useBeforeMounted } from '~/hooks/common/use-before-mounted' import { useNoteByNidQuery } from '~/hooks/data/use-note' import { ArticleElementProvider } from '~/providers/article/article-element-provider' import { useSetCurrentNoteId } from '~/providers/note/current-note-id-provider' import { NoteLayoutRightSidePortal } from '~/providers/note/right-side-provider' -import { parseMarkdown } from '~/remark' const PageImpl = () => { const { id } = useParams() as { id: string } @@ -39,7 +39,7 @@ const PageImpl = () => { return } - const mardownResult = parseMarkdown(data?.data?.text ?? '') + // const mardownResult = parseMarkdown(note.text ?? '') // Why do this, I mean why do set NoteId to context, don't use `useParams().id` for children components. // Because any router params or query changes, will cause components that use `useParams()` hook, this hook is a context hook, @@ -67,7 +67,7 @@ const PageImpl = () => { - {mardownResult.jsx} + diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index 81f1ec8520..cd27bd573e 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -42,7 +42,7 @@ const MemoedHeader = memo(() => { - + diff --git a/src/components/layout/header/internal/SiteOwnerAvatar.tsx b/src/components/layout/header/internal/SiteOwnerAvatar.tsx index f8beeeff78..ce14fc39c5 100644 --- a/src/components/layout/header/internal/SiteOwnerAvatar.tsx +++ b/src/components/layout/header/internal/SiteOwnerAvatar.tsx @@ -12,7 +12,7 @@ export const SiteOwnerAvatar: Component = ({ className }) => { return (
diff --git a/src/components/ui/banner/Banner.tsx b/src/components/ui/banner/Banner.tsx new file mode 100644 index 0000000000..8dc0715781 --- /dev/null +++ b/src/components/ui/banner/Banner.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { clsx } from 'clsx' +import type { FC } from 'react' + +import { + ClaritySuccessLine, + FluentShieldError20Regular, + FluentWarning28Regular, + IonInformation, +} from '../../icons/status' + +const IconMap = { + warning: FluentWarning28Regular, + info: IonInformation, + error: FluentShieldError20Regular, + success: ClaritySuccessLine, +} + +const bgColorMap = { + warning: 'bg-amber-50 dark:bg-amber-300', + info: 'bg-always-blue-50 dark:bg-always-blue-300', + success: 'bg-always-green-50 dark:bg-always-green-300', + error: 'bg-always-red-50 dark:bg-always-red-300', +} + +const borderColorMap = { + warning: 'border-amber-300', + info: 'border-always-blue-300', + + success: 'border-always-green-300', + error: 'border-always-red-300', +} + +const iconColorMap = { + warning: 'text-amber-500', + info: 'text-always-blue-500', + success: 'text-always-green-500', + error: 'text-always-red-500', +} + +export const Banner: FC<{ + type: 'warning' | 'error' | 'success' | 'info' + message?: string | React.ReactNode + className?: string + children?: React.ReactNode + placement?: 'center' | 'left' + showIcon?: boolean +}> = (props) => { + const Icon = IconMap[props.type] || IconMap.info + const { placement = 'center', showIcon = true } = props + return ( +
+ {showIcon && ( + + )} + {props.message ? ( + {props.message} + ) : ( + props.children + )} +
+ ) +} diff --git a/src/components/ui/banner/index.ts b/src/components/ui/banner/index.ts new file mode 100644 index 0000000000..b9a83aa3a2 --- /dev/null +++ b/src/components/ui/banner/index.ts @@ -0,0 +1 @@ +export * from './Banner' diff --git a/src/components/ui/collapse/Collapse.tsx b/src/components/ui/collapse/Collapse.tsx new file mode 100644 index 0000000000..b40d932fad --- /dev/null +++ b/src/components/ui/collapse/Collapse.tsx @@ -0,0 +1,44 @@ +'use client' + +import { AnimatePresence, motion } from 'framer-motion' +import * as React from 'react' + +import { microReboundPreset } from '~/constants/spring' + +export const Collapse = ({ + isOpened, + className, + children, +}: React.PropsWithChildren<{ isOpened: boolean } & { className?: string }>) => { + // By using `AnimatePresence` to mount and unmount the contents, we can animate + // them in and out while also only rendering the contents of open accordions + return ( + <> + + {isOpened && ( + + {children} + + )} + + + ) +} diff --git a/src/components/ui/collapse/index.ts b/src/components/ui/collapse/index.ts new file mode 100644 index 0000000000..eeb7c6c76c --- /dev/null +++ b/src/components/ui/collapse/index.ts @@ -0,0 +1 @@ +export * from './Collapse' diff --git a/src/components/ui/image/ZoomedImage.tsx b/src/components/ui/image/ZoomedImage.tsx index dc34e1f49f..f018cffbba 100644 --- a/src/components/ui/image/ZoomedImage.tsx +++ b/src/components/ui/image/ZoomedImage.tsx @@ -8,6 +8,7 @@ type TImageProps = { 'original-src'?: string imageRef?: React.MutableRefObject zoom?: boolean + accentColor?: string } & React.HTMLAttributes & ImageProps diff --git a/src/components/ui/markdown/Markdown.tsx b/src/components/ui/markdown/Markdown.tsx new file mode 100644 index 0000000000..134b189735 --- /dev/null +++ b/src/components/ui/markdown/Markdown.tsx @@ -0,0 +1,182 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import React, { + createElement, + memo, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { clsx } from 'clsx' +import { compiler } from 'markdown-to-jsx' +import type { MarkdownToJSX } from 'markdown-to-jsx' +import type { FC, PropsWithChildren } from 'react' + +import { range } from '~/lib/_' + +import styles from './index.module.css' +import { CommentAtRule } from './parsers/comment-at' +import { ContainerRule } from './parsers/container' +import { InsertRule } from './parsers/ins' +import { KateXRule } from './parsers/katex' +import { MarkRule } from './parsers/mark' +import { MentionRule } from './parsers/mention' +import { SpoilderRule } from './parsers/spoiler' +import { MParagraph, MTableBody, MTableHead, MTableRow } from './renderers' +import { MDetails } from './renderers/collapse' +import { MFootNote } from './renderers/footnotes' + +export interface MdProps { + value?: string + + style?: React.CSSProperties + readonly renderers?: { [key: string]: Partial } + wrapperProps?: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > + codeBlockFully?: boolean + className?: string + tocSlot?: (props: { headings: HTMLElement[] }) => JSX.Element | null +} + +export const Markdown: FC = + memo((props) => { + const { + value, + renderers, + style, + wrapperProps = {}, + codeBlockFully = false, + className, + overrides, + extendsRules, + additionalParserRules, + + ...rest + } = props + + const ref = useRef(null) + const [headings, setHeadings] = useState([]) + + useEffect(() => { + if (!ref.current) { + return + } + + const $headings = ref.current.querySelectorAll( + range(1, 6) + .map((i) => `h${i}`) + .join(','), + ) as NodeListOf + + setHeadings(Array.from($headings)) + + return () => { + setHeadings([]) + } + }, [value, props.children]) + + const node = useMemo(() => { + if (!value && typeof props.children != 'string') return null + + const mdElement = compiler(`${value || props.children}`, { + wrapper: null, + // @ts-ignore + overrides: { + p: MParagraph, + + thead: MTableHead, + tr: MTableRow, + tbody: MTableBody, + // FIXME: footer tag in raw html will renders not as expected, but footer tag in this markdown lib will wrapper as linkReferer footnotes + footer: MFootNote, + details: MDetails, + + // for custom react component + // LinkCard, + ...overrides, + }, + + extendsRules: { + gfmTask: { + react(node, _, state) { + return ( + + ) + }, + }, + + list: { + react(node, output, state) { + const Tag = node.ordered ? 'ol' : 'ul' + + return ( + + {node.items.map((item: any, i: number) => { + let className = '' + if (item[0]?.type == 'gfmTask') { + className = 'list-none flex items-center' + } + + return ( +
  • + {output(item, state!)} +
  • + ) + })} +
    + ) + }, + }, + + ...extendsRules, + ...renderers, + }, + additionalParserRules: { + spoilder: SpoilderRule, + mention: MentionRule, + commentAt: CommentAtRule, + mark: MarkRule, + ins: InsertRule, + kateX: KateXRule, + container: ContainerRule, + ...additionalParserRules, + }, + ...rest, + }) + + return mdElement + }, [ + value, + props.children, + overrides, + extendsRules, + renderers, + additionalParserRules, + rest, + ]) + + return ( +
    + {className ?
    {node}
    : node} + + {props.tocSlot ? createElement(props.tocSlot, { headings }) : null} +
    + ) + }) diff --git a/src/components/ui/markdown/components/gallery/index.module.css b/src/components/ui/markdown/components/gallery/index.module.css new file mode 100644 index 0000000000..66c2c12919 --- /dev/null +++ b/src/components/ui/markdown/components/gallery/index.module.css @@ -0,0 +1,35 @@ +.root { + &:hover .indicator { + opacity: 1; + } +} + +.container { + scroll-snap-type: x mandatory; + display: flex; + align-items: flex-start; + + &::-webkit-scrollbar { + display: none; + } +} + +.child { + scroll-snap-align: center; + flex-shrink: 0; + scroll-snap-stop: always; + + text-align: center; +} + +.child:last-child { + margin-right: 0 !important; +} + +.indicator { + @apply absolute bottom-[24px] left-[50%] flex bg-bg-opacity rounded-[24px] z-1 px-6 py-4 opacity-0; + @apply transition-opacity duration-300; + + transform: translateX(-50%); + backdrop-filter: blur(20px) saturate(180%); +} diff --git a/src/components/ui/markdown/components/gallery/index.tsx b/src/components/ui/markdown/components/gallery/index.tsx new file mode 100644 index 0000000000..51b09a8994 --- /dev/null +++ b/src/components/ui/markdown/components/gallery/index.tsx @@ -0,0 +1,225 @@ +// import React, { +// useCallback, +// useContext, +// useEffect, +// useMemo, +// useRef, +// useState, +// } from 'react' +// import { useInView } from 'react-intersection-observer' +// import clsx from 'clsx' +// import throttle from 'lodash-es/throttle' +// import type { FC, UIEventHandler } from 'react' +// import type { MImageType } from '../../utils/image' + +// // import { ImageSizeMetaContext } from '~/components/ui/Image/context' +// // import { calculateDimensions } from '~/components/ui/Image/utils/calc-image' +// import { useStateToRef } from '~/hooks/common/use-state-ref' + +// import { ZoomedImage } from '../../../image' +// import styles from './index.module.css' + +// const IMAGE_CONTAINER_MARGIN_INSET = 60 +// const CHILD_GAP = 15 +// const AUTOPLAY_DURATION = 5000 + +// interface GalleryProps { +// images: MImageType[] +// } + +// export const Gallery: FC = (props) => { +// const { images } = props +// const imageMeta = useContext(ImageSizeMetaContext) +// const [containerRef, setContainerRef] = useState(null) +// const containerWidth = useMemo( +// () => containerRef?.clientWidth || 0, +// [containerRef?.clientWidth], +// ) + +// const [, setUpdated] = useState({}) +// const memoedChildContainerWidthRef = useRef(0) + +// useEffect(() => { +// if (!containerRef) { +// return +// } + +// const ob = new ResizeObserver(() => { +// setUpdated({}) +// calChild(containerRef) +// }) +// function calChild(containerRef: HTMLDivElement) { +// const $child = containerRef.children.item(0) +// if ($child) { +// memoedChildContainerWidthRef.current = $child.clientWidth +// } +// } + +// calChild(containerRef) + +// ob.observe(containerRef) +// return () => { +// ob.disconnect() +// } +// }, [containerRef]) + +// const childStyle = useRef({ +// width: `calc(100% - ${IMAGE_CONTAINER_MARGIN_INSET}px)`, +// marginRight: `${CHILD_GAP}px`, +// }).current + +// const [currentIndex, setCurrentIndex] = useState(0) + +// // eslint-disable-next-line react-hooks/exhaustive-deps +// const handleOnScroll: UIEventHandler = useCallback( +// throttle>((e) => { +// const $ = e.target as HTMLDivElement + +// const index = Math.floor( +// ($.scrollLeft + IMAGE_CONTAINER_MARGIN_INSET + 15) / +// memoedChildContainerWidthRef.current, +// ) +// setCurrentIndex(index) +// }, 60), +// [], +// ) +// const handleScrollTo = useCallback( +// (i: number) => { +// if (!containerRef) { +// return +// } + +// containerRef.scrollTo({ +// left: memoedChildContainerWidthRef.current * i, +// behavior: 'smooth', +// }) +// }, +// [containerRef], +// ) + +// const autoplayTimerRef = useRef(null as any) + +// const currentIndexRef = useStateToRef(currentIndex) +// const totalImageLengthRef = useStateToRef(images.length) + +// // 向后翻页状态 +// const isForward = useRef(true) + +// const autoplayRef = useRef(true) +// const handleCancelAutoplay = useCallback(() => { +// if (!autoplayRef.current) { +// return +// } + +// autoplayRef.current = false +// clearInterval(autoplayTimerRef.current) +// }, []) + +// const { ref } = useInView({ +// initialInView: false, +// triggerOnce: images.length < 2, +// onChange(inView) { +// if (totalImageLengthRef.current < 2 || !autoplayRef.current) { +// return +// } +// if (inView) { +// autoplayTimerRef.current = setInterval(() => { +// if ( +// currentIndexRef.current + 1 > totalImageLengthRef.current - 1 && +// isForward.current +// ) { +// isForward.current = false +// } +// if (currentIndexRef.current - 1 < 0 && !isForward.current) { +// isForward.current = true +// } + +// const index = currentIndexRef.current + (isForward.current ? 1 : -1) +// handleScrollTo(index) +// }, AUTOPLAY_DURATION) +// } else { +// autoplayTimerRef.current = clearInterval(autoplayTimerRef.current) +// } +// }, +// }) + +// useEffect(() => { +// return () => { +// clearInterval(autoplayTimerRef.current) +// } +// }, []) + +// return ( +//
    +//
    +// {images.map((image) => { +// const info = imageMeta.get(image.url) + +// const maxWidth = containerWidth - IMAGE_CONTAINER_MARGIN_INSET +// const { height, width } = calculateDimensions( +// info?.width || 0, +// info?.height || 0, +// { +// width: maxWidth, + +// height: 600, +// }, +// ) +// const alt = image.name +// const title = image.footnote +// const imageCaption = +// title || +// (['!', '¡'].some((ch) => ch == alt?.[0]) ? alt?.slice(1) : '') || +// '' +// return ( +//
    +// +//
    +// ) +// })} +//
    + +//
    +// {Array.from({ +// length: images.length, +// }).map((_, i) => { +// return ( +//
    +// ) +// })} +//
    +//
    +// ) +// } +// TODO: 重构 +export const Gallery = () => null \ No newline at end of file diff --git a/src/components/ui/markdown/index.module.css b/src/components/ui/markdown/index.module.css new file mode 100644 index 0000000000..1209d323c4 --- /dev/null +++ b/src/components/ui/markdown/index.module.css @@ -0,0 +1,55 @@ +.md { + &.code-fully pre > code { + max-height: unset !important; + } + + summary { + list-style: none; + + &:hover { + opacity: 0.8; + } + } + + summary::marker { + display: none; + } + + details summary::before { + content: '+ '; + } + + details[open] summary::before { + content: '- '; + } + + details[open] summary::before, + details summary::before { + font-weight: 800; + font-family: var(--mono-font); + } + + sub span, + sup span { + border: 0 !important; + } + + sub, + sup { + & > a { + @apply inline-block; + } + + & > a::first-letter { + @apply hidden; + } + } + + li > p { + display: inline; + } + + li { + margin: 0.5em 0; + } +} diff --git a/src/components/ui/markdown/index.ts b/src/components/ui/markdown/index.ts new file mode 100644 index 0000000000..0cc9bef8e6 --- /dev/null +++ b/src/components/ui/markdown/index.ts @@ -0,0 +1,3 @@ +export * from './Markdown' + +export * from 'markdown-to-jsx' diff --git a/src/components/ui/markdown/parsers/comment-at.tsx b/src/components/ui/markdown/parsers/comment-at.tsx new file mode 100644 index 0000000000..98b32681a0 --- /dev/null +++ b/src/components/ui/markdown/parsers/comment-at.tsx @@ -0,0 +1,23 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { + Priority, + parseCaptureInline, + simpleInlineRegex, +} from 'markdown-to-jsx' +import React, { Fragment } from 'react' + +// @ +export const CommentAtRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex(/^@(\w+)\s/), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, _, state) { + const { content } = node + + if (!content || !content[0]?.content) { + return + } + + return @{content[0]?.content} + }, +} diff --git a/src/components/ui/markdown/parsers/container.tsx b/src/components/ui/markdown/parsers/container.tsx new file mode 100644 index 0000000000..70393aeae5 --- /dev/null +++ b/src/components/ui/markdown/parsers/container.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { blockRegex, Priority } from 'markdown-to-jsx' +import type { MarkdownToJSX } from 'markdown-to-jsx' + +import { Banner } from '../../banner/Banner' +import { Gallery } from '../components/gallery' + +const shouldCatchContainerName = ['gallery', 'banner', 'carousel'].join('|') +export const ContainerRule: MarkdownToJSX.Rule = { + match: blockRegex( + new RegExp( + `^\\s*::: *(?(${shouldCatchContainerName})) *({(?(.*?))})? *\n(?[\\s\\S]+?)\\s*::: *(?:\n *)+\n?`, + ), + ), + order: Priority.MED, + parse(capture) { + const { groups } = capture + return { + ...groups, + } + }, + // @ts-ignore + react(node, _, state) { + const { name, content, params } = node + + switch (name) { + case 'carousel': + case 'gallery': { + return ( + // + // TODO + + ) + } + case 'warn': + case 'error': + case 'danger': + case 'info': + case 'success': + case 'warning': { + const transformMap = { + warning: 'warn', + danger: 'error', + } + return ( + + ) + } + case 'banner': { + if (!params) { + break + } + + return ( + + ) + } + } + + return ( +
    +

    {content}

    +
    + ) + }, +} + +/** + * gallery container + * + * ::: gallery + * ![name](url) + * ![name](url) + * ![name](url) + * ::: + */ diff --git a/src/components/ui/markdown/parsers/ins.tsx b/src/components/ui/markdown/parsers/ins.tsx new file mode 100644 index 0000000000..2760cb9cdb --- /dev/null +++ b/src/components/ui/markdown/parsers/ins.tsx @@ -0,0 +1,19 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { + Priority, + parseCaptureInline, + simpleInlineRegex, +} from 'markdown-to-jsx' +import React from 'react' + +// ++Insert++ +export const InsertRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex( + /^\+\+((?:\[.*?\]|<.*?>(?:.*?<.*?>)?|`.*?`|.)*?)\+\+/, + ), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, output, state?) { + return {output(node.content, state!)} + }, +} diff --git a/src/components/ui/markdown/parsers/katex.tsx b/src/components/ui/markdown/parsers/katex.tsx new file mode 100644 index 0000000000..d3c6faca08 --- /dev/null +++ b/src/components/ui/markdown/parsers/katex.tsx @@ -0,0 +1,51 @@ +import React, { useState } from 'react' +import { + parseCaptureInline, + Priority, + simpleInlineRegex, +} from 'markdown-to-jsx' +import type { MarkdownToJSX } from 'markdown-to-jsx' +import type { FC } from 'react' + +import { loadScript, loadStyleSheet } from '~/lib/load-script' + +// @ts-ignore +const useInsertionEffect = React.useInsertionEffect || React.useEffect +// $ c = \pm\sqrt{a^2 + b^2} $ +export const KateXRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex( + /^\$\s{1,}((?:\[.*?\]|<.*?>(?:.*?<.*?>)?|`.*?`|.)*?)\s{1,}\$/, + ), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, _, state?) { + try { + const str = node.content.map((item: any) => item.content).join('') + + return {str} + } catch { + return null as any + } + }, +} + +const LateX: FC<{ children: string }> = (props) => { + const { children } = props + + const [html, setHtml] = useState('') + + useInsertionEffect(() => { + loadStyleSheet( + 'https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/KaTeX/0.15.2/katex.min.css', + ) + loadScript( + 'https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-M/KaTeX/0.15.2/katex.min.js', + ).then(() => { + // @ts-ignore + const html = window.katex.renderToString(children) + setHtml(html) + }) + }, []) + + return +} diff --git a/src/components/ui/markdown/parsers/mark.tsx b/src/components/ui/markdown/parsers/mark.tsx new file mode 100644 index 0000000000..5337be41c7 --- /dev/null +++ b/src/components/ui/markdown/parsers/mark.tsx @@ -0,0 +1,24 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { + Priority, + parseCaptureInline, + simpleInlineRegex, +} from 'markdown-to-jsx' +import React from 'react' + +// ==Mark== +export const MarkRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex(/^==((?:\[.*?\]|<.*?>(?:.*?<.*?>)?|`.*?`|.)*?)==/), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, output, state?) { + return ( + + {output(node.content, state!)} + + ) + }, +} diff --git a/src/components/ui/markdown/parsers/mention.tsx b/src/components/ui/markdown/parsers/mention.tsx new file mode 100644 index 0000000000..42c2399e7c --- /dev/null +++ b/src/components/ui/markdown/parsers/mention.tsx @@ -0,0 +1,69 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { Priority, simpleInlineRegex } from 'markdown-to-jsx' +import React from 'react' +import { CodiconGithubInverted, MdiTwitter, IcBaselineTelegram } from '~/components/icons/menu-collection' + + +const prefixToIconMap = { + GH: , + TW: , + TG: , +} + +const prefixToUrlMap = { + GH: 'https://github.com/', + TW: 'https://twitter.com/', + TG: 'https://t.me/', +} + +// {GH@Innei} {TW@Innei} {TG@Innei} +export const MentionRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex( + /^\{((?(GH)|(TW)|(TG))@(?\w+\b))\}\s?(?!\[.*?\])/, + ), + order: Priority.MIN, + parse(capture) { + const { groups } = capture + + if (!groups) { + return {} + } + return { + content: { prefix: groups.prefix, name: groups.name }, + type: 'mention', + } + }, + react(result, _, state) { + const { content } = result + if (!content) { + return null as any + } + + const { prefix, name } = content + if (!name) { + return null as any + + } + + // @ts-ignore + const Icon = prefixToIconMap[prefix] + // @ts-ignore + const urlPrefix = prefixToUrlMap[prefix] + + return ( +
    + {Icon} + + {name} + +
    + ) + }, +} diff --git a/src/components/ui/markdown/parsers/spoiler.tsx b/src/components/ui/markdown/parsers/spoiler.tsx new file mode 100644 index 0000000000..946939bc80 --- /dev/null +++ b/src/components/ui/markdown/parsers/spoiler.tsx @@ -0,0 +1,23 @@ +import type { MarkdownToJSX } from 'markdown-to-jsx' +import { + Priority, + parseCaptureInline, + simpleInlineRegex, +} from 'markdown-to-jsx' +import React from 'react' + +// ||Spoilder|| +export const SpoilderRule: MarkdownToJSX.Rule = { + match: simpleInlineRegex( + /^\|\|((?:\[.*?\]|<.*?>(?:.*?<.*?>)?|`.*?`|.)*?)\|\|/, + ), + order: Priority.LOW, + parse: parseCaptureInline, + react(node, output, state?) { + return ( + + {output(node.content, state!)} + + ) + }, +} diff --git a/src/components/ui/markdown/renderers/collapse.module.css b/src/components/ui/markdown/renderers/collapse.module.css new file mode 100644 index 0000000000..62341f4a41 --- /dev/null +++ b/src/components/ui/markdown/renderers/collapse.module.css @@ -0,0 +1,15 @@ +.collapse { + @apply my-2; + + :global(.ReactCollapse--collapse) { + transition: height 200ms; + } + + .title { + @apply mb-2 pl-2 flex items-center; + } + + p { + @apply !m-0; + } +} diff --git a/src/components/ui/markdown/renderers/collapse.tsx b/src/components/ui/markdown/renderers/collapse.tsx new file mode 100644 index 0000000000..14264f166f --- /dev/null +++ b/src/components/ui/markdown/renderers/collapse.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react' +import clsx from 'clsx' +import type { FC, ReactNode } from 'react' + +import { IcRoundKeyboardDoubleArrowRight } from '~/components/icons/arrow' + +import { Collapse } from '../../collapse' +import styles from './collapse.module.css' + +export const MDetails: FC<{ children: ReactNode[] }> = (props) => { + const [open, setOpen] = useState(false) + + const $head = props.children[0] + + return ( +
    +
    { + setOpen((o) => !o) + }} + > + + + + {$head} +
    + +
    + {props.children.slice(1)} +
    +
    +
    + ) +} diff --git a/src/components/ui/markdown/renderers/footnotes.tsx b/src/components/ui/markdown/renderers/footnotes.tsx new file mode 100644 index 0000000000..bdade4c394 --- /dev/null +++ b/src/components/ui/markdown/renderers/footnotes.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import type { FC, PropsWithChildren } from 'react' + +import { Divider } from '../../divider' + +export const MFootNote: FC = (props) => { + return ( +
    + + {props.children} +
    + ) +} diff --git a/src/components/ui/markdown/renderers/index.module.css b/src/components/ui/markdown/renderers/index.module.css new file mode 100644 index 0000000000..a423420a46 --- /dev/null +++ b/src/components/ui/markdown/renderers/index.module.css @@ -0,0 +1,28 @@ +.link { + display: inline-block; + position: relative; + + a { + cursor: alias; + overflow: hidden; + position: relative; + color: var(--primary); + } + + a::after { + content: ''; + position: absolute; + bottom: -1.9px; + height: 1px; + background-color: currentColor; + width: 0; + transform: translateX(-50%); + left: 50%; + text-align: center; + transition: width 0.5s ease-in-out; + } + + a:hover::after { + width: 100%; + } +} diff --git a/src/components/ui/markdown/renderers/index.ts b/src/components/ui/markdown/renderers/index.ts new file mode 100644 index 0000000000..71bb1f9e00 --- /dev/null +++ b/src/components/ui/markdown/renderers/index.ts @@ -0,0 +1,4 @@ +export * from './collapse' +export * from './footnotes' +export * from './paragraph' +export * from './table' diff --git a/src/components/ui/markdown/renderers/paragraph.tsx b/src/components/ui/markdown/renderers/paragraph.tsx new file mode 100644 index 0000000000..0f3fe2c775 --- /dev/null +++ b/src/components/ui/markdown/renderers/paragraph.tsx @@ -0,0 +1,15 @@ +import clsx from 'clsx' +import type { DetailedHTMLProps, FC, HTMLAttributes } from 'react' +import React from 'react' + +export const MParagraph: FC< + DetailedHTMLProps, HTMLParagraphElement> +> = (props) => { + const { children, ...other } = props + const { className, ...rest } = other + return ( +

    + {children} +

    + ) +} diff --git a/src/components/ui/markdown/renderers/table.tsx b/src/components/ui/markdown/renderers/table.tsx new file mode 100644 index 0000000000..a2f0dc18c9 --- /dev/null +++ b/src/components/ui/markdown/renderers/table.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import clsx from 'clsx' +import type { FC } from 'react' + +// TODO re-style +export const MTableHead: FC = (props) => { + const { children, className, ...rest } = props + return ( + + {children} + + ) +} + +export const MTableRow: FC = (props) => { + const { children, ...rest } = props + return {children} +} + +export const MTableBody: FC = (props) => { + const { children, ...rest } = props + return {children} +} diff --git a/src/components/ui/markdown/test-text.md b/src/components/ui/markdown/test-text.md new file mode 100644 index 0000000000..91b32c0a28 --- /dev/null +++ b/src/components/ui/markdown/test-text.md @@ -0,0 +1,341 @@ +> 影响大众想象力的,并不是事实本身,而是它扩散和传播的方式。 + +↑ 引用 + +# Test 文本 + +> 影响大众想象力的,并不是事实本身,而是它扩散和传播的方式。 + +# 一级 + +我与父亲不相见已二年余了,我最不能忘记的是他的背影。 + +那年冬天,祖母死了,父亲的差使也交卸了,正是祸不单行的日子。我从北京到徐州,打算跟着父亲奔丧回家。到徐州见着父亲,看见满院狼藉的东西,又想起祖母,不禁簌簌地流下眼泪。父亲说:“事已如此,不必难过,好在天无绝人之路!” + +## 二级 + +回家变卖典质,父亲还了亏空;又借钱办了丧事。这些日子,家中光景很是惨澹,一半为了丧事,一半为了父亲赋闲。丧事完毕,父亲要到南京谋事,我也要回北京念书,我们便同行。 + +到南京时,有朋友约去游逛,勾留了一日;第二日上午便须渡江到浦口,下午上车北去。父亲因为事忙,本已说定不送我,叫旅馆里一个熟识的茶房陪我同去。他再三嘱咐茶房,甚是仔细。但他终于不放心,怕茶房不妥帖;颇踌躇了一会。其实我那年已二十岁,北京已来往过两三次,是没有什么要紧的了。他踌躇了一会,终于决定还是自己送我去。我再三劝他不必去;他只说:“不要紧,他们去不好!” + +### 三级 + +> 影响大众想象力的,并不是事实本身,而是它扩散和传播的方式。 + +#### 四级 + +\`code: \` + +```tsx + +``` + +|| 你知道的太多了 || spoiler || 你知道的太多了 || + +[链接](https://baidu.com) + +![!图片描述](https://gitee.com/xun7788/my-imagination/raw/master/uPic/1615516941397.jpg) + +↑ 图片描述 + +**加粗: 歌词** + +~~dele~~ 删除 + +```mermaid +flowchart TD + 1([手动打 tag 发布一个 release]) --> + 2([CI 监视 release 的发布 开始构建和发布]) --> + 3([云构建打包产物 zip 发布到 GitHub Release]) -- SSH 连接到服务器--> + 4([执行部署脚本]) --> + 5([下载构建产物解压]) --> + 6([直接运行或使用 PM2 托管]) +``` + +GH Mention: (GH@Innei) + +> _夕暮れ_ +> +> 作詞:甲本ヒロト +> 作曲:甲本ヒロト +> +> はっきりさせなくてもいい +> あやふやなまんまでいい +> 僕達はなんなとなく幸せになるんだ +> +> 何年たってもいい 遠く離れてもいい +> 独りぼっちじゃないぜウィンクするぜ +> +> 夕暮れが僕のドアをノックする頃に +> あなたを「ギュッ」と抱きたくなってる +> 幻なんかじゃない 人生は夢じゃない +> 僕達ははっきりと生きてるんだ +> 夕焼け空は赤い 炎のように赤い +> この星の半分を真っ赤に染めた +> それよりももっと赤い血が +> 体中を流れてるんだぜ +> 夕暮れが僕のドアをノックする頃に +> +> あなたを「ギュッ」と抱きたくなってる +> 幻なんかじゃない 人生は夢じゃない +> 僕達ははっきりと生きてるんだ +> +> 夕焼け空は赤い 炎のように赤い +> この星の半分を真っ赤に染めた +> +> それよりももっと赤い血が +> 体中を流れてるんだぜ +> 体中を流れてるんだぜ +> 体中を流れてるんだぜ + +--- + +**Advertisement :)** + +- **[pica](https://nodeca.github.io/pica/demo/)** - high quality and fast image + resize in browser. +- **[babelfish](https://github.com/nodeca/babelfish/)** - developer friendly + i18n with plurals support and easy syntax. + +You will like those projects! + +--- + +# h1 Heading 8-) + +## h2 Heading + +### h3 Heading + +#### h4 Heading + +##### h5 Heading + +###### h6 Heading + +## Horizontal Rules + +--- + +--- + +--- + +## Typographic replacements + +Enable typographer option to see result. + +(c) (C) (r) (R) (tm) (TM) (p) (P) +- + +test.. test... test..... test?..... test!.... + +!!!!!! ???? ,, -- --- + +"Smartypants, double quotes" and 'single quotes' + +## Emphasis + +**This is bold text** + +**This is bold text** + +_This is italic text_ + +_This is italic text_ + +~~Strikethrough~~ + +## Blockquotes + +> Blockquotes can also be nested... +> +> > ...by using additional greater-than signs right next to each other... +> > +> > > ...or with spaces between arrows. + +## Lists + +Unordered + +- Create a list by starting a line with `+`, `-`, or `*` +- Sub-lists are made by indenting 2 spaces: + - Marker character change forces new list start: + - Ac tristique libero volutpat at + * Facilisis in pretium nisl aliquet + - Nulla volutpat aliquam velit +- Very easy! + +Ordered + +1. Lorem ipsum dolor sit amet +2. Consectetur adipiscing elit +3. Integer molestie lorem at massa + +4. You can use sequential numbers... +5. ...or keep all the numbers as `1.` + +Start numbering with offset: + +57. foo +1. bar + +## Code + +Inline `code` + +Indented code + + // Some comments + line 1 of code + line 2 of code + line 3 of code + +Block code "fences" + +``` +Sample text here... +``` + +Syntax highlighting + +```js +var foo = function (bar) { + return bar++ +} + +console.log(foo(5)) +``` + +## Tables + +| Option | Description | +| ------ | ------------------------------------------------------------------------- | +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | + +Right aligned columns + +| Option | Description | +| -----: | ------------------------------------------------------------------------: | +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | + +## Links + +[link text](http://dev.nodeca.com) + +[link with title](http://nodeca.github.io/pica/demo/ 'title text!') + +Autoconverted link https://github.com/nodeca/pica (enable linkify to see) + +## Images + +![Minion](https://octodex.github.com/images/minion.png) +![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg 'The Stormtroopocat') + +Like links, Images also have a footnote style syntax + +![Alt text][id] + +With a reference later in the document defining the URL location: + +[id]: https://octodex.github.com/images/dojocat.jpg 'The Dojocat' + +## Plugins + +The killer feature of `markdown-it` is very effective support of +[syntax plugins](https://www.npmjs.org/browse/keyword/markdown-it-plugin). + +### [Emojies](https://github.com/markdown-it/markdown-it-emoji) + +> Classic markup: :wink: :crush: :cry: :tear: :laughing: :yum: +> +> Shortcuts (emoticons): :-) :-( 8-) ;) + +see [how to change output](https://github.com/markdown-it/markdown-it-emoji#change-output) with twemoji. + +### [Subscript](https://github.com/markdown-it/markdown-it-sub) / [Superscript](https://github.com/markdown-it/markdown-it-sup) + +- 19^th^ +- H~2~O + +### [\](https://github.com/markdown-it/markdown-it-ins) + +++Inserted text++ + +### [\](https://github.com/markdown-it/markdown-it-mark) + +==Marked text== + +### [Footnotes](https://github.com/markdown-it/markdown-it-footnote) + +Footnote 1 link[^first]. + +Footnote 2 link[^second]. + +Inline footnote^[Text of inline footnote] definition. + +Duplicated footnote reference[^second]. + +[^first]: Footnote **can have markup** + + and multiple paragraphs. + +[^second]: Footnote text. + +### [Definition lists](https://github.com/markdown-it/markdown-it-deflist) + +Term 1 + +: Definition 1 +with lazy continuation. + +Term 2 with _inline markup_ + +: Definition 2 + + { some code, part of Definition 2 } + + Third paragraph of definition 2. + +_Compact style:_ + +Term 1 +~ Definition 1 + +Term 2 +~ Definition 2a +~ Definition 2b + +### [Abbreviations](https://github.com/markdown-it/markdown-it-abbr) + +This is HTML abbreviation example. + +It converts "HTML", but keep intact partial entries like "xxxHTMLyyy" and so on. + +\*[HTML]: Hyper Text Markup Language + +### [Custom containers](https://github.com/markdown-it/markdown-it-container) + +::: warning +_here be dragons_ +::: diff --git a/src/components/ui/markdown/utils/image.ts b/src/components/ui/markdown/utils/image.ts new file mode 100644 index 0000000000..6c85c032a7 --- /dev/null +++ b/src/components/ui/markdown/utils/image.ts @@ -0,0 +1,25 @@ +export interface MImageType { + name?: string + url: string + footnote?: string +} +export const pickImagesFromMarkdown = (md: string) => { + const regexp = + /^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*?(?:\s+['"]([\s\S]*?)['"])?\s*\)/ + + const lines = md.split('\n') + + const res: MImageType[] = [] + + for (const line of lines) { + const match = regexp.exec(line) + if (!match) { + continue + } + + const [, name, url, footnote] = match + res.push({ name, url, footnote }) + } + + return res +} diff --git a/src/lib/_.ts b/src/lib/_.ts index 40f5d63ac7..9b04766351 100644 --- a/src/lib/_.ts +++ b/src/lib/_.ts @@ -72,3 +72,11 @@ export const cloneDeep = (val: T): T => { return val } } + +export const range = (start: number, end: number): number[] => { + const result: number[] = [] + for (let i = start; i < end; i++) { + result.push(i) + } + return result +} diff --git a/src/lib/fonts.ts b/src/lib/fonts.ts index f97679d045..1d20aa064c 100644 --- a/src/lib/fonts.ts +++ b/src/lib/fonts.ts @@ -1,10 +1,16 @@ -import { Manrope } from 'next/font/google' +import { Manrope, Noto_Serif } from 'next/font/google' const sansFont = Manrope({ subsets: ['latin'], - weight: ['400', '500', '600', '700', '800'], + weight: ['400', '500'], variable: '--font-sans', display: 'swap', }) +const serifFont = Noto_Serif({ + subsets: ['latin'], + weight: ['400'], + variable: '--font-serif', + display: 'swap', +}) -export { sansFont } +export { sansFont, serifFont } diff --git a/src/lib/load-script.ts b/src/lib/load-script.ts new file mode 100644 index 0000000000..4df639e0bf --- /dev/null +++ b/src/lib/load-script.ts @@ -0,0 +1,101 @@ +import { isDev } from '~/utils/env' + +const isLoadScriptMap: Record = {} +const loadingQueueMap: Record = {} +export function loadScript(url: string) { + return new Promise((resolve, reject) => { + const status = isLoadScriptMap[url] + if (status === 'loaded') { + return resolve(null) + } else if (status === 'loading') { + loadingQueueMap[url] = !loadingQueueMap[url] + ? [[resolve, reject]] + : [...loadingQueueMap[url], [resolve, reject]] + return + } + + const script = document.createElement('script') + script.src = url + script.crossOrigin = 'anonymous' + + isLoadScriptMap[url] = 'loading' + script.onload = function () { + isLoadScriptMap[url] = 'loaded' + resolve(null) + if (loadingQueueMap[url]) { + loadingQueueMap[url].forEach(([resolve, reject]) => { + resolve(null) + }) + delete loadingQueueMap[url] + } + } + + if (isDev) { + console.log('load script: ', url) + } + + script.onerror = function (e) { + // this.onload = null here is necessary + // because even IE9 works not like others + this.onerror = this.onload = null + delete isLoadScriptMap[url] + loadingQueueMap[url].forEach(([resolve, reject]) => { + reject(e) + }) + delete loadingQueueMap[url] + reject(e) + } + + document.head.appendChild(script) + }) +} + +const cssMap = new Map() + +export function loadStyleSheet(href: string) { + if (cssMap.has(href)) { + const $link = cssMap.get(href)! + return { + $link, + remove: () => { + $link.parentNode && $link.parentNode.removeChild($link) + cssMap.delete(href) + }, + } + } + const $link = document.createElement('link') + $link.href = href + $link.rel = 'stylesheet' + $link.type = 'text/css' + $link.crossOrigin = 'anonymous' + cssMap.set(href, $link) + + $link.onerror = () => { + $link.onerror = null + cssMap.delete(href) + } + + document.head.appendChild($link) + + return { + remove: () => { + $link.parentNode && $link.parentNode.removeChild($link) + cssMap.delete(href) + }, + $link, + } +} + +export function appendStyle(style: string) { + let $style: HTMLStyleElement | null = document.createElement('style') + $style.innerHTML = style + document.head.appendChild($style) + return { + remove: () => { + if (!$style) return + $style.parentNode && $style.parentNode.removeChild($style) + $style.remove() + $style = null + }, + } +} diff --git a/tailwind.config.ts b/tailwind.config.ts index f10258989c..cc70f5919a 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -173,9 +173,9 @@ export default resolveConfig({ extend: { fontFamily: { - sans: 'system-ui,-apple-system,PingFang SC,"Microsoft YaHei",Segoe UI,Roboto,Helvetica,noto sans sc,hiragino sans gb,"sans-serif",Apple Color Emoji,Segoe UI Emoji,Not Color Emoji', + sans: 'var(--font-sans),system-ui,-apple-system,PingFang SC,"Microsoft YaHei",Segoe UI,Roboto,Helvetica,noto sans sc,hiragino sans gb,"sans-serif",Apple Color Emoji,Segoe UI Emoji,Not Color Emoji', serif: - '"Noto Serif CJK SC","Noto Serif SC","Source Han Serif SC","Source Han Serif",source-han-serif-sc,SongTi SC,SimSum,"Hiragino Sans GB",system-ui,-apple-system,Segoe UI,Roboto,Helvetica,"Microsoft YaHei","WenQuanYi Micro Hei",sans-serif', + 'var(--font-serif),"Noto Serif CJK SC","Noto Serif SC","Source Han Serif SC","Source Han Serif",source-han-serif-sc,SongTi SC,SimSum,"Hiragino Sans GB",system-ui,-apple-system,Segoe UI,Roboto,Helvetica,"Microsoft YaHei","WenQuanYi Micro Hei",sans-serif', mono: `"OperatorMonoSSmLig Nerd Font","Cascadia Code PL","FantasqueSansMono Nerd Font","operator mono","Fira code Retina","Fira code","Consolas", Monaco, "Hannotate SC", monospace, -apple-system`, }, screens: {