feat: text-to-diagram (#7325)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Barnabás Molnár 2023-11-25 22:11:03 +01:00 committed by GitHub
parent dd8a7d41e2
commit 14845a343b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1381 additions and 510 deletions

View File

@ -13,6 +13,8 @@ VITE_APP_PORTAL_URL=
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# put these in your .env.local, or make sure you don't commit!

View File

@ -9,6 +9,8 @@ VITE_APP_PORTAL_URL=https://portal.excalidraw.com
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
VITE_APP_WS_SERVER_URL=

View File

@ -25,6 +25,8 @@ import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
} from "../src/packages/excalidraw/index";
import {
AppState,
@ -773,6 +775,64 @@ const ExcalidrawWrapper = () => {
)}
</OverwriteConfirmDialog>
<AppFooter />
<TTDDialog
onTextSubmit={async (input) => {
try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
<TTDDialogTrigger />
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}

View File

@ -40,6 +40,7 @@ import {
MagicIcon,
} from "./icons";
import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
export const SelectedShapeActions = ({
appState,
@ -235,6 +236,8 @@ export const ShapesSwitcher = ({
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels();
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@ -338,14 +341,14 @@ export const ShapesSwitcher = ({
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
<DropdownMenu.Item
onSelect={() => app.setOpenDialog({ name: "mermaid" })}
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && (
<>
<DropdownMenu.Item
@ -354,10 +357,11 @@ export const ShapesSwitcher = ({
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "d2c-settings", "settings");
trackEvent("ai", "open-settings", "d2c");
app.setOpenDialog({
name: "magicSettings",
source: "settings",

View File

@ -381,7 +381,6 @@ 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 {
@ -1435,9 +1434,6 @@ class App extends React.Component<AppProps, AppState> {
onMagicSettingsConfirm={this.onMagicSettingsConfirm}
>
{this.props.children}
{this.state.openDialog?.name === "mermaid" && (
<MermaidToExcalidraw />
)}
</LayerUI>
<div className="excalidraw-textEditorContainer" />
@ -1706,7 +1702,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
openDialog: { name: "magicSettings", source: "generation" },
});
trackEvent("ai", "d2c-generate", "missing-key");
trackEvent("ai", "generate (missing key)", "d2c");
return;
}
@ -1719,7 +1715,7 @@ class App extends React.Component<AppProps, AppState> {
if (!magicFrameChildren.length) {
if (source === "button") {
this.setState({ errorMessage: "Cannot generate from an empty frame" });
trackEvent("ai", "d2c-generate", "no-children");
trackEvent("ai", "generate (no-children)", "d2c");
} else {
this.setActiveTool({ type: "magicframe" });
}
@ -1761,7 +1757,7 @@ class App extends React.Component<AppProps, AppState> {
const textFromFrameChildren = this.getTextFromElements(magicFrameChildren);
trackEvent("ai", "d2c-generate", "generating");
trackEvent("ai", "generate (start)", "d2c");
const result = await diagramToHTML({
image: dataURL,
@ -1771,7 +1767,7 @@ class App extends React.Component<AppProps, AppState> {
});
if (!result.ok) {
trackEvent("ai", "d2c-generate", "generating-failed");
trackEvent("ai", "generate (failed)", "d2c");
console.error(result.error);
this.updateMagicGeneration({
frameElement,
@ -1783,7 +1779,7 @@ class App extends React.Component<AppProps, AppState> {
});
return;
}
trackEvent("ai", "d2c-generate", "generating-done");
trackEvent("ai", "generate (success)", "d2c");
if (result.choices[0].message.content == null) {
this.updateMagicGeneration({
@ -1877,7 +1873,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
openDialog: { name: "magicSettings", source: "tool" },
});
trackEvent("ai", "d2c-tool", "missing-key");
trackEvent("ai", "tool-select (missing key)", "d2c");
return;
}
@ -1887,7 +1883,7 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 0) {
this.setActiveTool({ type: TOOL_TYPE.magicframe });
trackEvent("ai", "d2c-tool", "empty-selection");
trackEvent("ai", "tool-select (empty-selection)", "d2c");
} else {
const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
selectedElements.length === 1 &&
@ -1905,7 +1901,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
trackEvent("ai", "d2c-tool", "existing-selection");
trackEvent("ai", "tool-select (existing selection)", "d2c");
let frame: ExcalidrawMagicFrameElement;
if (selectedMagicFrame) {

View File

@ -2,7 +2,11 @@ import clsx from "clsx";
import { composeEventHandlers } from "../utils";
import "./Button.scss";
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
interface ButtonProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
type?: "button" | "submit" | "reset";
onSelect: () => any;
/** whether button is in active state */

View File

@ -62,6 +62,7 @@ import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog";
interface LayerUIProps {
actionManager: ActionManager;
@ -396,6 +397,7 @@ const LayerUI = ({
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}

View File

@ -1,221 +0,0 @@
@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

@ -1,243 +0,0 @@
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

@ -0,0 +1,10 @@
.excalidraw {
.dialog-mermaid {
&-title {
margin-block: 0.25rem;
font-size: 1.25rem;
font-weight: 700;
padding-inline: 2.5rem;
}
}
}

View File

@ -0,0 +1,133 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../../types";
import { useApp } from "../App";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { ArrowRightIcon } from "../icons";
import "./MermaidToExcalidraw.scss";
import { t } from "../../i18n";
import Trans from "../Trans";
import {
LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW,
MermaidToExcalidrawLibProps,
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { TTDDialogPanels } from "./TTDDialogPanels";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
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 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 MermaidToExcalidraw = ({
mermaidToExcalidrawLib,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
}) => {
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState<Error | null>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
convertMermaidToExcalidraw({
canvasRef,
data,
mermaidToExcalidrawLib,
setError,
text: deferredText,
}).catch(() => {});
}, [deferredText, mermaidToExcalidrawLib]);
const textRef = useRef(text);
// slightly hacky but really quite simple
// essentially, we want to save the text to LS when the component unmounts
useEffect(() => {
textRef.current = text;
}, [text]);
useEffect(() => {
return () => {
if (textRef.current) {
saveMermaidDataToStorage(textRef.current);
}
};
}, []);
return (
<>
<div className="ttd-dialog-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>
)}
/>
</div>
<TTDDialogPanels>
<TTDDialogPanel label={t("mermaid.syntax")}>
<TTDDialogInput
input={text}
placeholder={"Write Mermaid diagram defintion here..."}
onChange={(event) => setText(event.target.value)}
/>
</TTDDialogPanel>
<TTDDialogPanel
label={t("mermaid.preview")}
panelAction={{
action: () => {
insertToEditor({
app,
data,
text,
shouldSaveMermaidDataToStorage: true,
});
},
label: t("mermaid.button"),
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={canvasRef}
loaded={mermaidToExcalidrawLib.loaded}
error={error}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
export default MermaidToExcalidraw;

View File

@ -0,0 +1,301 @@
@import "../../css/variables.module";
$verticalBreakpoint: 861px;
.excalidraw {
.Modal.Dialog.ttd-dialog {
padding: 1.25rem;
&.Dialog--fullscreen {
margin-top: 0;
}
.Island {
padding-inline: 0 !important;
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
box-shadow: none;
}
.Modal__content {
height: auto;
max-height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
max-height: 750px;
height: 100%;
}
}
.Dialog__content {
flex: 1 1 auto;
}
}
.ttd-dialog-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
margin-bottom: 1.5rem;
}
.ttd-dialog-tabs-root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.ttd-dialog-tab-trigger {
color: var(--color-on-surface);
font-size: 0.875rem;
margin: 0;
padding: 0 1rem;
background-color: transparent;
border: 0;
height: 2.875rem;
font-weight: 600;
font-family: inherit;
letter-spacing: 0.4px;
&[data-state="active"] {
border-bottom: 2px solid var(--color-primary);
}
}
.ttd-dialog-triggers {
border-bottom: 1px solid var(--color-surface-high);
margin-bottom: 1.5rem;
padding-inline: 2.5rem;
}
.ttd-dialog-content {
padding-inline: 2.5rem;
height: 100%;
display: flex;
flex-direction: column;
&[hidden] {
display: none;
}
}
.ttd-dialog-input {
width: auto;
height: 10rem;
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;
font-family: monospace;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
height: 100%;
}
}
.ttd-dialog-output-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
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);
height: 400px;
width: auto;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
// acts as min-height
height: 200px;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
.ttd-dialog-output-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
.ttd-dialog-output-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;
}
}
.ttd-dialog-panels {
height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
}
}
.ttd-dialog-panel {
display: flex;
flex-direction: column;
width: 100%;
&__header {
display: flex;
margin: 0px 4px 4px 4px;
align-items: center;
gap: 1rem;
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
}
}
&:first-child {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 4rem;
}
}
@media screen and (min-width: $verticalBreakpoint) {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 0.5rem !important;
}
}
textarea {
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;
}
}
}
.ttd-dialog-panel-button-container {
margin-top: 1rem;
margin-bottom: 0.5rem;
&.invisible {
.ttd-dialog-panel-button {
display: none;
@media screen and (min-width: $verticalBreakpoint) {
display: block;
visibility: hidden;
}
}
}
}
.ttd-dialog-panel-button {
&.excalidraw-button {
font-family: inherit;
font-weight: 600;
height: 2.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
width: 100%;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background-color: var(--color-primary);
}
}
@media screen and (min-width: $verticalBreakpoint) {
width: auto;
min-width: 7.5rem;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
position: relative;
div {
display: contents;
&.invisible {
visibility: hidden;
}
&.Spinner {
display: flex !important;
position: absolute;
inset: 0;
--spinner-color: white;
@at-root .excalidraw.theme--dark#{&} {
--spinner-color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}

View File

@ -0,0 +1,325 @@
import { Dialog } from "../Dialog";
import { useApp } from "../App";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import TTDDialogTabs from "./TTDDialogTabs";
import { ChangeEventHandler, useEffect, useRef, useState } from "react";
import { useUIAppState } from "../../context/ui-appState";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
import { TTDDialogTab } from "./TTDDialogTab";
import { t } from "../../i18n";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels";
import {
MermaidToExcalidrawLibProps,
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss";
import { isFiniteNumber } from "../../utils";
import { atom, useAtom } from "jotai";
import { trackEvent } from "../../analytics";
const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 1000;
const rateLimitsAtom = atom<{
rateLimit: number;
rateLimitRemaining: number;
} | null>(null);
type OnTestSubmitRetValue = {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
} & (
| { generatedResponse: string | undefined; error?: null | undefined }
| {
error: Error;
generatedResponse?: null | undefined;
}
);
export const TTDDialog = (
props:
| {
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
}
| { __fallback: true },
) => {
const appState = useUIAppState();
if (appState.openDialog?.name !== "ttd") {
return null;
}
return <TTDDialogBase {...props} tab={appState.openDialog.tab} />;
};
/**
* Text to diagram (TTD) dialog
*/
export const TTDDialogBase = withInternalFallback(
"TTDDialogBase",
({
tab,
...rest
}: {
tab: string;
} & (
| {
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
}
| { __fallback: true }
)) => {
const app = useApp();
const someRandomDivRef = useRef<HTMLDivElement>(null);
const [text, setText] = useState("");
const prompt = text.trim();
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (
event,
) => {
setText(event.target.value);
};
const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
const onGenerate = async () => {
if (
prompt.length > MAX_PROMPT_LENGTH ||
prompt.length < MIN_PROMPT_LENGTH ||
onTextSubmitInProgess ||
rateLimits?.rateLimitRemaining === 0 ||
// means this is not a text-to-diagram dialog (needed for TS only)
"__fallback" in rest
) {
if (prompt.length < MIN_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too short (min ${MIN_PROMPT_LENGTH} characters)`,
),
);
}
if (prompt.length > MAX_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too long (max ${MAX_PROMPT_LENGTH} characters)`,
),
);
}
return;
}
try {
setOnTextSubmitInProgess(true);
trackEvent("ai", "generate", "ttd");
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
await rest.onTextSubmit(prompt);
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
setRateLimits({ rateLimit, rateLimitRemaining });
}
if (error) {
setError(error);
return;
}
if (!generatedResponse) {
setError(new Error("Generation failed"));
return;
}
try {
await convertMermaidToExcalidraw({
canvasRef: someRandomDivRef,
data,
mermaidToExcalidrawLib,
setError,
text: generatedResponse,
});
trackEvent("ai", "mermaid parse success", "ttd");
saveMermaidDataToStorage(generatedResponse);
} catch (error: any) {
trackEvent("ai", "mermaid parse failed", "ttd");
setError(
new Error(
"Generated an invalid diagram :(. You may also try a different prompt.",
),
);
}
} catch (error: any) {
let message: string | undefined = error.message;
if (!message || message === "Failed to fetch") {
message = "Request failed";
}
setError(new Error(message));
} finally {
setOnTextSubmitInProgess(false);
}
};
const refOnGenerate = useRef(onGenerate);
refOnGenerate.current = onGenerate;
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
useState<MermaidToExcalidrawLibProps>({
loaded: false,
api: import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
),
});
useEffect(() => {
const fn = async () => {
await mermaidToExcalidrawLib.api;
setMermaidToExcalidrawLib((prev) => ({ ...prev, loaded: true }));
};
fn();
}, [mermaidToExcalidrawLib.api]);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const [error, setError] = useState<Error | null>(null);
return (
<Dialog
className="ttd-dialog"
onCloseRequest={() => {
app.setOpenDialog(null);
}}
size={1200}
title=""
{...rest}
autofocus={false}
>
<TTDDialogTabs tab={tab}>
{"__fallback" in rest && rest.__fallback ? (
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
) : (
<TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
{t("labels.textToDiagram")}
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="mermaid">Mermaid</TTDDialogTabTrigger>
</TTDDialogTabTriggers>
)}
<TTDDialogTab className="ttd-dialog-content" tab="mermaid">
<MermaidToExcalidraw
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
/>
</TTDDialogTab>
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
<div className="ttd-dialog-desc">
Currently we use Mermaid as a middle step, so you'll get best
results if you describe a diagram, workflow, flow chart, and
similar.
</div>
<TTDDialogPanels>
<TTDDialogPanel
label={t("labels.prompt")}
panelAction={{
action: onGenerate,
label: "Generate",
icon: ArrowRightIcon,
}}
onTextSubmitInProgess={onTextSubmitInProgess}
panelActionDisabled={
prompt.length > MAX_PROMPT_LENGTH ||
rateLimits?.rateLimitRemaining === 0
}
renderTopRight={() => {
if (!rateLimits) {
return null;
}
return (
<div
className="ttd-dialog-rate-limit"
style={{
fontSize: 12,
marginLeft: "auto",
color:
rateLimits.rateLimitRemaining === 0
? "var(--color-danger)"
: undefined,
}}
>
{rateLimits.rateLimitRemaining} requests left today
</div>
);
}}
renderBottomRight={() => {
const ratio = prompt.length / MAX_PROMPT_LENGTH;
if (ratio > 0.8) {
return (
<div
style={{
marginLeft: "auto",
fontSize: 12,
fontFamily: "monospace",
color:
ratio > 1 ? "var(--color-danger)" : undefined,
}}
>
Length: {prompt.length}/{MAX_PROMPT_LENGTH}
</div>
);
}
return null;
}}
>
<TTDDialogInput
onChange={handleTextChange}
input={text}
placeholder={"Describe what you want to see..."}
onKeyboardSubmit={() => {
refOnGenerate.current();
}}
/>
</TTDDialogPanel>
<TTDDialogPanel
label="Preview"
panelAction={{
action: () => {
console.info("Panel action clicked");
insertToEditor({ app, data });
},
label: "Insert",
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={someRandomDivRef}
error={error}
loaded={mermaidToExcalidrawLib.loaded}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</TTDDialogTab>
)}
</TTDDialogTabs>
</Dialog>
);
},
);

View File

@ -0,0 +1,52 @@
import { ChangeEventHandler, useEffect, useRef } from "react";
import { EVENT } from "../../constants";
import { KEYS } from "../../keys";
interface TTDDialogInputProps {
input: string;
placeholder: string;
onChange: ChangeEventHandler<HTMLTextAreaElement>;
onKeyboardSubmit?: () => void;
}
export const TTDDialogInput = ({
input,
placeholder,
onChange,
onKeyboardSubmit,
}: TTDDialogInputProps) => {
const ref = useRef<HTMLTextAreaElement>(null);
const callbackRef = useRef(onKeyboardSubmit);
callbackRef.current = onKeyboardSubmit;
useEffect(() => {
if (!callbackRef.current) {
return;
}
const textarea = ref.current;
if (textarea) {
const handleKeyDown = (event: KeyboardEvent) => {
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.ENTER) {
event.preventDefault();
callbackRef.current?.();
}
};
textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}
}, []);
return (
<textarea
className="ttd-dialog-input"
onChange={onChange}
value={input}
placeholder={placeholder}
autoFocus
ref={ref}
/>
);
};

View File

@ -0,0 +1,39 @@
import Spinner from "../Spinner";
const ErrorComp = ({ error }: { error: string }) => {
return (
<div
data-testid="ttd-dialog-output-error"
className="ttd-dialog-output-error"
>
Error! <p>{error}</p>
</div>
);
};
interface TTDDialogOutputProps {
error: Error | null;
canvasRef: React.RefObject<HTMLDivElement>;
loaded: boolean;
}
export const TTDDialogOutput = ({
error,
canvasRef,
loaded,
}: TTDDialogOutputProps) => {
return (
<div className="ttd-dialog-output-wrapper">
{error && <ErrorComp error={error.message} />}
{loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="ttd-dialog-output-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
);
};

View File

@ -0,0 +1,58 @@
import { ReactNode } from "react";
import { Button } from "../Button";
import clsx from "clsx";
import Spinner from "../Spinner";
interface TTDDialogPanelProps {
label: string;
children: ReactNode;
panelAction?: {
label: string;
action: () => void;
icon?: ReactNode;
};
panelActionDisabled?: boolean;
onTextSubmitInProgess?: boolean;
renderTopRight?: () => ReactNode;
renderBottomRight?: () => ReactNode;
}
export const TTDDialogPanel = ({
label,
children,
panelAction,
panelActionDisabled = false,
onTextSubmitInProgess,
renderTopRight,
renderBottomRight,
}: TTDDialogPanelProps) => {
return (
<div className="ttd-dialog-panel">
<div className="ttd-dialog-panel__header">
<label>{label}</label>
{renderTopRight?.()}
</div>
{children}
<div
className={clsx("ttd-dialog-panel-button-container", {
invisible: !panelAction,
})}
style={{ display: "flex", alignItems: "center" }}
>
<Button
className="ttd-dialog-panel-button"
onSelect={panelAction ? panelAction.action : () => {}}
disabled={panelActionDisabled || onTextSubmitInProgess}
>
<div className={clsx({ invisible: onTextSubmitInProgess })}>
{panelAction?.label}
{panelAction?.icon && <span>{panelAction.icon}</span>}
</div>
{onTextSubmitInProgess && <Spinner />}
</Button>
{renderBottomRight?.()}
</div>
</div>
);
};

View File

@ -0,0 +1,5 @@
import { ReactNode } from "react";
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
return <div className="ttd-dialog-panels">{children}</div>;
};

View File

@ -0,0 +1,17 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTab = ({
tab,
children,
...rest
}: {
tab: string;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.Content {...rest} value={tab}>
{children}
</RadixTabs.Content>
);
};
TTDDialogTab.displayName = "TTDDialogTab";

View File

@ -0,0 +1,21 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTabTrigger = ({
children,
tab,
onSelect,
...rest
}: {
children: React.ReactNode;
tab: string;
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
return (
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
<button type="button" className="ttd-dialog-tab-trigger" {...rest}>
{children}
</button>
</RadixTabs.Trigger>
);
};
TTDDialogTabTrigger.displayName = "TTDDialogTabTrigger";

View File

@ -0,0 +1,13 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.List className="ttd-dialog-triggers" {...rest}>
{children}
</RadixTabs.List>
);
};
TTDDialogTabTriggers.displayName = "TTDDialogTabTriggers";

View File

@ -0,0 +1,38 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { ReactNode } from "react";
import { useExcalidrawSetAppState } from "../App";
const TTDDialogTabs = ({
children,
tab,
...rest
}: {
children: ReactNode;
tab: string;
}) => {
const setAppState = useExcalidrawSetAppState();
return (
<RadixTabs.Root
className="ttd-dialog-tabs-root"
value={tab}
onValueChange={(
// at least in test enviros, `tab` can be `undefined`
tab: string | undefined,
) => {
if (tab) {
setAppState({
openDialog: { name: "ttd", tab },
});
}
}}
{...rest}
>
{children}
</RadixTabs.Root>
);
};
TTDDialogTabs.displayName = "TTDDialogTabs";
export default TTDDialogTabs;

View File

@ -0,0 +1,34 @@
import { ReactNode } from "react";
import { useTunnels } from "../../context/tunnels";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import { useExcalidrawSetAppState } from "../App";
import { brainIcon } from "../icons";
import { t } from "../../i18n";
import { trackEvent } from "../../analytics";
export const TTDDialogTrigger = ({
children,
icon,
}: {
children?: ReactNode;
icon?: JSX.Element;
}) => {
const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState();
return (
<TTDDialogTriggerTunnel.In>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "dialog open", "ttd");
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
}}
icon={icon ?? brainIcon}
>
{children ?? t("labels.textToDiagram")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</TTDDialogTriggerTunnel.In>
);
};
TTDDialogTrigger.displayName = "TTDDialogTrigger";

View File

@ -0,0 +1,153 @@
import { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { AppClassProperties, BinaryFiles } from "../../types";
import { canvasToBlob } from "../../data/blob";
const resetPreview = ({
canvasRef,
setError,
}: {
canvasRef: React.RefObject<HTMLDivElement>;
setError: (error: Error | null) => void;
}) => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
export interface MermaidToExcalidrawLibProps {
loaded: boolean;
api: Promise<{
parseMermaidToExcalidraw: (
definition: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
}>;
}
interface ConvertMermaidToExcalidrawFormatProps {
canvasRef: React.RefObject<HTMLDivElement>;
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
text: string;
setError: (error: Error | null) => void;
data: React.MutableRefObject<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
}
export const convertMermaidToExcalidraw = async ({
canvasRef,
mermaidToExcalidrawLib,
text,
setError,
data,
}: ConvertMermaidToExcalidrawFormatProps) => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (!canvasNode || !parent) {
return;
}
if (!text) {
resetPreview({ canvasRef, setError });
return;
}
try {
const api = await mermaidToExcalidrawLib.api;
const { elements, files } = await api.parseMermaidToExcalidraw(text, {
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 (err: any) {
console.error(err);
parent.style.background = "var(--default-bg-color)";
if (text) {
setError(err);
}
throw err;
}
};
export const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
export 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);
}
};
export const insertToEditor = ({
app,
data,
text,
shouldSaveMermaidDataToStorage,
}: {
app: AppClassProperties;
data: React.MutableRefObject<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
text?: string;
shouldSaveMermaidDataToStorage?: boolean;
}) => {
const { elements: newElements, files } = data.current;
if (!newElements.length) {
return;
}
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
app.setOpenDialog(null);
if (shouldSaveMermaidDataToStorage && text) {
saveMermaidDataToStorage(text);
}
};

View File

@ -63,9 +63,13 @@
}
&__text {
display: flex;
align-items: center;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
gap: 0.75rem;
}
&__shortcut {

View File

@ -37,6 +37,32 @@ const DropdownMenuItem = ({
</button>
);
};
DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropDownMenuItemBadge = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div
style={{
display: "inline-flex",
marginLeft: "auto",
padding: "1px 4px",
background: "pink",
borderRadius: 6,
fontSize: 11,
color: "black",
fontFamily: "monospace",
}}
>
{children}
</div>
);
};
DropDownMenuItemBadge.displayName = "DropdownMenuItemBadge";
DropdownMenuItem.Badge = DropDownMenuItemBadge;
export default DropdownMenuItem;
DropdownMenuItem.displayName = "DropdownMenuItem";

View File

@ -1742,3 +1742,16 @@ export const eyeClosedIcon = createIcon(
</g>,
tablerIconProps,
);
export const brainIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" />
<path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" />
<path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" />
<path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" />
<path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" />
<path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" />
</g>,
tablerIconProps,
);

View File

@ -13,6 +13,7 @@ type TunnelsContextValue = {
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
TTDDialogTriggerTunnel: Tunnel;
jotaiScope: symbol;
};
@ -32,6 +33,7 @@ export const useInitializeTunnels = () => {
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
TTDDialogTriggerTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);

View File

@ -39,6 +39,7 @@
button {
cursor: pointer;
user-select: none;
}
&:focus {

View File

@ -132,7 +132,9 @@
"sidebarLock": "Keep sidebar open",
"selectAllElementsInFrame": "Select all elements in frame",
"removeAllElementsFromFrame": "Remove all elements from frame",
"eyeDropper": "Pick color from canvas"
"eyeDropper": "Pick color from canvas",
"textToDiagram": "Text to diagram",
"prompt": "Prompt"
},
"library": {
"noItems": "No items added yet...",

View File

@ -76,6 +76,8 @@ const {
MainMenu,
LiveCollaborationTrigger,
convertToExcalidrawElements,
TTDDialog,
TTDDialogTrigger,
} = window.ExcalidrawLib;
const COMMENT_ICON_DIMENSION = 32;
@ -681,7 +683,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
}
initialData={initialStatePromiseRef.current.promise}
onChange={(elements, state) => {
console.info("Elements :", elements, "State : ", state);
// console.info("Elements :", elements, "State : ", state);
}}
onPointerUpdate={(payload: {
pointer: { x: number; y: number };
@ -737,6 +739,20 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
Toggle Custom Sidebar
</Sidebar.Trigger>
{renderMenu()}
{excalidrawAPI && (
<TTDDialogTrigger icon={<span>😀</span>}>
Text to diagram
</TTDDialogTrigger>
)}
<TTDDialog
onTextSubmit={async (_) => {
console.info("submit");
// sleep for 2s
await new Promise((resolve) => setTimeout(resolve, 2000));
throw new Error("error, go away now");
// return "dummy";
}}
/>
</Excalidraw>
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
{comment && renderComment()}

View File

@ -246,6 +246,8 @@ export { WelcomeScreen };
export { LiveCollaborationTrigger };
export { DefaultSidebar } from "../../components/DefaultSidebar";
export { TTDDialog } from "../../components/TTDDialog/TTDDialog";
export { TTDDialogTrigger } from "../../components/TTDDialog/TTDDialogTrigger";
export { normalizeLink } from "../../data/url";
export { convertToExcalidrawElements } from "../../data/transform";

View File

@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
<Excalidraw
initialData={{
appState: {
openDialog: { name: "mermaid" },
openDialog: { name: "ttd", tab: "mermaid" },
},
}}
/>,
@ -110,16 +110,16 @@ describe("Test <MermaidToExcalidraw/>", () => {
});
it("should open mermaid popup when active tool is mermaid", async () => {
const dialog = document.querySelector(".dialog-mermaid")!;
const dialog = document.querySelector(".ttd-dialog")!;
await waitFor(() => dialog.querySelector("canvas"));
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 dialog = document.querySelector(".ttd-dialog")!;
const closeBtn = dialog.querySelector(".Dialog__close")!;
fireEvent.click(closeBtn);
expect(document.querySelector(".dialog-mermaid")).toBe(null);
expect(document.querySelector(".ttd-dialog")).toBe(null);
expect(window.h.state.activeTool).toStrictEqual({
customType: null,
lastActiveTool: null,
@ -129,9 +129,12 @@ describe("Test <MermaidToExcalidraw/>", () => {
});
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);
const dialog = document.querySelector(".ttd-dialog")!;
expect(dialog).not.toBeNull();
const selector = ".ttd-dialog-input";
let editor = await getTextEditor(selector, true);
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
@ -151,17 +154,8 @@ describe("Test <MermaidToExcalidraw/>", () => {
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>
`);
expect(
dialog.querySelector('[data-testid="mermaid-error"]'),
).toMatchInlineSnapshot("null");
});
});

View File

@ -1,10 +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
"<div class=\\"Modal Dialog ttd-dialog\\" 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\\"><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 dir=\\"ltr\\" data-orientation=\\"horizontal\\" class=\\"ttd-dialog-tabs-root\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><div data-state=\\"active\\" data-orientation=\\"horizontal\\" role=\\"tabpanel\\" aria-labelledby=\\"radix-:r0:-trigger-mermaid\\" id=\\"radix-:r0:-content-mermaid\\" tabindex=\\"0\\" class=\\"ttd-dialog-content\\" style=\\"animation-duration: 0s;\\"><div class=\\"ttd-dialog-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.</div><div class=\\"ttd-dialog-panels\\"><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Mermaid Syntax</label></div><textarea class=\\"ttd-dialog-input\\" placeholder=\\"Write Mermaid diagram defintion here...\\">flowchart TD
A[Christmas] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|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\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></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>"
C --&gt;|Three| F[Car]</textarea><div class=\\"ttd-dialog-panel-button-container invisible\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\"></div></button></div></div><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Preview</label></div><div class=\\"ttd-dialog-output-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"ttd-dialog-output-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div><div class=\\"ttd-dialog-panel-button-container\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\">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></div></button></div></div></div></div></div></div></div></div></div>"
`;

View File

@ -273,7 +273,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(13);
expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(line.points.length).toEqual(3);
@ -416,7 +416,7 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[1] + delta,
]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
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(20);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
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(19);
expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(line.points.length).toEqual(5);

View File

@ -246,14 +246,15 @@ export interface AppState {
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog:
| null
| { name: "imageExport" | "help" | "jsonExport" | "mermaid" }
| { name: "imageExport" | "help" | "jsonExport" }
| {
name: "magicSettings";
source:
| "tool" // when magicframe tool is selected
| "generation" // when magicframe generate button is clicked
| "settings"; // when AI settings dialog is explicitly invoked
};
}
| { name: "ttd"; tab: string };
/**
* Reflects user preference for whether the default sidebar should be docked.
*

View File

@ -925,3 +925,7 @@ export const isMemberOf = <T extends string>(
};
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
export const isFiniteNumber = (value: any): value is number => {
return typeof value === "number" && Number.isFinite(value);
};

1
src/vite-env.d.ts vendored
View File

@ -17,6 +17,7 @@ interface ImportMetaEnv {
// set this only if using the collaboration workflow we use on excalidraw.com
VITE_APP_PORTAL_URL: string;
VITE_APP_AI_BACKEND: string;
VITE_APP_FIREBASE_CONFIG: string;