feat: Support mermaid flowchart and sequence diagrams to excalidraw diagrams 🥳 (#6920)

* feat: integrate mermaidToExcalidraw

* create mermaid to excal dialog

* allow mermaid syntax and export in preview

* fix

* fix webpack config

* fix markdown error by using named export

* center preview

* set elements as selected when inserted onto canvas

* persist mermaid data to storage

* store canvas data in refs

* load mermaid lazily

* tweak design

* compute width, height correctly for arrows

* fix undefined vertex issue

* add mermaid icon in dropdown

* add a note in dialog

* reset preview when error

* show error in preview when error

* show mermaid error messgae react way

* design tweaks

* add example and docs link

* fix

* tweak design to remove scroll bar

* show a spinner unless mermaid loaded

* regenerate ids when needed via programmatic api, this makes sure for mermaid diagrams ids are regenerated

* tweak

* add option to transform viewport to scene coords in transform api

* make opts optional and use 100% zoom when inserting to canvas

* fix arrow bindings in safari and firefox

* fix elements insert position and viewport centering

* fix: Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.

* defer rendering the preview

* tweak text

* fix tests

* remove only

* make design responsive

* fix: show extra tools dropdown in mobile

* fix mobile css

* width auto

* upgrade mermaid-to-excalidraw

* don't pass appState in deps as its not used

* upgrade mermaid-to-excalidraw to fix firefox issue

* use types from mermaid-to-excalidraw

* upgrade mermaid-to-excalidraw

* use stable version of mermaid-to-excalidraw

* upgrade mermaid-to-excalidraw

* fix width of shapes toolbar for smaller screen size and also fix regression of mobile menu

* use i18n

* better api

* enable test coverage in ui

* Add tests

* use common utils to update and get text editor

* updgrade mermaid-to-excalidraw to support sequence diagrams

* fix test

* don't update arrow container height anytime in when redrawing text bounding box

* increase size limit

* increase size limit of vendor to 900kb

* use openDialog for mermaid

* upgrade mermaid-to-excalidraw

* update frame id post generation

* upgrade mermaid-to-excalidraw to add entity codes support

* update size limit

* upgrade mermaid-to-excalidraw package with frame api changes

* upgrade mermaid-to-excalidraw to remove directive and use config

* don't highlight mermaid tool and remove unused api setSelection

* stop using loading state to update text area

* move some styling to scss

* review fixes

* use modifiedTableIcon props and remove stale snap

* css

* dialog css

* fix snap

* use dialog border

* change mermaidToExcalidrawLib to state

* better styling of errors

* make modal bigger

* fix mobile

* update snaps

* fix icon color

* fix dark mode insert button color

* horizontally center spinner

* render canvas conditionally on loaded state

* rd tweaks

* tweak class names

* remove max height

* typo in example

* upgrade mermaid-to-excalidraw

* simplify error state

* fix height & overflow on vertical breakpoint

* fix lint

* show errors in overlay

* set textarea font family

* reduce opacity

* update snap

* upgrade to mermaid  0.1.2

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2023-11-03 17:41:34 +05:30 committed by GitHub
parent a7db41c5ba
commit e8def8da8d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1579 additions and 1228 deletions

View file

@ -34,6 +34,7 @@ import {
EmbedIcon,
extraToolsIcon,
frameToolIcon,
mermaidLogoIcon,
laserPointerToolIcon,
} from "./icons";
import { KEYS } from "../keys";
@ -223,7 +224,6 @@ export const ShapesSwitcher = ({
app: AppClassProperties;
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
@ -273,111 +273,63 @@ export const ShapesSwitcher = ({
);
})}
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className={clsx("App-toolbar__extra-tools-trigger", {
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
(laserToolSelected && !app.props.isCollaborating),
})}
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })}
icon={frameToolIcon}
checked={activeTool.type === "frame"}
name="editor-current-shape"
title={`${capitalizeString(
t("toolBar.frame"),
)} ${KEYS.F.toLocaleUpperCase()}`}
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui");
app.setActiveTool({ type: "frame" });
}}
selected={activeTool.type === "frame"}
/>
<ToolButton
className={clsx("Shape", { fillable: false })}
type="radio"
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "embeddable" })}
icon={EmbedIcon}
checked={activeTool.type === "embeddable"}
name="editor-current-shape"
title={capitalizeString(t("toolBar.embeddable"))}
aria-label={capitalizeString(t("toolBar.embeddable"))}
data-testid={`toolbar-embeddable`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
}}
onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui");
app.setActiveTool({ type: "embeddable" });
}}
selected={activeTool.type === "embeddable"}
/>
</>
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className={clsx("App-toolbar__extra-tools-trigger", {
"App-toolbar__extra-tools-trigger--selected":
frameToolSelected ||
embeddableToolSelected ||
// in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button
// on top of it
(laserToolSelected && !app.props.isCollaborating),
})}
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "laser" })}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "frame" });
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
selected={frameToolSelected}
>
{t("toolBar.frame")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "embeddable" });
}}
icon={EmbedIcon}
data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
>
{t("toolBar.embeddable")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
app.setActiveTool({ type: "laser" });
}}
icon={laserPointerToolIcon}
data-testid="toolbar-laser"
selected={laserToolSelected}
shortcut={KEYS.K.toLocaleUpperCase()}
>
{t("toolBar.laser")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("mermaid")}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</>
);
};

View file

@ -366,6 +366,7 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import { LaserToolOverlay } from "./LaserTool/LaserTool";
import { LaserPathManager } from "./LaserTool/LaserPathManager";
import {
@ -1245,7 +1246,11 @@ class App extends React.Component<AppProps, AppState> {
isCollaborating={this.props.isCollaborating}
>
{this.props.children}
{this.state.openDialog === "mermaid" && (
<MermaidToExcalidraw />
)}
</LayerUI>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
@ -2324,11 +2329,12 @@ class App extends React.Component<AppProps, AppState> {
},
);
private addElementsFromPasteOrLibrary = (opts: {
addElementsFromPasteOrLibrary = (opts: {
elements: readonly ExcalidrawElement[];
files: BinaryFiles | null;
position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
fitToContent?: boolean;
}) => {
const elements = restoreElements(opts.elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@ -2433,6 +2439,12 @@ class App extends React.Component<AppProps, AppState> {
},
);
this.setActiveTool({ type: "selection" });
if (opts.fitToContent) {
this.scrollToContent(newElements, {
fitToContent: true,
});
}
};
// TODO rewrite this to paste both text & images at the same time if
@ -3308,6 +3320,10 @@ class App extends React.Component<AppProps, AppState> {
});
};
setOpenDialog = (dialogType: AppState["openDialog"]) => {
this.setState({ openDialog: dialogType });
};
private setCursor = (cursor: string) => {
setCursor(this.interactiveCanvas, cursor);
};
@ -4258,6 +4274,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x,
scenePointer.y,
);
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElement,

View file

@ -0,0 +1,221 @@
@import "../css/variables.module";
$verticalBreakpoint: 860px;
.excalidraw {
.dialog-mermaid {
&-title {
margin-bottom: 5px;
margin-top: 2px;
}
&-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
}
.Modal__content .Island {
box-shadow: none;
}
@at-root .excalidraw:not(.excalidraw--mobile)#{&} {
padding: 1.25rem;
.Modal__content {
height: 100%;
max-height: 750px;
@media screen and (max-width: $verticalBreakpoint) {
height: auto;
// When vertical, we want the height to span whole viewport.
// This is also important for the children not to overflow the
// modal/viewport (for some reason).
max-height: 100%;
}
.Island {
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
.Dialog__content {
display: flex;
flex: 1 1 auto;
}
}
}
}
}
.dialog-mermaid-body {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
height: 100%;
column-gap: 4rem;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
}
.dialog-mermaid-panels {
display: grid;
width: 100%;
grid-template-columns: 1fr 1fr;
justify-content: space-between;
gap: 4rem;
grid-row: 1;
grid-column: 1 / 3;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
margin-bottom: 4px;
margin-left: 4px;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 4px;
}
}
&-text {
display: flex;
flex-direction: column;
textarea {
width: 20rem;
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
&-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
// acts as min-height
height: 200px;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
@media screen and (max-width: $verticalBreakpoint) {
// acts as min-height
height: 400px;
width: auto;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
&-preview-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
&-preview {
display: flex;
flex-direction: column;
}
.mermaid-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
}
.dialog-mermaid-buttons {
grid-column: 2;
.dialog-mermaid-insert {
&.excalidraw-button {
font-family: "Assistant";
font-weight: 600;
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
width: 7.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}

View file

@ -0,0 +1,243 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../types";
import { useApp } from "./App";
import { Button } from "./Button";
import { Dialog } from "./Dialog";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../element/types";
import { canvasToBlob } from "../data/blob";
import { ArrowRightIcon } from "./icons";
import Spinner from "./Spinner";
import "./MermaidToExcalidraw.scss";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { t } from "../i18n";
import Trans from "./Trans";
const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const saveMermaidDataToStorage = (data: string) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importMermaidDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
if (data) {
return data;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const ErrorComp = ({ error }: { error: string }) => {
return (
<div data-testid="mermaid-error" className="mermaid-error">
Error! <p>{error}</p>
</div>
);
};
const MermaidToExcalidraw = () => {
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
loaded: boolean;
api: {
parseMermaidToExcalidraw: (
defination: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
} | null;
}>({ loaded: false, api: null });
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
const resetPreview = () => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
useEffect(() => {
const loadMermaidToExcalidrawLib = async () => {
const api = await import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
);
setMermaidToExcalidrawLib({ loaded: true, api });
};
loadMermaidToExcalidrawLib();
}, []);
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
const renderExcalidrawPreview = async () => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (
!mermaidToExcalidrawLib.loaded ||
!canvasNode ||
!parent ||
!mermaidToExcalidrawLib.api
) {
return;
}
if (!deferredText) {
resetPreview();
return;
}
try {
const { elements, files } =
await mermaidToExcalidrawLib.api.parseMermaidToExcalidraw(
deferredText,
{
fontSize: DEFAULT_FONT_SIZE,
},
);
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (e: any) {
parent.style.background = "var(--default-bg-color)";
if (deferredText) {
setError(e.message);
}
}
};
renderExcalidrawPreview();
}, [deferredText, mermaidToExcalidrawLib]);
const onClose = () => {
app.setOpenDialog(null);
saveMermaidDataToStorage(text);
};
const onSelect = () => {
const { elements: newElements, files } = data.current;
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
onClose();
};
return (
<Dialog
className="dialog-mermaid"
onCloseRequest={onClose}
size={1200}
title={
<>
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
<span className="dialog-mermaid-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
/>
<br />
</span>
</>
}
>
<div className="dialog-mermaid-body">
<div className="dialog-mermaid-panels">
<div className="dialog-mermaid-panels-text">
<label>{t("mermaid.syntax")}</label>
<textarea
onChange={(event) => setText(event.target.value)}
value={text}
/>
</div>
<div className="dialog-mermaid-panels-preview">
<label>{t("mermaid.preview")}</label>
<div className="dialog-mermaid-panels-preview-wrapper">
{error && <ErrorComp error={error} />}
{mermaidToExcalidrawLib.loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="dialog-mermaid-panels-preview-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
</div>
</div>
<div className="dialog-mermaid-buttons">
<Button className="dialog-mermaid-insert" onSelect={onSelect}>
{t("mermaid.button")}
<span>{ArrowRightIcon}</span>
</Button>
</div>
</div>
</Dialog>
);
};
export default MermaidToExcalidraw;

View file

@ -160,6 +160,15 @@
width: var(--lg-button-size);
height: var(--lg-button-size);
@media screen and (max-width: 450px) {
width: 1.8rem;
height: 1.8rem;
}
@media screen and (max-width: 379px) {
width: 1.5rem;
height: 1.5rem;
}
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);

View file

@ -16,6 +16,10 @@
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.25rem;
@include isMobile {
margin: 0;
}
}
}
@ -41,5 +45,6 @@
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
z-index: 1;
}
}

View file

@ -7,8 +7,6 @@
margin-top: 0.25rem;
&--mobile {
bottom: 55px;
top: auto;
left: 0;
width: 100%;
row-gap: 0.75rem;

View file

@ -1654,6 +1654,22 @@ export const frameToolIcon = createIcon(
tablerIconProps,
);
export const mermaidLogoIcon = createIcon(
<path
fill="currentColor"
d="M407.48,111.18C335.587,108.103 269.573,152.338 245.08,220C220.587,152.338 154.573,108.103 82.68,111.18C80.285,168.229 107.577,222.632 154.74,254.82C178.908,271.419 193.35,298.951 193.27,328.27L193.27,379.13L296.9,379.13L296.9,328.27C296.816,298.953 311.255,271.42 335.42,254.82C382.596,222.644 409.892,168.233 407.48,111.18Z"
/>,
);
export const ArrowRightIcon = createIcon(
<g strokeWidth="1.25">
<path d="M4.16602 10H15.8327" />
<path d="M12.5 13.3333L15.8333 10" />
<path d="M12.5 6.66666L15.8333 9.99999" />
</g>,
modifiedTablerIconProps,
);
export const laserPointerToolIcon = createIcon(
<g
fill="none"