feat: mermaid diagram integration (#202)

This commit is contained in:
Philip Okugbe
2024-08-24 18:30:07 +01:00
committed by GitHub
parent 17475bf123
commit 7e80797e3f
10 changed files with 733 additions and 17 deletions

View File

@ -33,6 +33,7 @@
"jwt-decode": "^4.0.0",
"katex": "^0.16.10",
"lowlight": "^3.1.0",
"mermaid": "^11.0.1",
"react": "^18.3.1",
"react-arborist": "^3.4.0",
"react-dom": "^18.3.1",

View File

@ -1,17 +1,34 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
import { useState } from "react";
import classes from "./code-block.module.css";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks";
import MermaidView from "@/features/editor/components/code-block/mermaid-view.tsx";
import classes from "./code-block.module.css";
export default function CodeBlockView(props: NodeViewProps) {
const { node, updateAttributes, extension, editor, selected } = props;
const { node, updateAttributes, extension, editor, getPos } = props;
const { language } = node.attrs;
const [languageValue, setLanguageValue] = useState<string | null>(
language || null,
);
const { hovered, ref } = useHover();
const [isSelected, setIsSelected] = useState(false);
useEffect(() => {
const updateSelection = () => {
const { state } = editor;
const { from, to } = state.selection;
// Check if the selection intersects with the node's range
const isNodeSelected =
(from >= getPos() && from < getPos() + node.nodeSize) ||
(to > getPos() && to <= getPos() + node.nodeSize);
setIsSelected(isNodeSelected);
};
editor.on("selectionUpdate", updateSelection);
return () => {
editor.off("selectionUpdate", updateSelection);
};
}, [editor, getPos(), node.nodeSize]);
function changeLanguage(language: string) {
setLanguageValue(language);
@ -21,10 +38,10 @@ export default function CodeBlockView(props: NodeViewProps) {
}
return (
<NodeViewWrapper className="codeBlock" ref={ref}>
<Group justify="flex-end">
<NodeViewWrapper className="codeBlock">
<Group justify="flex-end" contentEditable={false}>
<Select
placeholder="Auto"
placeholder="auto"
checkIconPosition="right"
data={extension.options.lowlight.listLanguages()}
value={languageValue}
@ -54,9 +71,18 @@ export default function CodeBlockView(props: NodeViewProps) {
</CopyButton>
</Group>
<pre spellCheck="false">
<pre
spellCheck="false"
hidden={
((language === "mermaid" && !editor.isEditable) ||
(language === "mermaid" && !isSelected)) &&
node.textContent.length > 0
}
>
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
{language === "mermaid" && <MermaidView props={props} />}
</NodeViewWrapper>
);
}

View File

@ -2,3 +2,17 @@
height: 25px;
min-height: 25px;
}
.error {
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
display: flex;
align-items: center;
justify-content: center;
}
.mermaid {
display: flex;
align-items: center;
justify-content: center;
}

View File

@ -0,0 +1,50 @@
import { NodeViewProps } from "@tiptap/react";
import { useEffect, useState } from "react";
import mermaid from "mermaid";
import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css";
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true,
});
interface MermaidViewProps {
props: NodeViewProps;
}
export default function MermaidView({ props }: MermaidViewProps) {
const { node } = props;
const [preview, setPreview] = useState<string>("");
useEffect(() => {
const id = `mermaid-${uuidv4()}`;
if (node.textContent.length > 0) {
mermaid
.render(id, node.textContent)
.then((item) => {
setPreview(item.svg);
})
.catch((err) => {
if (props.editor.isEditable) {
setPreview(
`<div class="${classes.error}">Mermaid diagram error: ${err}</div>`,
);
} else {
setPreview(
`<div class="${classes.error}">Invalid Mermaid Diagram</div>`,
);
}
});
}
}, [node.textContent]);
return (
<div
className={classes.mermaid}
contentEditable={false}
dangerouslySetInnerHTML={{ __html: preview }}
></div>
);
}

View File

@ -7,6 +7,7 @@ import {
IconH2,
IconH3,
IconInfoCircle,
IconLetterY,
IconList,
IconListNumbers,
IconMath,
@ -253,6 +254,19 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setMathBlock().run(),
},
{
title: "Mermaid diagram",
description: "Insert mermaid diagram",
searchTerms: ["mermaid", "diagram", "chart"],
icon: IconLetterY,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.setCodeBlock({ language: "mermaid" })
.run(),
},
],
};

View File

@ -12,7 +12,6 @@ import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import Table from "@tiptap/extension-table";
import TableHeader from "@tiptap/extension-table-header";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
@ -32,6 +31,7 @@ import {
TiptapVideo,
LinkExtension,
Selection,
CustomCodeBlock,
} from "@docmost/editor-ext";
import {
randomElement,
@ -46,7 +46,6 @@ import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import { common, createLowlight } from "lowlight";
import VideoView from "@/features/editor/components/video/video-view.tsx";
import { ReactNodeViewRenderer } from "@tiptap/react";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext";
@ -139,11 +138,8 @@ export const mainExtensions = [
Callout.configure({
view: CalloutView,
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({
CustomCodeBlock.configure({
view: CodeBlockView,
lowlight,
HTMLAttributes: {
spellcheck: false,

View File

@ -15,6 +15,7 @@ import TableHeader from '@tiptap/extension-table-header';
import {
Callout,
Comment,
CustomCodeBlock,
Details,
DetailsContent,
DetailsSummary,
@ -35,7 +36,9 @@ import { generateHTML } from '../common/helpers/prosemirror/html';
import { generateJSON } from '@tiptap/html';
export const tiptapExtensions = [
StarterKit,
StarterKit.configure({
codeBlock: false
}),
Comment,
TextAlign,
TaskList,
@ -62,6 +65,7 @@ export const tiptapExtensions = [
TiptapImage,
TiptapVideo,
Callout,
CustomCodeBlock
] as any;
export function jsonToHtml(tiptapJson: any) {