mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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:
parent
a7db41c5ba
commit
e8def8da8d
26 changed files with 1579 additions and 1228 deletions
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
221
src/components/MermaidToExcalidraw.scss
Normal file
221
src/components/MermaidToExcalidraw.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
243
src/components/MermaidToExcalidraw.tsx
Normal file
243
src/components/MermaidToExcalidraw.tsx
Normal 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;
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,8 +7,6 @@
|
|||
margin-top: 0.25rem;
|
||||
|
||||
&--mobile {
|
||||
bottom: 55px;
|
||||
top: auto;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
row-gap: 0.75rem;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -280,6 +280,11 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
|
||||
.dropdown-menu--mobile {
|
||||
bottom: 55px;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.App-mobile-menu {
|
||||
|
@ -592,6 +597,8 @@
|
|||
background-color: var(--island-bg-color);
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
@ -601,8 +608,8 @@
|
|||
}
|
||||
|
||||
.App-toolbar--mobile {
|
||||
overflow-x: auto;
|
||||
max-width: 90vw;
|
||||
overflow: visible;
|
||||
max-width: 98vw;
|
||||
|
||||
.ToolIcon__keybinding {
|
||||
display: none;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -91,7 +91,7 @@ export const redrawTextBoundingBox = (
|
|||
);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
|
||||
const nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
|
||||
import { getTextEditor } from "../tests/queries/dom";
|
||||
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
@ -26,10 +26,7 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
|||
const tab = " ";
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
};
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
describe("start text editing", () => {
|
||||
|
@ -195,7 +192,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = await getTextEditor(false);
|
||||
const editor = await getTextEditor(textEditorSelector, false);
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
|
@ -217,7 +214,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = await getTextEditor(false);
|
||||
const editor = await getTextEditor(textEditorSelector, false);
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
|
@ -258,7 +255,7 @@ describe("textWysiwyg", () => {
|
|||
textElement = UI.createElement("text");
|
||||
|
||||
mouse.clickOn(textElement);
|
||||
textarea = await getTextEditor(true);
|
||||
textarea = await getTextEditor(textEditorSelector, true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
@ -468,7 +465,7 @@ describe("textWysiwyg", () => {
|
|||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
|
||||
textarea = await getTextEditor(true);
|
||||
textarea = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(
|
||||
textarea,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
|
@ -520,7 +517,7 @@ describe("textWysiwyg", () => {
|
|||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
|
@ -548,7 +545,7 @@ describe("textWysiwyg", () => {
|
|||
]);
|
||||
expect(text.angle).toBe(rectangle.angle);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
|
@ -575,7 +572,7 @@ describe("textWysiwyg", () => {
|
|||
API.setSelectedElements([diamond]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const value = new Array(1000).fill("1").join("\n");
|
||||
|
@ -610,7 +607,7 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
let editor = await getTextEditor(true);
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
|
@ -625,7 +622,7 @@ describe("textWysiwyg", () => {
|
|||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
mouse.down();
|
||||
editor = await getTextEditor(true);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
@ -647,7 +644,7 @@ describe("textWysiwyg", () => {
|
|||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
|
@ -682,7 +679,7 @@ describe("textWysiwyg", () => {
|
|||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
@ -707,7 +704,7 @@ describe("textWysiwyg", () => {
|
|||
freedraw.y + freedraw.height / 2,
|
||||
);
|
||||
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
|
||||
|
@ -741,7 +738,7 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
|
@ -756,7 +753,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(
|
||||
editor,
|
||||
|
@ -801,7 +798,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.down();
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = await getTextEditor(true);
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
@ -814,7 +811,7 @@ describe("textWysiwyg", () => {
|
|||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
editor = await getTextEditor(true);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
|
@ -847,7 +844,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = await getTextEditor(true);
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
|
@ -868,7 +865,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = await getTextEditor(true);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
@ -897,7 +894,7 @@ describe("textWysiwyg", () => {
|
|||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
|
@ -934,7 +931,7 @@ describe("textWysiwyg", () => {
|
|||
// Bind first text
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
|
@ -955,7 +952,7 @@ describe("textWysiwyg", () => {
|
|||
it("should respect text alignment when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
let editor = await getTextEditor(true);
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
@ -972,7 +969,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = await getTextEditor(true);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
editor.select();
|
||||
|
||||
|
@ -995,7 +992,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = await getTextEditor(true);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
editor.select();
|
||||
|
||||
|
@ -1033,7 +1030,7 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
mouse.down();
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
|
@ -1048,7 +1045,7 @@ describe("textWysiwyg", () => {
|
|||
it("should scale font size correctly when resizing using shift", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
@ -1068,7 +1065,7 @@ describe("textWysiwyg", () => {
|
|||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
@ -1100,7 +1097,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
it("undo should work", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
@ -1137,7 +1134,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
it("should not allow bound text with only whitespaces", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
updateTextEditor(editor, " ");
|
||||
|
@ -1192,7 +1189,7 @@ describe("textWysiwyg", () => {
|
|||
it("should reset the container height cache when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
let editor = await getTextEditor(true);
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
@ -1204,7 +1201,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = await getTextEditor(true);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -1220,7 +1217,7 @@ describe("textWysiwyg", () => {
|
|||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
|
||||
|
@ -1245,7 +1242,7 @@ describe("textWysiwyg", () => {
|
|||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(
|
||||
|
@ -1277,12 +1274,12 @@ describe("textWysiwyg", () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = await getTextEditor(true);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = await getTextEditor(true);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
editor.select();
|
||||
});
|
||||
|
||||
|
@ -1393,7 +1390,7 @@ describe("textWysiwyg", () => {
|
|||
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = await getTextEditor(true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
updateTextEditor(
|
||||
editor,
|
||||
|
@ -1481,7 +1478,7 @@ describe("textWysiwyg", () => {
|
|||
// Bind first text
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
let editor = await getTextEditor(true);
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello!");
|
||||
expect(
|
||||
|
@ -1506,7 +1503,7 @@ describe("textWysiwyg", () => {
|
|||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
editor = await getTextEditor(true);
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Excalidraw");
|
||||
editor.blur();
|
||||
|
|
|
@ -242,7 +242,8 @@
|
|||
"embeddable": "Web Embed",
|
||||
"laser": "Laser pointer",
|
||||
"hand": "Hand (panning tool)",
|
||||
"extraTools": "More tools"
|
||||
"extraTools": "More tools",
|
||||
"mermaidToExcalidraw": "Mermaid to Excalidraw"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Canvas actions",
|
||||
|
@ -501,5 +502,12 @@
|
|||
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mermaid": {
|
||||
"title": "Mermaid to Excalidraw",
|
||||
"button": "Insert",
|
||||
"description": "Currently only <flowchartLink>Flowcharts</flowchartLink> and <sequenceLink>Sequence Diagrams</sequenceLink> are supported. The other types will be rendered as image in Excalidraw.",
|
||||
"syntax": "Mermaid Syntax",
|
||||
"preview": "Preview"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[
|
||||
{
|
||||
"path": "dist/excalidraw.production.min.js",
|
||||
"limit": "320 kB"
|
||||
"limit": "325 kB"
|
||||
},
|
||||
{
|
||||
"path": "dist/excalidraw-assets/locales",
|
||||
|
@ -11,6 +11,6 @@
|
|||
{
|
||||
"path": "dist/excalidraw-assets/vendor-*.js",
|
||||
"name": "dist/excalidraw-assets/vendor*.js",
|
||||
"limit": "30 kB"
|
||||
"limit": "900 kB"
|
||||
}
|
||||
]
|
||||
|
|
|
@ -41,6 +41,14 @@ module.exports = {
|
|||
"sass-loader",
|
||||
],
|
||||
},
|
||||
// So that type module works with webpack
|
||||
// https://github.com/webpack/webpack/issues/11467#issuecomment-691873586
|
||||
{
|
||||
test: /\.m?js/,
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude:
|
||||
|
|
|
@ -44,6 +44,14 @@ module.exports = {
|
|||
"sass-loader",
|
||||
],
|
||||
},
|
||||
// So that type module works with webpack
|
||||
// https://github.com/webpack/webpack/issues/11467#issuecomment-691873586
|
||||
{
|
||||
test: /\.m?js/,
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(ts|tsx|js|jsx|mjs)$/,
|
||||
exclude:
|
||||
|
|
167
src/tests/MermaidToExcalidraw.test.tsx
Normal file
167
src/tests/MermaidToExcalidraw.test.tsx
Normal file
|
@ -0,0 +1,167 @@
|
|||
import { act, fireEvent, render } from "./test-utils";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import React from "react";
|
||||
import { expect, vi } from "vitest";
|
||||
import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
|
||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||
|
||||
vi.mock("@excalidraw/mermaid-to-excalidraw", async (importActual) => {
|
||||
const module = (await importActual()) as any;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...module,
|
||||
};
|
||||
});
|
||||
const parseMermaidToExcalidrawSpy = vi.spyOn(
|
||||
MermaidToExcalidraw,
|
||||
"parseMermaidToExcalidraw",
|
||||
);
|
||||
|
||||
parseMermaidToExcalidrawSpy.mockImplementation(
|
||||
async (
|
||||
definition: string,
|
||||
options?: MermaidToExcalidraw.MermaidOptions | undefined,
|
||||
) => {
|
||||
const firstLine = definition.split("\n")[0];
|
||||
return new Promise((resolve, reject) => {
|
||||
if (firstLine === "flowchart TD") {
|
||||
resolve({
|
||||
elements: [
|
||||
{
|
||||
id: "Start",
|
||||
type: "rectangle",
|
||||
groupIds: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 69.703125,
|
||||
height: 44,
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
groupIds: [],
|
||||
text: "Start",
|
||||
fontSize: 20,
|
||||
},
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
id: "Stop",
|
||||
type: "rectangle",
|
||||
groupIds: [],
|
||||
x: 2.7109375,
|
||||
y: 94,
|
||||
width: 64.28125,
|
||||
height: 44,
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
groupIds: [],
|
||||
text: "Stop",
|
||||
fontSize: 20,
|
||||
},
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
id: "Start_Stop",
|
||||
type: "arrow",
|
||||
groupIds: [],
|
||||
x: 34.852,
|
||||
y: 44,
|
||||
strokeWidth: 2,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, 50],
|
||||
],
|
||||
roundness: {
|
||||
type: 2,
|
||||
},
|
||||
start: {
|
||||
id: "Start",
|
||||
},
|
||||
end: {
|
||||
id: "Stop",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
reject(new Error("ERROR"));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(React, "useRef").mockReturnValue({
|
||||
current: {
|
||||
parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy,
|
||||
},
|
||||
});
|
||||
|
||||
describe("Test <MermaidToExcalidraw/>", () => {
|
||||
beforeEach(async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: {
|
||||
openDialog: "mermaid",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
it("should open mermaid popup when active tool is mermaid", async () => {
|
||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||
|
||||
expect(dialog.outerHTML).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should close the popup and set the tool to selection when close button clicked", () => {
|
||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||
const closeBtn = dialog.querySelector(".Dialog__close")!;
|
||||
fireEvent.click(closeBtn);
|
||||
expect(document.querySelector(".dialog-mermaid")).toBe(null);
|
||||
expect(window.h.state.activeTool).toStrictEqual({
|
||||
customType: null,
|
||||
lastActiveTool: null,
|
||||
locked: false,
|
||||
type: "selection",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error in preview when mermaid library throws error", async () => {
|
||||
const dialog = document.querySelector(".dialog-mermaid")!;
|
||||
const selector = ".dialog-mermaid-panels-text textarea";
|
||||
let editor = await getTextEditor(selector, false);
|
||||
|
||||
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
|
||||
|
||||
expect(editor.textContent).toMatchInlineSnapshot(`
|
||||
"flowchart TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[Car]"
|
||||
`);
|
||||
|
||||
await act(async () => {
|
||||
updateTextEditor(editor, "flowchart TD1");
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
});
|
||||
editor = await getTextEditor(selector, false);
|
||||
|
||||
expect(editor.textContent).toBe("flowchart TD1");
|
||||
expect(dialog.querySelector('[data-testid="mermaid-error"]'))
|
||||
.toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="mermaid-error"
|
||||
data-testid="mermaid-error"
|
||||
>
|
||||
Error!
|
||||
<p>
|
||||
ERROR
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
});
|
10
src/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
Normal file
10
src/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
|
||||
"<div class=\\"Modal Dialog dialog-mermaid\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 1200px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><h2 id=\\"test-id-dialog-title\\" class=\\"Dialog__title\\"><span class=\\"Dialog__titleContent\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><span class=\\"dialog-mermaid-desc\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">Flowcharts</a> and <a href=\\"https://mermaid.js.org/syntax/sequenceDiagram.html\\">Sequence Diagrams</a> are supported. The other types will be rendered as image in Excalidraw.<br></span></span></h2><button class=\\"Dialog__close\\" title=\\"Close\\" aria-label=\\"Close\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g clip-path=\\"url(#a)\\" stroke=\\"currentColor\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><path d=\\"M15 5 5 15M5 5l10 10\\"></path></g><defs><clipPath id=\\"a\\"><path fill=\\"#fff\\" d=\\"M0 0h20v20H0z\\"></path></clipPath></defs></svg></button><div class=\\"Dialog__content\\"><div class=\\"dialog-mermaid-body\\"><div class=\\"dialog-mermaid-panels\\"><div class=\\"dialog-mermaid-panels-text\\"><label>Mermaid Syntax</label><textarea>flowchart TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[Car]</textarea></div><div class=\\"dialog-mermaid-panels-preview\\"><label>Preview</label><div class=\\"dialog-mermaid-panels-preview-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"dialog-mermaid-panels-preview-canvas-container\\"></div></div></div></div><div class=\\"dialog-mermaid-buttons\\"><button type=\\"button\\" class=\\"excalidraw-button dialog-mermaid-insert\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></button></div></div></div></div></div></div>"
|
||||
`;
|
|
@ -468,16 +468,16 @@ export class UI {
|
|||
static async editText<
|
||||
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
|
||||
>(element: T, text: string) {
|
||||
const openedEditor = document.querySelector<HTMLTextAreaElement>(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
);
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
const openedEditor =
|
||||
document.querySelector<HTMLTextAreaElement>(textEditorSelector);
|
||||
|
||||
if (!openedEditor) {
|
||||
mouse.select(element);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
}
|
||||
|
||||
const editor = await getTextEditor();
|
||||
const editor = await getTextEditor(textEditorSelector);
|
||||
if (!editor) {
|
||||
throw new Error("Can't find wysiwyg text editor in the dom");
|
||||
}
|
||||
|
|
|
@ -273,7 +273,7 @@ describe("Test Linear Elements", () => {
|
|||
|
||||
// drag line from midpoint
|
||||
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(line.points.length).toEqual(3);
|
||||
|
@ -416,7 +416,7 @@ describe("Test Linear Elements", () => {
|
|||
lastSegmentMidpoint[1] + delta,
|
||||
]);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
@ -519,7 +519,7 @@ describe("Test Linear Elements", () => {
|
|||
// delete 3rd point
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(20);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
|
||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||
|
@ -566,7 +566,7 @@ describe("Test Linear Elements", () => {
|
|||
lastSegmentMidpoint[0] + delta,
|
||||
lastSegmentMidpoint[1] + delta,
|
||||
]);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(9);
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
import { waitFor } from "@testing-library/dom";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
|
||||
export const getTextEditor = async (waitForEditor = true) => {
|
||||
const query = () =>
|
||||
document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
export const getTextEditor = async (selector: string, waitForEditor = true) => {
|
||||
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
|
||||
if (waitForEditor) {
|
||||
await waitFor(() => expect(query()).not.toBe(null));
|
||||
return query();
|
||||
}
|
||||
return query();
|
||||
};
|
||||
|
||||
export const updateTextEditor = (
|
||||
editor: HTMLTextAreaElement,
|
||||
value: string,
|
||||
) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
};
|
||||
|
|
|
@ -241,7 +241,7 @@ export type AppState = {
|
|||
openMenu: "canvas" | "shape" | null;
|
||||
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
|
||||
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
|
||||
openDialog: "imageExport" | "help" | "jsonExport" | null;
|
||||
openDialog: "imageExport" | "help" | "jsonExport" | "mermaid" | null;
|
||||
/**
|
||||
* Reflects user preference for whether the default sidebar should be docked.
|
||||
*
|
||||
|
@ -537,8 +537,12 @@ export type AppClassProperties = {
|
|||
onInsertElements: App["onInsertElements"];
|
||||
onExportImage: App["onExportImage"];
|
||||
lastViewportPosition: App["lastViewportPosition"];
|
||||
scrollToContent: App["scrollToContent"];
|
||||
addFiles: App["addFiles"];
|
||||
addElementsFromPasteOrLibrary: App["addElementsFromPasteOrLibrary"];
|
||||
togglePenMode: App["togglePenMode"];
|
||||
setActiveTool: App["setActiveTool"];
|
||||
setOpenDialog: App["setOpenDialog"];
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue