feat: Add mermaidjs integration (#3679)

* feat: Add mermaidjs integration (#3523)

* Add mermaidjs to dependencies and CodeFenceNode

* Fix diagram id for mermaidjs diagrams

* Fix typescript compiler errors on mermaid integration

* Fix id generation for mermaid diagrams

* Refactor mermaidjs integration into prosemirror plugin

* Remove unnecessary class attribute in mermaidjs integration

* Change mermaidjs label to singular

* Change decorator.inline to decorator.node for mermaid diagram id

* Fix diagram toggle state

* Add border and background to mermaid diagrams

* Stop mermaidjs from overwriting fontFamily inside diagrams

* Add stable diagramId to mermaid diagrams

* Separate text for hide/show diagram
Use uuid as diagramId, avoid storing in state
Fix cursor on diagrams

* fix: Base diagram visibility off presence of source

* fix: More cases where our font-family is ignored

* Disable HTML labels

* fix: Button styling – not technically required but now we have a third button this felt all the more needed

closes #3116

* named chunks

* Upgrade mermaid 9.1.3

Co-authored-by: Jan Niklas Richter <5812215+ArcticXWolf@users.noreply.github.com>
This commit is contained in:
Tom Moor
2022-06-29 08:44:36 +03:00
committed by GitHub
parent e24a5adbd5
commit 9a6e09bafa
9 changed files with 972 additions and 280 deletions

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-irregular-whitespace */
import { lighten, transparentize } from "polished";
import { darken, lighten, transparentize } from "polished";
import styled from "styled-components";
const EditorStyles = styled.div<{
@@ -773,20 +773,45 @@ const EditorStyles = styled.div<{
select,
button {
background: ${(props) => props.theme.background};
color: ${(props) => props.theme.text};
border-width: 1px;
font-size: 13px;
display: none;
margin: 0;
padding: 0;
border: 0;
background: ${(props) => props.theme.buttonNeutralBackground};
color: ${(props) => props.theme.buttonNeutralText};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${(props) =>
props.theme.buttonNeutralBorder} 0 0 0 1px inset;
border-radius: 4px;
padding: 2px 4px;
height: 18px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
flex-shrink: 0;
cursor: pointer;
user-select: none;
appearance: none !important;
padding: 6px 8px;
display: none;
&::-moz-focus-inner {
padding: 0;
border: 0;
}
&:hover:not(:disabled) {
background-color: ${(props) =>
darken(0.05, props.theme.buttonNeutralBackground)};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${(props) =>
props.theme.buttonNeutralBorder} 0 0 0 1px inset;
}
}
button {
padding: 2px 4px;
select {
background-image: url('data:image/svg+xml;utf8,<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" d="M9.03087 9C8.20119 9 7.73238 9.95209 8.23824 10.6097L11.2074 14.4696C11.6077 14.99 12.3923 14.99 12.7926 14.4696L15.7618 10.6097C16.2676 9.95209 15.7988 9 14.9691 9L9.03087 9Z" fill="currentColor"/> </svg>');
background-repeat: no-repeat;
background-position: center right;
padding-right: 20px;
}
&:focus-within,
&:hover {
select {
display: ${(props) => (props.readOnly ? "none" : "inline")};
@@ -803,6 +828,49 @@ const EditorStyles = styled.div<{
button:active {
display: inline;
}
button.show-source-button {
display: none;
}
button.show-diagram-button {
display: inline;
}
&.code-hidden {
button,
select,
button.show-diagram-button {
display: none;
}
button.show-source-button {
display: inline;
}
pre {
display: none;
}
}
}
.mermaid-diagram-wrapper {
display: flex;
align-items: center;
justify-content: center;
background: ${(props) => props.theme.codeBackground};
border-radius: 6px;
border: 1px solid ${(props) => props.theme.codeBorder};
padding: 8px;
user-select: none;
cursor: default;
* {
font-family: ${(props) => props.theme.fontFamily};
}
&.diagram-hidden {
display: none;
}
}
pre {

View File

@@ -18,6 +18,7 @@ export default function useDictionary() {
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
copy: t("Copy"),
createLink: t("Create link"),
createLinkError: t("Sorry, an error occurred creating the link"),
createNewDoc: t("Create a new doc"),
@@ -69,6 +70,8 @@ export default function useDictionary() {
table: t("Table"),
tip: t("Tip"),
tipNotice: t("Tip notice"),
showDiagram: t("Show diagram"),
showSource: t("Show source"),
warning: t("Warning"),
warningNotice: t("Warning notice"),
insertDate: t("Current date"),

View File

@@ -8,7 +8,10 @@ export async function loadPolyfills() {
if (!supportsResizeObserver()) {
polyfills.push(
import("@juggle/resize-observer").then((module) => {
import(
/* webpackChunkName: "resize-observer" */
"@juggle/resize-observer"
).then((module) => {
window.ResizeObserver = module.ResizeObserver;
})
);

View File

@@ -67,6 +67,7 @@
"@theo.gravity/datadog-apm": "2.1.0",
"@tippy.js/react": "^2.2.2",
"@tommoor/remove-markdown": "^0.3.2",
"@types/mermaid": "^8.2.9",
"autotrack": "^2.4.1",
"aws-sdk": "^2.1044.0",
"babel-plugin-lodash": "^3.3.4",
@@ -126,6 +127,7 @@
"markdown-it": "^12.3.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
"mermaid": "9.1.3",
"mime-types": "^2.1.35",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
@@ -328,6 +330,7 @@
"yarn-deduplicate": "^3.1.0"
},
"resolutions": {
"d3": "^7.0.0",
"node-fetch": "^2.6.7",
"socket.io-parser": "^3.4.0",
"prosemirror-transform": "1.2.5",

View File

@@ -38,6 +38,7 @@ import { Dictionary } from "~/hooks/useDictionary";
import toggleBlockType from "../commands/toggleBlockType";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Mermaid from "../plugins/Mermaid";
import Prism, { LANGUAGES } from "../plugins/Prism";
import isInCode from "../queries/isInCode";
import { Dispatch } from "../types";
@@ -114,7 +115,7 @@ export default class CodeFence extends Node {
],
toDOM: (node) => {
const button = document.createElement("button");
button.innerText = "Copy";
button.innerText = this.options.dictionary.copy;
button.type = "button";
button.addEventListener("click", this.handleCopyToClipboard);
@@ -135,9 +136,30 @@ export default class CodeFence extends Node {
select.appendChild(option);
});
// For the Mermaid language we add an extra button to toggle between
// source code and a rendered diagram view.
if (node.attrs.language === "mermaidjs") {
const showSourceButton = document.createElement("button");
showSourceButton.innerText = this.options.dictionary.showSource;
showSourceButton.type = "button";
showSourceButton.classList.add("show-source-button");
showSourceButton.addEventListener("click", this.handleToggleDiagram);
actions.prepend(showSourceButton);
const showDiagramButton = document.createElement("button");
showDiagramButton.innerText = this.options.dictionary.showDiagram;
showDiagramButton.type = "button";
showDiagramButton.classList.add("show-digram-button");
showDiagramButton.addEventListener("click", this.handleToggleDiagram);
actions.prepend(showDiagramButton);
}
return [
"div",
{ class: "code-block", "data-language": node.attrs.language },
{
class: "code-block",
"data-language": node.attrs.language,
},
["div", { contentEditable: "false" }, actions],
["pre", ["code", { spellCheck: "false" }, 0]],
];
@@ -222,20 +244,46 @@ export default class CodeFence extends Node {
if (result) {
const language = element.value;
const transaction = tr
.setSelection(Selection.near(view.state.doc.resolve(result.inside)))
.setNodeMarkup(result.inside, undefined, {
language,
});
view.dispatch(transaction);
localStorage?.setItem(PERSISTENCE_KEY, language);
}
};
handleToggleDiagram = (event: InputEvent) => {
const { view } = this.editor;
const { tr } = view.state;
const element = event.currentTarget;
if (!(element instanceof HTMLButtonElement)) {
return;
}
const { top, left } = element.getBoundingClientRect();
const result = view.posAtCoords({ top, left });
if (!result) {
return;
}
const diagramId = element
.closest(".code-block")
?.getAttribute("data-diagram-id");
if (!diagramId) {
return;
}
const transaction = tr.setMeta("mermaid", { toggleDiagram: diagramId });
view.dispatch(transaction);
};
get plugins() {
return [Prism({ name: this.name })];
return [Prism({ name: this.name }), Mermaid({ name: this.name })];
}
inputRules({ type }: { type: NodeType }) {

View File

@@ -0,0 +1,190 @@
import { Node } from "prosemirror-model";
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
import { findBlockNodes } from "prosemirror-utils";
import { Decoration, DecorationSet } from "prosemirror-view";
import { v4 as uuidv4 } from "uuid";
type MermaidState = {
decorationSet: DecorationSet;
diagramVisibility: Record<number, boolean>;
};
function getNewState({
doc,
name,
pluginState,
}: {
doc: Node;
name: string;
pluginState: MermaidState;
}) {
const decorations: Decoration[] = [];
// Find all blocks that represent Mermaid diagrams
const blocks: { node: Node; pos: number }[] = findBlockNodes(doc).filter(
(item) =>
item.node.type.name === name && item.node.attrs.language === "mermaidjs"
);
blocks.forEach((block) => {
const diagramDecorationPos = block.pos + block.node.nodeSize;
const existingDecorations = pluginState.decorationSet.find(
block.pos,
diagramDecorationPos
);
// Attempt to find the existing diagramId from the decoration, or assign
// a new one if none exists yet.
let diagramId = existingDecorations[0]?.spec["diagramId"];
if (diagramId === undefined) {
diagramId = uuidv4();
}
// Make the diagram visible by default if it contains source code
if (pluginState.diagramVisibility[diagramId] === undefined) {
pluginState.diagramVisibility[diagramId] = !!block.node.textContent;
}
const diagramDecoration = Decoration.widget(
block.pos + block.node.nodeSize,
() => {
const diagramWrapper = document.createElement("div");
diagramWrapper.classList.add("mermaid-diagram-wrapper");
if (pluginState.diagramVisibility[diagramId] === false) {
diagramWrapper.classList.add("diagram-hidden");
}
import(
/* webpackChunkName: "mermaid" */
"mermaid"
).then((module) => {
module.default.initialize({
startOnLoad: true,
flowchart: {
htmlLabels: false,
},
themeVariables: {
fontFamily: "inherit",
},
});
try {
module.default.render(
"mermaid-diagram-" + diagramId,
block.node.textContent,
(svgCode) => {
diagramWrapper.innerHTML = svgCode;
}
);
} catch (error) {
console.log(error);
const errorNode = document.getElementById(
"d" + "mermaid-diagram-" + diagramId
);
if (errorNode) {
diagramWrapper.appendChild(errorNode);
}
}
});
return diagramWrapper;
},
{
diagramId,
}
);
const attributes = { "data-diagram-id": "" + diagramId };
if (pluginState.diagramVisibility[diagramId] !== false) {
attributes["class"] = "code-hidden";
}
const diagramIdDecoration = Decoration.node(
block.pos,
block.pos + block.node.nodeSize,
attributes,
{
diagramId,
}
);
decorations.push(diagramDecoration);
decorations.push(diagramIdDecoration);
});
return {
decorationSet: DecorationSet.create(doc, decorations),
diagramVisibility: pluginState.diagramVisibility,
};
}
export default function Mermaid({ name }: { name: string }) {
let diagramShown = false;
return new Plugin({
key: new PluginKey("mermaid"),
state: {
init: (_: Plugin, { doc }) => {
const pluginState: MermaidState = {
decorationSet: DecorationSet.create(doc, []),
diagramVisibility: {},
};
return pluginState;
},
apply: (
transaction: Transaction,
pluginState: MermaidState,
oldState,
state
) => {
const nodeName = state.selection.$head.parent.type.name;
const previousNodeName = oldState.selection.$head.parent.type.name;
const codeBlockChanged =
transaction.docChanged && [nodeName, previousNodeName].includes(name);
const ySyncEdit = !!transaction.getMeta("y-sync$");
const mermaidMeta = transaction.getMeta("mermaid");
const diagramToggled = mermaidMeta?.toggleDiagram !== undefined;
if (diagramToggled) {
pluginState.diagramVisibility[
mermaidMeta.toggleDiagram
] = !pluginState.diagramVisibility[mermaidMeta.toggleDiagram];
}
if (!diagramShown || codeBlockChanged || diagramToggled || ySyncEdit) {
diagramShown = true;
return getNewState({
doc: transaction.doc,
name,
pluginState,
});
}
return {
decorationSet: pluginState.decorationSet.map(
transaction.mapping,
transaction.doc
),
diagramVisibility: pluginState.diagramVisibility,
};
},
},
view: (view) => {
if (!diagramShown) {
// we don't draw diagrams on code blocks on the first render as part of mounting
// as it's expensive (relative to the rest of the document). Instead let
// it render without a diagram and then trigger a defered render of Mermaid
// by updating the plugins metadata
setTimeout(() => {
view.dispatch(view.state.tr.setMeta("mermaid", { loaded: true }));
}, 10);
}
return {};
},
props: {
decorations(state) {
return this.getState(state).decorationSet;
},
},
});
}

View File

@@ -17,6 +17,7 @@ export const LANGUAGES = {
java: "Java",
javascript: "JavaScript",
json: "JSON",
mermaidjs: "Mermaid Diagram",
perl: "Perl",
php: "PHP",
powershell: "Powershell",

View File

@@ -199,6 +199,7 @@
"Code block": "Code block",
"Copied to clipboard": "Copied to clipboard",
"Code": "Code",
"Copy": "Copy",
"Create link": "Create link",
"Sorry, an error occurred creating the link": "Sorry, an error occurred creating the link",
"Create a new doc": "Create a new doc",
@@ -246,6 +247,8 @@
"Table": "Table",
"Tip": "Tip",
"Tip notice": "Tip notice",
"Show diagram": "Show diagram",
"Show source": "Show source",
"Warning": "Warning",
"Warning notice": "Warning notice",
"Current date": "Current date",
@@ -577,7 +580,6 @@
"API token copied to clipboard": "API token copied to clipboard",
"Revoke token": "Revoke token",
"Copied": "Copied",
"Copy": "Copy",
"Revoke": "Revoke",
"Revoking": "Revoking",
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",

902
yarn.lock

File diff suppressed because it is too large Load Diff