feat: support markdown
This commit is contained in:
parent
bd230ddd4f
commit
85d144a1e9
34 changed files with 2350 additions and 4 deletions
42
ui/components/Markdown/renderer/blockquote.tsx
Normal file
42
ui/components/Markdown/renderer/blockquote.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { Blockquote } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
import { NodesRenderer } from "../NodesRenderer";
|
||||
|
||||
/**
|
||||
* Render `blockquote`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#blockquote
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-blockquote
|
||||
*/
|
||||
export class BlockquoteRenderer extends React.Component<Blockquote> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<Blockquote>): boolean {
|
||||
const props = this.props;
|
||||
return props.children !== nextProps.children;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const childNodes = this.props.children;
|
||||
return (
|
||||
<blockquote className={cls}>
|
||||
<NodesRenderer nodes={childNodes} />
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cls = cx(
|
||||
astClasses.blockquote,
|
||||
css({
|
||||
boxSizing: "border-box",
|
||||
padding: "0.625em 1em",
|
||||
borderLeft: "0.25em solid var(--colorBorderBlockquote)",
|
||||
margin: "0px 0px 1.25em 0px",
|
||||
background: "var(--colorBgBlockquote)",
|
||||
boxShadow: "0 1px 2px 0 hsla(0deg, 0%, 0%, 0.1)",
|
||||
"> :last-child": {
|
||||
marginBottom: 0,
|
||||
},
|
||||
}),
|
||||
);
|
27
ui/components/Markdown/renderer/break.tsx
Normal file
27
ui/components/Markdown/renderer/break.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { Break } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
|
||||
/**
|
||||
* Render `break`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#break
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-break
|
||||
*/
|
||||
export class BreakRenderer extends React.Component<Break> {
|
||||
public override shouldComponentUpdate(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
return <br className={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
const cls = cx(
|
||||
astClasses.break,
|
||||
css({
|
||||
boxSizing: "border-box",
|
||||
}),
|
||||
);
|
32
ui/components/Markdown/renderer/code.tsx
Normal file
32
ui/components/Markdown/renderer/code.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { useStateValue } from "@guanghechen/react-viewmodel";
|
||||
import type { Code } from "@yozora/ast";
|
||||
import { useNodeRendererContext, type INodeRenderer, IReactMarkdownThemeScheme } from "../context";
|
||||
import { CodeRendererInner } from "./inner/CodeRendererInner";
|
||||
|
||||
/**
|
||||
* Render `code`
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#code
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-indented-code
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-fenced-code
|
||||
*/
|
||||
export const CodeRenderer: INodeRenderer<Code> = props => {
|
||||
const { lang } = props;
|
||||
const value: string = props.value.replace(/[\r\n]+$/, ""); // Remove trailing line endings.
|
||||
|
||||
const { viewmodel } = useNodeRendererContext();
|
||||
const preferCodeWrap: boolean = useStateValue(viewmodel.preferCodeWrap$);
|
||||
const showCodeLineno: boolean = useStateValue(viewmodel.showCodeLineno$);
|
||||
const themeScheme: IReactMarkdownThemeScheme = useStateValue(viewmodel.themeScheme$);
|
||||
const darken: boolean = themeScheme === "darken";
|
||||
|
||||
return (
|
||||
<CodeRendererInner
|
||||
darken={darken}
|
||||
lang={lang ?? "text"}
|
||||
value={value}
|
||||
preferCodeWrap={preferCodeWrap}
|
||||
showCodeLineno={showCodeLineno}
|
||||
/>
|
||||
);
|
||||
};
|
37
ui/components/Markdown/renderer/delete.tsx
Normal file
37
ui/components/Markdown/renderer/delete.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { Delete, Node } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
import { NodesRenderer } from "../NodesRenderer";
|
||||
|
||||
/**
|
||||
* Render `delete`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#delete
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-delete
|
||||
*/
|
||||
export class DeleteRenderer extends React.Component<Delete> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<Delete>): boolean {
|
||||
const props = this.props;
|
||||
return props.children !== nextProps.children;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const childNodes: Node[] = this.props.children;
|
||||
return (
|
||||
<del className={cls}>
|
||||
<NodesRenderer nodes={childNodes} />
|
||||
</del>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cls = cx(
|
||||
astClasses.delete,
|
||||
css({
|
||||
marginRight: "4px",
|
||||
color: "var(--colorDelete)",
|
||||
fontStyle: "italic",
|
||||
textDecoration: "line-through",
|
||||
}),
|
||||
);
|
35
ui/components/Markdown/renderer/emphasis.tsx
Normal file
35
ui/components/Markdown/renderer/emphasis.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { Emphasis, Node } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
import { NodesRenderer } from "../NodesRenderer";
|
||||
|
||||
/**
|
||||
* Render `emphasis`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#emphasis
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-emphasis
|
||||
*/
|
||||
export class EmphasisRenderer extends React.Component<Emphasis> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<Emphasis>): boolean {
|
||||
const props = this.props;
|
||||
return props.children !== nextProps.children;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const childNodes: Node[] = this.props.children;
|
||||
return (
|
||||
<em className={cls}>
|
||||
<NodesRenderer nodes={childNodes} />
|
||||
</em>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cls = cx(
|
||||
astClasses.emphasis,
|
||||
css({
|
||||
fontStyle: "italic",
|
||||
margin: "0 6px 0 2px",
|
||||
}),
|
||||
);
|
135
ui/components/Markdown/renderer/heading.tsx
Normal file
135
ui/components/Markdown/renderer/heading.tsx
Normal file
|
@ -0,0 +1,135 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { Heading } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
import { NodesRenderer } from "../NodesRenderer";
|
||||
|
||||
type IHeading = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
|
||||
interface IProps extends Heading {
|
||||
linkIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `heading` content.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#heading
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-heading
|
||||
*/
|
||||
export class HeadingRenderer extends React.Component<IProps> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
|
||||
const props = this.props;
|
||||
return (
|
||||
props.depth !== nextProps.depth ||
|
||||
props.identifier !== nextProps.identifier ||
|
||||
props.children !== nextProps.children ||
|
||||
props.linkIcon !== nextProps.linkIcon
|
||||
);
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const { depth, identifier, children, linkIcon = "¶" } = this.props;
|
||||
|
||||
const id = identifier == null ? undefined : encodeURIComponent(identifier);
|
||||
const h: IHeading = ("h" + depth) as IHeading;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const H: any = h as keyof JSX.IntrinsicElements;
|
||||
const cls = cx(astClasses.heading, classes.heading, classes[h]);
|
||||
|
||||
return (
|
||||
<H id={id} className={cls}>
|
||||
<p className={classes.content}>
|
||||
<NodesRenderer nodes={children} />
|
||||
</p>
|
||||
{identifier && (
|
||||
<a className={classes.anchor} href={"#" + id}>
|
||||
{linkIcon}
|
||||
</a>
|
||||
)}
|
||||
</H>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const anchorCls = css({
|
||||
flex: "0 0 3rem",
|
||||
paddingLeft: "0.5rem",
|
||||
color: "var(--colorLink)",
|
||||
opacity: 0,
|
||||
transition: "color 0.2s ease-in-out, opacity 0.2s ease-in-out",
|
||||
userSelect: "none",
|
||||
textDecoration: "none",
|
||||
"> svg": {
|
||||
overflow: "hidden",
|
||||
display: "inline-block",
|
||||
verticalAlign: "middle",
|
||||
fill: "currentColor",
|
||||
},
|
||||
});
|
||||
|
||||
const classes = {
|
||||
heading: css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
padding: "0px",
|
||||
margin: "0px 0px 1.25em 0px",
|
||||
marginBottom: "1em",
|
||||
lineHeight: "1.25",
|
||||
fontFamily: "var(--fontFamilyHeading)",
|
||||
color: "var(--colorHeading)",
|
||||
[`&:active .${anchorCls}`]: {
|
||||
opacity: 0.8,
|
||||
color: "var(--colorLinkActive)",
|
||||
},
|
||||
[`&&:hover .${anchorCls}`]: {
|
||||
opacity: 0.8,
|
||||
color: "var(--colorLinkHover)",
|
||||
},
|
||||
}),
|
||||
anchor: anchorCls,
|
||||
content: css({
|
||||
flex: "0 1 auto",
|
||||
minWidth: 0,
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "pre-wrap",
|
||||
lineHeight: "1.7",
|
||||
}),
|
||||
h1: css({
|
||||
padding: "0.3rem 0",
|
||||
borderBottom: "1px solid var(--colorBorderHeading)",
|
||||
fontSize: "2rem",
|
||||
fontStyle: "normal",
|
||||
fontWeight: 500,
|
||||
}),
|
||||
h2: css({
|
||||
padding: "0.3rem 0",
|
||||
borderBottom: "1px solid var(--colorBorderHeading)",
|
||||
fontSize: "1.5rem",
|
||||
fontStyle: "normal",
|
||||
fontWeight: 500,
|
||||
marginBottom: "0.875rem",
|
||||
}),
|
||||
h3: css({
|
||||
fontSize: "1.25rem",
|
||||
fontStyle: "normal",
|
||||
fontWeight: 500,
|
||||
}),
|
||||
h4: css({
|
||||
fontSize: "1rem",
|
||||
fontStyle: "normal",
|
||||
fontWeight: 500,
|
||||
}),
|
||||
h5: css({
|
||||
fontSize: "0.875rem",
|
||||
fontStyle: "normal",
|
||||
fontWeight: 500,
|
||||
}),
|
||||
h6: css({
|
||||
fontSize: "0.85rem",
|
||||
fontStyle: "normal",
|
||||
fontWeight: 500,
|
||||
}),
|
||||
};
|
34
ui/components/Markdown/renderer/image.tsx
Normal file
34
ui/components/Markdown/renderer/image.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import type { Image } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import type { INodeRenderer } from "../context";
|
||||
import { astClasses } from "../context";
|
||||
import { ImageRendererInner } from "./inner/ImageRendererInner";
|
||||
|
||||
/**
|
||||
* Render `image`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#image
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-image
|
||||
*/
|
||||
export const ImageRenderer: INodeRenderer<Image> = props => {
|
||||
const {
|
||||
url: src,
|
||||
alt,
|
||||
title,
|
||||
srcSet,
|
||||
sizes,
|
||||
loading,
|
||||
} = props as Image & React.ImgHTMLAttributes<HTMLElement>;
|
||||
|
||||
return (
|
||||
<ImageRendererInner
|
||||
alt={alt}
|
||||
src={src}
|
||||
title={title}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
loading={loading}
|
||||
className={astClasses.image}
|
||||
/>
|
||||
);
|
||||
};
|
36
ui/components/Markdown/renderer/imageReference.tsx
Normal file
36
ui/components/Markdown/renderer/imageReference.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { useStateValue } from "@guanghechen/react-viewmodel";
|
||||
import type { Definition, ImageReference } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { useNodeRendererContext, type INodeRenderer, astClasses } from "../context";
|
||||
import { ImageRendererInner } from "./inner/ImageRendererInner";
|
||||
|
||||
/**
|
||||
* Render `imageReference`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#imageReference
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-image-reference
|
||||
*/
|
||||
export const ImageReferenceRenderer: INodeRenderer<ImageReference> = props => {
|
||||
const { viewmodel } = useNodeRendererContext();
|
||||
const definitionMap: Readonly<Record<string, Definition>> = useStateValue(
|
||||
viewmodel.definitionMap$,
|
||||
);
|
||||
const { alt, srcSet, sizes, loading } = props as ImageReference &
|
||||
React.ImgHTMLAttributes<HTMLElement>;
|
||||
|
||||
const definition = definitionMap[props.identifier];
|
||||
const src: string = definition?.url ?? "";
|
||||
const title: string | undefined = definition?.title;
|
||||
|
||||
return (
|
||||
<ImageRendererInner
|
||||
alt={alt}
|
||||
src={src}
|
||||
title={title}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
loading={loading}
|
||||
className={astClasses.imageReference}
|
||||
/>
|
||||
);
|
||||
};
|
87
ui/components/Markdown/renderer/index.ts
Normal file
87
ui/components/Markdown/renderer/index.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
BlockquoteType,
|
||||
BreakType,
|
||||
CodeType,
|
||||
DefinitionType,
|
||||
DeleteType,
|
||||
EmphasisType,
|
||||
HeadingType,
|
||||
HtmlType,
|
||||
ImageReferenceType,
|
||||
ImageType,
|
||||
InlineCodeType,
|
||||
LinkReferenceType,
|
||||
LinkType,
|
||||
ListItemType,
|
||||
ListType,
|
||||
ParagraphType,
|
||||
StrongType,
|
||||
TableType,
|
||||
TextType,
|
||||
ThematicBreakType,
|
||||
} from "@yozora/ast";
|
||||
import { INodeRendererMap } from "../context";
|
||||
import { BlockquoteRenderer } from "./blockquote";
|
||||
import { BreakRenderer } from "./break";
|
||||
import { CodeRenderer } from "./code";
|
||||
import { DeleteRenderer } from "./delete";
|
||||
import { EmphasisRenderer } from "./emphasis";
|
||||
import { HeadingRenderer } from "./heading";
|
||||
import { ImageRenderer } from "./image";
|
||||
import { ImageReferenceRenderer } from "./imageReference";
|
||||
import { InlineCodeRenderer } from "./inlineCode";
|
||||
import { LinkRenderer } from "./link";
|
||||
import { LinkReferenceRenderer } from "./linkReference";
|
||||
import { ListRenderer } from "./list";
|
||||
import { ListItemRenderer } from "./listItem";
|
||||
import { ParagraphRenderer } from "./paragraph";
|
||||
import { StrongRenderer } from "./strong";
|
||||
import { TableRenderer } from "./table";
|
||||
import { TextRenderer } from "./text";
|
||||
import { ThematicBreakRenderer } from "./thematicBreak";
|
||||
|
||||
export function buildNodeRendererMap(
|
||||
customizedRendererMap?: Readonly<Partial<INodeRendererMap>>,
|
||||
): Readonly<INodeRendererMap> {
|
||||
if (customizedRendererMap == null) return defaultNodeRendererMap;
|
||||
|
||||
let hasChanged = false;
|
||||
const result: INodeRendererMap = {} as unknown as INodeRendererMap;
|
||||
for (const [key, val] of Object.entries(customizedRendererMap)) {
|
||||
if (val && val !== defaultNodeRendererMap[key]) {
|
||||
hasChanged = true;
|
||||
result[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanged ? { ...defaultNodeRendererMap, ...result } : defaultNodeRendererMap;
|
||||
}
|
||||
|
||||
// Default ast renderer map.
|
||||
export const defaultNodeRendererMap: Readonly<INodeRendererMap> = {
|
||||
[BlockquoteType]: BlockquoteRenderer,
|
||||
[BreakType]: BreakRenderer,
|
||||
[CodeType]: CodeRenderer,
|
||||
[DefinitionType]: () => null,
|
||||
[DeleteType]: DeleteRenderer,
|
||||
[EmphasisType]: EmphasisRenderer,
|
||||
[HeadingType]: HeadingRenderer,
|
||||
[HtmlType]: () => null,
|
||||
[ImageType]: ImageRenderer,
|
||||
[ImageReferenceType]: ImageReferenceRenderer,
|
||||
[InlineCodeType]: InlineCodeRenderer,
|
||||
[LinkType]: LinkRenderer,
|
||||
[LinkReferenceType]: LinkReferenceRenderer,
|
||||
[ListType]: ListRenderer,
|
||||
[ListItemType]: ListItemRenderer,
|
||||
[ParagraphType]: ParagraphRenderer,
|
||||
[StrongType]: StrongRenderer,
|
||||
[TableType]: TableRenderer,
|
||||
[TextType]: TextRenderer,
|
||||
[ThematicBreakType]: ThematicBreakRenderer,
|
||||
_fallback: function ReactMarkdownNodeFallback(node, key) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Cannot find render for \`${node.type}\` type node with key \`${key}\`:`, node);
|
||||
return null;
|
||||
},
|
||||
};
|
36
ui/components/Markdown/renderer/inlineCode.tsx
Normal file
36
ui/components/Markdown/renderer/inlineCode.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { InlineCode } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
|
||||
/**
|
||||
* Render `inline-code`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#inlinecode
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-inline-code
|
||||
*/
|
||||
export class InlineCodeRenderer extends React.Component<InlineCode> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<InlineCode>): boolean {
|
||||
const props = this.props;
|
||||
return props.value !== nextProps.value;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
return <code className={cls}>{this.props.value}</code>;
|
||||
}
|
||||
}
|
||||
|
||||
const cls = cx(
|
||||
astClasses.inlineCode,
|
||||
css({
|
||||
padding: "1px 4px",
|
||||
borderRadius: "4px",
|
||||
margin: 0,
|
||||
background: "hsla(210deg, 15%, 60%, 0.15)",
|
||||
lineHeight: "1.375",
|
||||
color: "var(--colorInlineCode)",
|
||||
fontFamily: "var(--fontFamilyCode)",
|
||||
fontSize: "min(1rem, 18px)",
|
||||
fontWeight: 500,
|
||||
}),
|
||||
);
|
69
ui/components/Markdown/renderer/inner/CodeRendererInner.tsx
Normal file
69
ui/components/Markdown/renderer/inner/CodeRendererInner.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import CodeHighlighter from "@yozora/react-code-highlighter";
|
||||
import React from "react";
|
||||
import { astClasses } from "../../context";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
interface IProps {
|
||||
darken: boolean;
|
||||
lang: string;
|
||||
value: string;
|
||||
preferCodeWrap: boolean;
|
||||
showCodeLineno: boolean;
|
||||
pendingText?: string;
|
||||
copyingText?: string;
|
||||
copiedText?: string;
|
||||
failedText?: string;
|
||||
}
|
||||
|
||||
export class CodeRendererInner extends React.PureComponent<IProps> {
|
||||
public override render(): React.ReactElement {
|
||||
const { calcContentForCopy } = this;
|
||||
const { darken, lang, value, preferCodeWrap, showCodeLineno } = this.props;
|
||||
|
||||
return (
|
||||
<code className={codeCls} data-wrap={preferCodeWrap}>
|
||||
<CodeHighlighter
|
||||
lang={lang}
|
||||
value={value}
|
||||
collapsed={false}
|
||||
showLineNo={showCodeLineno && !preferCodeWrap}
|
||||
darken={darken}
|
||||
/>
|
||||
<div className={copyBtnCls}>
|
||||
<CopyButton calcContentForCopy={calcContentForCopy} />
|
||||
</div>
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
protected calcContentForCopy = (): string => {
|
||||
return this.props.value;
|
||||
};
|
||||
}
|
||||
|
||||
const copyBtnCls = css({
|
||||
position: "absolute",
|
||||
right: "4px",
|
||||
top: "4px",
|
||||
display: "none",
|
||||
});
|
||||
|
||||
const codeCls = cx(
|
||||
astClasses.code,
|
||||
css({
|
||||
position: "relative",
|
||||
display: "block",
|
||||
boxSizing: "border-box",
|
||||
borderRadius: "4px",
|
||||
margin: "0px 0px 1.25em 0px",
|
||||
backgroundColor: "var(--colorBgCode)",
|
||||
[`&:hover > .${copyBtnCls}`]: {
|
||||
display: "inline-block",
|
||||
},
|
||||
[`&&[data-wrap="true"] > div`]: {
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "keep-all",
|
||||
},
|
||||
}),
|
||||
);
|
69
ui/components/Markdown/renderer/inner/CopyButton.tsx
Normal file
69
ui/components/Markdown/renderer/inner/CopyButton.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import { Copy as CopyIcon, ClipboardPaste as CopiedIcon } from "lucide-react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import React from "react";
|
||||
|
||||
export enum CopyStatus {
|
||||
PENDING = 0,
|
||||
COPYING = 1,
|
||||
COPIED = 2,
|
||||
FAILED = 3,
|
||||
}
|
||||
|
||||
export interface ICopyButtonProps {
|
||||
delay?: number;
|
||||
className?: string;
|
||||
calcContentForCopy: () => string;
|
||||
}
|
||||
|
||||
export const CopyButton: React.FC<ICopyButtonProps> = props => {
|
||||
const { className, delay = 1500, calcContentForCopy } = props;
|
||||
const [status, setStatus] = React.useState<CopyStatus>(CopyStatus.PENDING);
|
||||
const disabled: boolean = status !== CopyStatus.PENDING;
|
||||
|
||||
const onCopy = () => {
|
||||
if (status === CopyStatus.PENDING) {
|
||||
setStatus(CopyStatus.COPYING);
|
||||
try {
|
||||
const contentForCopy: string = calcContentForCopy();
|
||||
copy(contentForCopy);
|
||||
setStatus(CopyStatus.COPIED);
|
||||
} catch (_error) {
|
||||
setStatus(CopyStatus.FAILED);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect((): (() => void) | undefined => {
|
||||
if (status === CopyStatus.COPIED || status === CopyStatus.FAILED) {
|
||||
const timer = setTimeout(() => setStatus(CopyStatus.PENDING), delay);
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [status, delay]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cx(
|
||||
classes.copyButton,
|
||||
className,
|
||||
"bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onCopy}
|
||||
>
|
||||
status === CopyStatus.PENDING ? <CopyIcon size={24} /> : <CopiedIcon size={24} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const classes = {
|
||||
copyButton: css({
|
||||
cursor: "pointer",
|
||||
}),
|
||||
};
|
59
ui/components/Markdown/renderer/inner/ImageRendererInner.tsx
Normal file
59
ui/components/Markdown/renderer/inner/ImageRendererInner.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { css } from "@emotion/css";
|
||||
import React from "react";
|
||||
|
||||
interface IProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string | undefined;
|
||||
srcSet: string | undefined;
|
||||
sizes: string | undefined;
|
||||
loading: "eager" | "lazy" | undefined;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export class ImageRendererInner extends React.Component<IProps> {
|
||||
public override shouldComponentUpdate(nextProps: IProps): boolean {
|
||||
const props = this.props;
|
||||
return (
|
||||
props.src !== nextProps.src ||
|
||||
props.alt !== nextProps.alt ||
|
||||
props.title !== nextProps.title ||
|
||||
props.srcSet !== nextProps.srcSet ||
|
||||
props.sizes !== nextProps.sizes ||
|
||||
props.loading !== nextProps.loading ||
|
||||
props.className !== nextProps.className
|
||||
);
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const { src, alt, title, srcSet, sizes, loading, className } = this.props;
|
||||
return (
|
||||
<figure className={`${className} ${cls}`}>
|
||||
<img alt={alt} src={src} title={title} srcSet={srcSet} sizes={sizes} loading={loading} />
|
||||
{title && <figcaption>{title}</figcaption>}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cls = css({
|
||||
boxSizing: "border-box",
|
||||
maxWidth: "80%", // Prevent images from overflowing the container.
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
margin: 0,
|
||||
"> img": {
|
||||
flex: "1 0 auto",
|
||||
boxSizing: "border-box",
|
||||
maxWidth: "100%",
|
||||
border: "1px solid var(--colorBorderImage)",
|
||||
boxShadow: "0 0 20px 1px rgba(126, 125, 150, 0.6)",
|
||||
},
|
||||
"> figcaption": {
|
||||
textAlign: "center",
|
||||
fontStyle: "italic",
|
||||
fontSize: "1em",
|
||||
color: "var(--colorImageTitle)",
|
||||
},
|
||||
});
|
48
ui/components/Markdown/renderer/inner/LinkRendererInner.tsx
Normal file
48
ui/components/Markdown/renderer/inner/LinkRendererInner.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { Node } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { NodesRenderer } from "../../NodesRenderer";
|
||||
|
||||
interface IProps {
|
||||
url: string;
|
||||
title: string | undefined;
|
||||
childNodes: Node[] | undefined;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export class LinkRendererInner extends React.Component<IProps> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<IProps>): boolean {
|
||||
const props = this.props;
|
||||
return (
|
||||
props.url !== nextProps.url ||
|
||||
props.title !== nextProps.title ||
|
||||
props.childNodes !== nextProps.childNodes ||
|
||||
props.className !== nextProps.className
|
||||
);
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const { url, title, childNodes, className } = this.props;
|
||||
return (
|
||||
<a className={cx(cls, className)} href={url} title={title} rel="noopener, noreferrer" target="_blank">
|
||||
<NodesRenderer nodes={childNodes} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cls = css({
|
||||
padding: "0.2rem 0",
|
||||
color: "var(--colorLink)",
|
||||
textDecoration: "none",
|
||||
"&:active": {
|
||||
color: "var(--colorLinkActive)",
|
||||
},
|
||||
"&&:hover": {
|
||||
color: "var(--colorLinkHover)",
|
||||
textDecoration: "underline",
|
||||
},
|
||||
"&:visited": {
|
||||
color: "var(--colorLinkVisited)",
|
||||
},
|
||||
});
|
23
ui/components/Markdown/renderer/link.tsx
Normal file
23
ui/components/Markdown/renderer/link.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { Link } from "@yozora/ast";
|
||||
import { astClasses, type INodeRenderer } from "../context";
|
||||
import { LinkRendererInner } from "./inner/LinkRendererInner";
|
||||
|
||||
/**
|
||||
* Render `link`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#link
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-link
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-autolink
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-autolink-extension
|
||||
*/
|
||||
export const LinkRenderer: INodeRenderer<Link> = props => {
|
||||
const { url, title, children: childNodes } = props;
|
||||
return (
|
||||
<LinkRendererInner
|
||||
url={url}
|
||||
title={title}
|
||||
childNodes={childNodes}
|
||||
className={astClasses.link}
|
||||
/>
|
||||
);
|
||||
};
|
28
ui/components/Markdown/renderer/linkReference.tsx
Normal file
28
ui/components/Markdown/renderer/linkReference.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { useStateValue } from "@guanghechen/react-viewmodel";
|
||||
import type { Definition, LinkReference } from "@yozora/ast";
|
||||
import { useNodeRendererContext, type INodeRenderer, astClasses } from "../context";
|
||||
import { LinkRendererInner } from "./inner/LinkRendererInner";
|
||||
|
||||
/**
|
||||
* Render `link-reference`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#linkReference
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-link-reference
|
||||
*/
|
||||
export const LinkReferenceRenderer: INodeRenderer<LinkReference> = props => {
|
||||
const { viewmodel } = useNodeRendererContext();
|
||||
const definitionMap: Readonly<Record<string, Definition>> = useStateValue(
|
||||
viewmodel.definitionMap$,
|
||||
);
|
||||
const definition = definitionMap[props.identifier];
|
||||
const url: string = definition?.url ?? "";
|
||||
const title: string | undefined = definition?.title;
|
||||
return (
|
||||
<LinkRendererInner
|
||||
url={url}
|
||||
title={title}
|
||||
childNodes={props.children}
|
||||
className={astClasses.linkReference}
|
||||
/>
|
||||
);
|
||||
};
|
53
ui/components/Markdown/renderer/list.tsx
Normal file
53
ui/components/Markdown/renderer/list.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { List } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
import { NodesRenderer } from "../NodesRenderer";
|
||||
|
||||
/**
|
||||
* Render `list`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#list
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-list
|
||||
*/
|
||||
export class ListRenderer extends React.Component<List> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<List>): boolean {
|
||||
const props = this.props;
|
||||
return (
|
||||
props.ordered !== nextProps.ordered ||
|
||||
props.orderType !== nextProps.orderType ||
|
||||
props.start !== nextProps.start ||
|
||||
props.children !== nextProps.children
|
||||
);
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const { ordered, orderType, start, children } = this.props;
|
||||
|
||||
if (ordered) {
|
||||
return (
|
||||
<ol className={cls} type={orderType} start={start}>
|
||||
<NodesRenderer nodes={children} />
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={cls}>
|
||||
<NodesRenderer nodes={children} />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cls = cx(
|
||||
astClasses.list,
|
||||
css({
|
||||
padding: "0px",
|
||||
margin: "0 0 1em 2em",
|
||||
lineHeight: "2",
|
||||
"> :last-child": {
|
||||
marginBottom: "0px",
|
||||
},
|
||||
}),
|
||||
);
|
39
ui/components/Markdown/renderer/listItem.tsx
Normal file
39
ui/components/Markdown/renderer/listItem.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { ListItem, Node } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
import { NodesRenderer } from "../NodesRenderer";
|
||||
|
||||
/**
|
||||
* Render `listItem`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#listitem
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-list-item
|
||||
*/
|
||||
export class ListItemRenderer extends React.Component<ListItem> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<ListItem>): boolean {
|
||||
const props = this.props;
|
||||
return props.children !== nextProps.children;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const childNodes: Node[] = this.props.children;
|
||||
return (
|
||||
<li className={cls}>
|
||||
<NodesRenderer nodes={childNodes} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cls = cx(
|
||||
astClasses.listItem,
|
||||
css({
|
||||
position: "relative",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
"> :last-child": {
|
||||
marginBottom: 0,
|
||||
},
|
||||
}),
|
||||
);
|
72
ui/components/Markdown/renderer/paragraph.tsx
Normal file
72
ui/components/Markdown/renderer/paragraph.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { Node, Paragraph } from "@yozora/ast";
|
||||
import { ImageReferenceType, ImageType } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
import { NodesRenderer } from "../NodesRenderer";
|
||||
|
||||
/**
|
||||
* Render `paragraph`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#paragraph
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-paragraph
|
||||
*/
|
||||
export class ParagraphRenderer extends React.Component<Paragraph> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<Paragraph>): boolean {
|
||||
const props = this.props;
|
||||
return props.children !== nextProps.children;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const childNodes: Node[] = this.props.children;
|
||||
|
||||
// If there are some image / imageReferences element in the paragraph,
|
||||
// then wrapper the content with div to avoid the warnings such as:
|
||||
//
|
||||
// validateDOMNesting(...): <figure> cannot appear as a descendant of <p>.
|
||||
const notValidParagraph: boolean = childNodes.some(
|
||||
child => child.type === ImageType || child.type === ImageReferenceType,
|
||||
);
|
||||
|
||||
if (notValidParagraph) {
|
||||
return (
|
||||
<div className={paragraphDisplayCls}>
|
||||
<NodesRenderer nodes={childNodes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={paragraphCls}>
|
||||
<NodesRenderer nodes={childNodes} />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const paragraphCls: string = cx(
|
||||
astClasses.paragraph,
|
||||
css({
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
margin: "0px 0px 1.25em 0px",
|
||||
marginBottom: "1em",
|
||||
lineHeight: "1.8",
|
||||
hyphens: "auto",
|
||||
wordBreak: "normal",
|
||||
overflowWrap: "anywhere",
|
||||
"> :last-child": {
|
||||
marginBottom: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const paragraphDisplayCls: string = cx(
|
||||
paragraphCls,
|
||||
css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem 0",
|
||||
margin: 0,
|
||||
}),
|
||||
);
|
34
ui/components/Markdown/renderer/strong.tsx
Normal file
34
ui/components/Markdown/renderer/strong.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { Node, Strong } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
import { NodesRenderer } from "../NodesRenderer";
|
||||
|
||||
/**
|
||||
* Render `strong`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#strong
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-emphasis
|
||||
*/
|
||||
export class StrongRenderer extends React.Component<Strong> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<Strong>): boolean {
|
||||
const props = this.props;
|
||||
return props.children !== nextProps.children;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const childNodes: Node[] = this.props.children;
|
||||
return (
|
||||
<strong className={cls}>
|
||||
<NodesRenderer nodes={childNodes} />
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cls = cx(
|
||||
astClasses.strong,
|
||||
css({
|
||||
fontWeight: 600,
|
||||
}),
|
||||
);
|
145
ui/components/Markdown/renderer/table.tsx
Normal file
145
ui/components/Markdown/renderer/table.tsx
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import { isEqual } from "@guanghechen/equal";
|
||||
import type { Table } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
import { NodesRenderer } from "../NodesRenderer";
|
||||
|
||||
/**
|
||||
* Render yozora `table`, `tableRow` and `tableCell`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#table
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#tablecell
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#tablerow
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-table
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-table-row
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-table-cell
|
||||
*/
|
||||
export class TableRenderer extends React.Component<Table> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<Table>): boolean {
|
||||
const props = this.props;
|
||||
return !isEqual(props.columns, nextProps.columns) || !isEqual(props.children, nextProps.children);
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const { columns, children: rows } = this.props;
|
||||
const aligns = columns.map(col => col.align ?? undefined);
|
||||
const [ths, ...tds] = rows.map(row =>
|
||||
row.children.map((cell, idx) => <NodesRenderer key={idx} nodes={cell.children} />),
|
||||
);
|
||||
return (
|
||||
<table className={cls}>
|
||||
<thead>
|
||||
<tr>
|
||||
{ths.map((children, idx) => (
|
||||
<Th key={idx} align={aligns[idx]}>
|
||||
{children}
|
||||
</Th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tds.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((children, idx) => (
|
||||
<td key={idx} align={aligns[idx]}>
|
||||
{children}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface IThProps {
|
||||
align: "left" | "center" | "right" | undefined;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
class Th extends React.Component<IThProps> {
|
||||
protected readonly ref: React.RefObject<HTMLTableCellElement>;
|
||||
|
||||
constructor(props: IThProps) {
|
||||
super(props);
|
||||
this.ref = { current: null };
|
||||
}
|
||||
|
||||
public override shouldComponentUpdate(nextProps: Readonly<IThProps>): boolean {
|
||||
const props = this.props;
|
||||
return props.align !== nextProps.align || props.children !== nextProps.children;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
const { align, children } = this.props;
|
||||
return (
|
||||
<th ref={this.ref} align={align}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
public override componentDidMount(): void {
|
||||
const th = this.ref.current;
|
||||
if (th) {
|
||||
th.setAttribute("title", th.innerText);
|
||||
}
|
||||
}
|
||||
|
||||
public override componentDidUpdate(): void {
|
||||
const th = this.ref.current;
|
||||
if (th) {
|
||||
th.setAttribute("title", th.innerText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cls: string = cx(
|
||||
astClasses.table,
|
||||
css({
|
||||
display: "block",
|
||||
overflow: "auto",
|
||||
width: "max-content",
|
||||
maxWidth: "100%",
|
||||
padding: 0,
|
||||
borderCollapse: "collapse",
|
||||
borderRadius: "6px",
|
||||
borderSpacing: "0px",
|
||||
border: "1px solid var(--colorBorderTable)",
|
||||
margin: "0 auto 1.25em",
|
||||
lineHeight: "1.6",
|
||||
"> thead": {
|
||||
backgroundColor: "var(--colorBgTableHead)",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
th: {
|
||||
padding: "0.5rem 1rem",
|
||||
borderLeft: "1px solid var(--colorBorderTable)",
|
||||
wordBreak: "normal",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
"&:first-child": {
|
||||
borderLeft: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
"> tbody": {
|
||||
tr: {
|
||||
borderTop: "1px solid var(--colorBorderTable)",
|
||||
backgroundColor: "var(--colorBgTableOddRow)",
|
||||
},
|
||||
"tr:nth-child(2n)": {
|
||||
backgroundColor: "var(--colorBgTableEvenRow)",
|
||||
},
|
||||
td: {
|
||||
padding: "0.5rem 1rem",
|
||||
borderLeft: "1px solid var(--colorBorderTable)",
|
||||
"&:first-child": {
|
||||
borderLeft: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
19
ui/components/Markdown/renderer/text.tsx
Normal file
19
ui/components/Markdown/renderer/text.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import type { Text } from "@yozora/ast";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Render `text`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#text
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-text
|
||||
*/
|
||||
export class TextRenderer extends React.Component<Text> {
|
||||
public override shouldComponentUpdate(nextProps: Readonly<Text>): boolean {
|
||||
const props = this.props;
|
||||
return props.value !== nextProps.value;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
return <React.Fragment>{this.props.value}</React.Fragment>;
|
||||
}
|
||||
}
|
35
ui/components/Markdown/renderer/thematicBreak.tsx
Normal file
35
ui/components/Markdown/renderer/thematicBreak.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { css, cx } from "@emotion/css";
|
||||
import type { ThematicBreak } from "@yozora/ast";
|
||||
import React from "react";
|
||||
import { astClasses } from "../context";
|
||||
|
||||
/**
|
||||
* Render `thematicBreak`.
|
||||
*
|
||||
* @see https://www.npmjs.com/package/@yozora/ast#thematicBreak
|
||||
* @see https://www.npmjs.com/package/@yozora/tokenizer-thematic-break
|
||||
*/
|
||||
export class ThematicBreakRenderer extends React.Component<ThematicBreak> {
|
||||
public override shouldComponentUpdate(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public override render(): React.ReactElement {
|
||||
return <hr className={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
const cls = cx(
|
||||
astClasses.thematicBreak,
|
||||
css({
|
||||
boxSizing: "content-box",
|
||||
display: "block",
|
||||
height: 0,
|
||||
width: "100%",
|
||||
padding: 0,
|
||||
border: 0,
|
||||
borderBottom: `1px solid #dadada`,
|
||||
outline: 0,
|
||||
margin: "1.5em 0px",
|
||||
}),
|
||||
);
|
Loading…
Add table
Add a link
Reference in a new issue