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:
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
190
shared/editor/plugins/Mermaid.ts
Normal file
190
shared/editor/plugins/Mermaid.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const LANGUAGES = {
|
||||
java: "Java",
|
||||
javascript: "JavaScript",
|
||||
json: "JSON",
|
||||
mermaidjs: "Mermaid Diagram",
|
||||
perl: "Perl",
|
||||
php: "PHP",
|
||||
powershell: "Powershell",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
Reference in New Issue
Block a user