chore: implement node renderer components

This commit is contained in:
Steven 2023-12-17 10:49:49 +08:00
parent 28c0549705
commit 5d677c3c57
30 changed files with 456 additions and 26 deletions

View File

@ -46,7 +46,6 @@ enum NodeType {
ESCAPING_CHARACTER = 19;
}
// Define the Node message.
message Node {
NodeType type = 1;
oneof node {

View File

@ -1092,7 +1092,7 @@
<a name="memos-api-v2-Node"></a>
### Node
Define the Node message.
| Field | Type | Label | Description |

View File

@ -215,7 +215,6 @@ func (x *ParseMarkdownResponse) GetNodes() []*Node {
return nil
}
// Define the Node message.
type Node struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache

View File

@ -0,0 +1,18 @@
import { Node } from "@/types/proto/api/v2/markdown_service";
import Renderer from "./Renderer";
interface Props {
children: Node[];
}
const Blockquote: React.FC<Props> = ({ children }: Props) => {
return (
<blockquote>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} node={child} />
))}
</blockquote>
);
};
export default Blockquote;

View File

@ -0,0 +1,19 @@
import { Node } from "@/types/proto/api/v2/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
children: Node[];
}
const Bold: React.FC<Props> = ({ children }: Props) => {
return (
<strong>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} node={child} />
))}
</strong>
);
};
export default Bold;

View File

@ -0,0 +1,14 @@
interface Props {
symbol: string;
content: string;
}
const BoldItalic: React.FC<Props> = ({ content }: Props) => {
return (
<strong>
<em>{content}</em>
</strong>
);
};
export default BoldItalic;

View File

@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Code: React.FC<Props> = ({ content }: Props) => {
return <code>{content}</code>;
};
export default Code;

View File

@ -0,0 +1,42 @@
import classNames from "classnames";
import copy from "copy-to-clipboard";
import hljs from "highlight.js";
import toast from "react-hot-toast";
interface Props {
language: string;
content: string;
}
const CodeBlock: React.FC<Props> = ({ language, content }: Props) => {
const formatedLanguage = language.toLowerCase() || "plaintext";
let highlightedCode = hljs.highlightAuto(content).value;
try {
const temp = hljs.highlight(content, {
language: formatedLanguage,
}).value;
highlightedCode = temp;
} catch (error) {
// Skip error and use default highlighted code.
}
const handleCopyButtonClick = () => {
copy(content);
toast.success("Copied to clipboard!");
};
return (
<pre className="group w-full my-1 p-3 rounded bg-gray-100 dark:bg-zinc-600 whitespace-pre-wrap relative">
<button
className="text-xs font-mono italic absolute top-0 right-0 px-2 leading-6 border btn-text rounded opacity-0 group-hover:opacity-60"
onClick={handleCopyButtonClick}
>
copy
</button>
<code className={classNames(`language-${formatedLanguage}`, "block")} dangerouslySetInnerHTML={{ __html: highlightedCode }}></code>
</pre>
);
};
export default CodeBlock;

View File

@ -0,0 +1,9 @@
interface Props {
symbol: string;
}
const EscapingCharacter: React.FC<Props> = ({ symbol }: Props) => {
return <span>{symbol}</span>;
};
export default EscapingCharacter;

View File

@ -0,0 +1,20 @@
import { Node } from "@/types/proto/api/v2/markdown_service";
import Renderer from "./Renderer";
interface Props {
level: number;
children: Node[];
}
const Heading: React.FC<Props> = ({ level, children }: Props) => {
const Head = `h${level}` as keyof JSX.IntrinsicElements;
return (
<Head>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} node={child} />
))}
</Head>
);
};
export default Heading;

View File

@ -0,0 +1,9 @@
interface Props {
symbol: string;
}
const HorizontalRule: React.FC<Props> = () => {
return <hr />;
};
export default HorizontalRule;

View File

@ -0,0 +1,10 @@
interface Props {
altText: string;
url: string;
}
const Image: React.FC<Props> = ({ altText, url }: Props) => {
return <img alt={altText} src={url} />;
};
export default Image;

View File

@ -0,0 +1,10 @@
interface Props {
symbol: string;
content: string;
}
const Italic: React.FC<Props> = ({ content }: Props) => {
return <em>{content}</em>;
};
export default Italic;

View File

@ -0,0 +1,7 @@
interface Props {}
const LineBreak: React.FC<Props> = () => {
return <br />;
};
export default LineBreak;

View File

@ -0,0 +1,14 @@
interface Props {
text: string;
url: string;
}
const Link: React.FC<Props> = ({ text, url }: Props) => {
return (
<a className="text-blue-600 dark:text-blue-400 cursor-pointer underline break-all hover:opacity-80 decoration-1" href={url}>
{text}
</a>
);
};
export default Link;

View File

@ -0,0 +1,26 @@
import { Node } from "@/types/proto/api/v2/markdown_service";
import Renderer from "./Renderer";
interface Props {
number: string;
children: Node[];
}
const OrderedList: React.FC<Props> = ({ number, children }: Props) => {
return (
<ol>
<li className="grid grid-cols-[24px_1fr] gap-1">
<div className="w-7 h-6 flex justify-center items-center">
<span className="opacity-80">{number}.</span>
</div>
<div>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} node={child} />
))}
</div>
</li>
</ol>
);
};
export default OrderedList;

View File

@ -0,0 +1,18 @@
import { Node } from "@/types/proto/api/v2/markdown_service";
import Renderer from "./Renderer";
interface Props {
children: Node[];
}
const Paragraph: React.FC<Props> = ({ children }: Props) => {
return (
<p>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} node={child} />
))}
</p>
);
};
export default Paragraph;

View File

@ -0,0 +1,92 @@
import {
BlockquoteNode,
BoldItalicNode,
BoldNode,
CodeBlockNode,
CodeNode,
EscapingCharacterNode,
HeadingNode,
HorizontalRuleNode,
ImageNode,
ItalicNode,
LinkNode,
Node,
NodeType,
OrderedListNode,
ParagraphNode,
StrikethroughNode,
TagNode,
TaskListNode,
TextNode,
UnorderedListNode,
} from "@/types/proto/api/v2/markdown_service";
import Blockquote from "./Blockquote";
import Bold from "./Bold";
import BoldItalic from "./BoldItalic";
import Code from "./Code";
import CodeBlock from "./CodeBlock";
import EscapingCharacter from "./EscapingCharacter";
import Heading from "./Heading";
import HorizontalRule from "./HorizontalRule";
import Image from "./Image";
import Italic from "./Italic";
import LineBreak from "./LineBreak";
import Link from "./Link";
import OrderedList from "./OrderedList";
import Paragraph from "./Paragraph";
import Strikethrough from "./Strikethrough";
import Tag from "./Tag";
import TaskList from "./TaskList";
import Text from "./Text";
import UnorderedList from "./UnorderedList";
interface Props {
node: Node;
}
const Renderer: React.FC<Props> = ({ node }: Props) => {
switch (node.type) {
case NodeType.LINE_BREAK:
return <LineBreak />;
case NodeType.PARAGRAPH:
return <Paragraph {...(node.paragraphNode as ParagraphNode)} />;
case NodeType.CODE_BLOCK:
return <CodeBlock {...(node.codeBlockNode as CodeBlockNode)} />;
case NodeType.HEADING:
return <Heading {...(node.headingNode as HeadingNode)} />;
case NodeType.HORIZONTAL_RULE:
return <HorizontalRule {...(node.horizontalRuleNode as HorizontalRuleNode)} />;
case NodeType.BLOCKQUOTE:
return <Blockquote {...(node.blockquoteNode as BlockquoteNode)} />;
case NodeType.ORDERED_LIST:
return <OrderedList {...(node.orderedListNode as OrderedListNode)} />;
case NodeType.UNORDERED_LIST:
return <UnorderedList {...(node.unorderedListNode as UnorderedListNode)} />;
case NodeType.TASK_LIST:
return <TaskList {...(node.taskListNode as TaskListNode)} />;
case NodeType.TEXT:
return <Text {...(node.textNode as TextNode)} />;
case NodeType.BOLD:
return <Bold {...(node.boldNode as BoldNode)} />;
case NodeType.ITALIC:
return <Italic {...(node.italicNode as ItalicNode)} />;
case NodeType.BOLD_ITALIC:
return <BoldItalic {...(node.boldItalicNode as BoldItalicNode)} />;
case NodeType.CODE:
return <Code {...(node.codeNode as CodeNode)} />;
case NodeType.IMAGE:
return <Image {...(node.imageNode as ImageNode)} />;
case NodeType.LINK:
return <Link {...(node.linkNode as LinkNode)} />;
case NodeType.TAG:
return <Tag {...(node.tagNode as TagNode)} />;
case NodeType.STRIKETHROUGH:
return <Strikethrough {...(node.strikethroughNode as StrikethroughNode)} />;
case NodeType.ESCAPING_CHARACTER:
return <EscapingCharacter {...(node.escapingCharacterNode as EscapingCharacterNode)} />;
default:
return null;
}
};
export default Renderer;

View File

@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Strikethrough: React.FC<Props> = ({ content }: Props) => {
return <del>{content}</del>;
};
export default Strikethrough;

View File

@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Tag: React.FC<Props> = ({ content }: Props) => {
return <span className="inline-block w-auto text-blue-600 dark:text-blue-400">#{content}</span>;
};
export default Tag;

View File

@ -0,0 +1,28 @@
import { Checkbox } from "@mui/joy";
import { Node } from "@/types/proto/api/v2/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
complete: boolean;
children: Node[];
}
const TaskList: React.FC<Props> = ({ complete, children }: Props) => {
return (
<ul>
<li className="grid grid-cols-[24px_1fr] gap-1">
<div className="w-7 h-6 flex justify-center items-center">
<Checkbox size="sm" checked={complete} readOnly />
</div>
<div>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} node={child} />
))}
</div>
</li>
</ul>
);
};
export default TaskList;

View File

@ -0,0 +1,9 @@
interface Props {
content: string;
}
const Text: React.FC<Props> = ({ content }: Props) => {
return <span>{content}</span>;
};
export default Text;

View File

@ -0,0 +1,26 @@
import { Node } from "@/types/proto/api/v2/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
children: Node[];
}
const UnorderedList: React.FC<Props> = ({ children }: Props) => {
return (
<ul>
<li className="grid grid-cols-[24px_1fr] gap-1">
<div className="w-7 h-6 flex justify-center items-center">
<span className="opacity-80"></span>
</div>
<div>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} node={child} />
))}
</div>
</li>
</ul>
);
};
export default UnorderedList;

View File

@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from "react";
import { markdownServiceClient } from "@/grpcweb";
import { Node } from "@/types/proto/api/v2/markdown_service";
import Renderer from "./Renderer";
interface Props {
content: string;
className?: string;
onMemoContentClick?: (e: React.MouseEvent) => void;
}
const MemoContentV1: React.FC<Props> = (props: Props) => {
const { className, content, onMemoContentClick } = props;
const [nodes, setNodes] = useState<Node[]>([]);
const memoContentContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
markdownServiceClient
.parseMarkdown({
markdown: content,
})
.then(({ nodes }) => {
setNodes(nodes);
});
}, [content]);
const handleMemoContentClick = async (e: React.MouseEvent) => {
if (onMemoContentClick) {
onMemoContentClick(e);
}
};
return (
<div className={`w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-300 ${className || ""}`}>
<div
ref={memoContentContainerRef}
className="w-full max-w-full word-break text-base leading-6 space-y-1"
onClick={handleMemoContentClick}
>
{nodes.map((node, index) => (
<Renderer key={`${node.type}-${index}`} node={node} />
))}
</div>
</div>
);
};
export default MemoContentV1;

View File

@ -0,0 +1 @@
export interface RendererContext {}

View File

@ -0,0 +1 @@
export * from "./context";

View File

@ -1,13 +1,12 @@
import { Select, Option, Button, IconButton, Divider } from "@mui/joy";
import { isNumber, last, uniq, uniqBy } from "lodash-es";
import { isNumber, last, uniqBy } from "lodash-es";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "@/helpers/consts";
import useCurrentUser from "@/hooks/useCurrentUser";
import { getMatchedNodes } from "@/labs/marked";
import { useGlobalStore, useMemoStore, useResourceStore, useTagStore } from "@/store/module";
import { useGlobalStore, useMemoStore, useResourceStore } from "@/store/module";
import { useUserV1Store } from "@/store/v1";
import { Resource } from "@/types/proto/api/v2/resource_service";
import { UserSetting, User_Role } from "@/types/proto/api/v2/user_service";
@ -52,7 +51,6 @@ const MemoEditor = (props: Props) => {
} = useGlobalStore();
const userV1Store = useUserV1Store();
const memoStore = useMemoStore();
const tagStore = useTagStore();
const resourceStore = useResourceStore();
const currentUser = useCurrentUser();
const [state, setState] = useState<State>({
@ -335,13 +333,6 @@ const MemoEditor = (props: Props) => {
};
});
// Upsert tag with the content.
const matchedNodes = getMatchedNodes(content);
const tagNameList = uniq(matchedNodes.filter((node) => node.parserName === "tag").map((node) => node.matchedContent.slice(1)));
for (const tagName of tagNameList) {
await tagStore.upsertTag(tagName);
}
setState((prevState) => ({
...prevState,
resourceList: [],

View File

@ -2,6 +2,7 @@ import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-we
import { ActivityServiceDefinition } from "./types/proto/api/v2/activity_service";
import { AuthServiceDefinition } from "./types/proto/api/v2/auth_service";
import { InboxServiceDefinition } from "./types/proto/api/v2/inbox_service";
import { MarkdownServiceDefinition } from "./types/proto/api/v2/markdown_service";
import { MemoServiceDefinition } from "./types/proto/api/v2/memo_service";
import { ResourceServiceDefinition } from "./types/proto/api/v2/resource_service";
import { SystemServiceDefinition } from "./types/proto/api/v2/system_service";
@ -35,3 +36,5 @@ export const inboxServiceClient = clientFactory.create(InboxServiceDefinition, c
export const activityServiceClient = clientFactory.create(ActivityServiceDefinition, channel);
export const webhookServiceClient = clientFactory.create(WebhookServiceDefinition, channel);
export const markdownServiceClient = clientFactory.create(MarkdownServiceDefinition, channel);

View File

@ -69,16 +69,6 @@
code {
@apply block;
}
&:hover {
.codeblock-copy-btn {
@apply flex;
}
}
.codeblock-copy-btn {
@apply btn-normal absolute hidden top-2 right-2 border-solid border-2;
}
}
code {

View File

@ -6,7 +6,7 @@ import { Link, useParams } from "react-router-dom";
import FloatingNavButton from "@/components/FloatingNavButton";
import Icon from "@/components/Icon";
import Memo from "@/components/Memo";
import MemoContent from "@/components/MemoContent";
import MemoContentV1 from "@/components/MemoContentV1";
import MemoEditor from "@/components/MemoEditor";
import showMemoEditorDialog from "@/components/MemoEditor/MemoEditorDialog";
import MemoRelationListView from "@/components/MemoRelationListView";
@ -133,7 +133,7 @@ const MemoDetail = () => {
<div className="w-full mb-4 flex flex-row justify-start items-center mr-1">
<span className="text-gray-400 select-none">{getDateTimeString(memo.displayTs)}</span>
</div>
<MemoContent content={memo.content} />
<MemoContentV1 content={memo.content} />
<MemoResourceListView resourceList={memo.resourceList} />
<MemoRelationListView memo={memo} relationList={referenceRelations} />
<div className="w-full mt-4 flex flex-col sm:flex-row justify-start sm:justify-between sm:items-center gap-2">