summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/TTDDialog
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/TTDDialog')
-rw-r--r--packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.scss10
-rw-r--r--packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx132
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialog.scss315
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialog.tsx394
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx53
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogOutput.tsx39
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogPanel.tsx63
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogPanels.tsx5
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx14
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogTab.tsx17
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogTabTrigger.tsx21
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogTabTriggers.tsx13
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx55
-rw-r--r--packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx35
-rw-r--r--packages/excalidraw/components/TTDDialog/common.ts161
15 files changed, 1327 insertions, 0 deletions
diff --git a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.scss b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.scss
new file mode 100644
index 0000000..2a9a40b
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.scss
@@ -0,0 +1,10 @@
+.excalidraw {
+ .dialog-mermaid {
+ &-title {
+ margin-block: 0.25rem;
+ font-size: 1.25rem;
+ font-weight: 700;
+ padding-inline: 2.5rem;
+ }
+ }
+}
diff --git a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx
new file mode 100644
index 0000000..83fb91d
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx
@@ -0,0 +1,132 @@
+import { useState, useRef, useEffect, useDeferredValue } from "react";
+import type { BinaryFiles } from "../../types";
+import { useApp } from "../App";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+import { ArrowRightIcon } from "../icons";
+import "./MermaidToExcalidraw.scss";
+import { t } from "../../i18n";
+import Trans from "../Trans";
+import type { MermaidToExcalidrawLibProps } from "./common";
+import {
+ convertMermaidToExcalidraw,
+ insertToEditor,
+ saveMermaidDataToStorage,
+} from "./common";
+import { TTDDialogPanels } from "./TTDDialogPanels";
+import { TTDDialogPanel } from "./TTDDialogPanel";
+import { TTDDialogInput } from "./TTDDialogInput";
+import { TTDDialogOutput } from "./TTDDialogOutput";
+import { EditorLocalStorage } from "../../data/EditorLocalStorage";
+import { EDITOR_LS_KEYS } from "../../constants";
+import { debounce, isDevEnv } from "../../utils";
+import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
+
+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 debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300);
+
+const MermaidToExcalidraw = ({
+ mermaidToExcalidrawLib,
+}: {
+ mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
+}) => {
+ const [text, setText] = useState(
+ () =>
+ EditorLocalStorage.get<string>(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) ||
+ MERMAID_EXAMPLE,
+ );
+ 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(() => {
+ convertMermaidToExcalidraw({
+ canvasRef,
+ data,
+ mermaidToExcalidrawLib,
+ setError,
+ mermaidDefinition: deferredText,
+ }).catch((err) => {
+ if (isDevEnv()) {
+ console.error("Failed to parse mermaid definition", err);
+ }
+ });
+
+ debouncedSaveMermaidDefinition(deferredText);
+ }, [deferredText, mermaidToExcalidrawLib]);
+
+ useEffect(
+ () => () => {
+ debouncedSaveMermaidDefinition.flush();
+ },
+ [],
+ );
+
+ const onInsertToEditor = () => {
+ insertToEditor({
+ app,
+ data,
+ text,
+ shouldSaveMermaidDataToStorage: true,
+ });
+ };
+
+ 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>
+ )}
+ classLink={(el) => (
+ <a href="https://mermaid.js.org/syntax/classDiagram.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)}
+ onKeyboardSubmit={() => {
+ onInsertToEditor();
+ }}
+ />
+ </TTDDialogPanel>
+ <TTDDialogPanel
+ label={t("mermaid.preview")}
+ panelAction={{
+ action: () => {
+ onInsertToEditor();
+ },
+ label: t("mermaid.button"),
+ icon: ArrowRightIcon,
+ }}
+ renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
+ >
+ <TTDDialogOutput
+ canvasRef={canvasRef}
+ loaded={mermaidToExcalidrawLib.loaded}
+ error={error}
+ />
+ </TTDDialogPanel>
+ </TTDDialogPanels>
+ </>
+ );
+};
+export default MermaidToExcalidraw;
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialog.scss b/packages/excalidraw/components/TTDDialog/TTDDialog.scss
new file mode 100644
index 0000000..37ec336
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialog.scss
@@ -0,0 +1,315 @@
+@import "../../css/variables.module.scss";
+
+$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: 700;
+ 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;
+ }
+ }
+ }
+
+ .ttd-dialog-submit-shortcut {
+ margin-inline-start: 0.5rem;
+ font-size: 0.625rem;
+ opacity: 0.6;
+ display: flex;
+ gap: 0.125rem;
+
+ &__key {
+ border: 1px solid gray;
+ padding: 2px 3px;
+ border-radius: 4px;
+ }
+ }
+}
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx
new file mode 100644
index 0000000..c77dfbf
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx
@@ -0,0 +1,394 @@
+import { Dialog } from "../Dialog";
+import { useApp, useExcalidrawSetAppState } from "../App";
+import MermaidToExcalidraw from "./MermaidToExcalidraw";
+import TTDDialogTabs from "./TTDDialogTabs";
+import type { ChangeEventHandler } from "react";
+import { 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 type { MermaidToExcalidrawLibProps } from "./common";
+import {
+ convertMermaidToExcalidraw,
+ insertToEditor,
+ saveMermaidDataToStorage,
+} from "./common";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+import type { BinaryFiles } from "../../types";
+import { ArrowRightIcon } from "../icons";
+
+import "./TTDDialog.scss";
+import { atom, useAtom } from "../../editor-jotai";
+import { trackEvent } from "../../analytics";
+import { InlineIcon } from "../InlineIcon";
+import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
+import { isFiniteNumber } from "@excalidraw/math";
+
+const MIN_PROMPT_LENGTH = 3;
+const MAX_PROMPT_LENGTH = 1000;
+
+const rateLimitsAtom = atom<{
+ rateLimit: number;
+ rateLimitRemaining: number;
+} | null>(null);
+
+const ttdGenerationAtom = atom<{
+ generatedResponse: string | null;
+ prompt: string | null;
+} | 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: "text-to-diagram" | "mermaid";
+ } & (
+ | {
+ onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
+ }
+ | { __fallback: true }
+ )) => {
+ const app = useApp();
+ const setAppState = useExcalidrawSetAppState();
+
+ const someRandomDivRef = useRef<HTMLDivElement>(null);
+
+ const [ttdGeneration, setTtdGeneration] = useAtom(ttdGenerationAtom);
+
+ const [text, setText] = useState(ttdGeneration?.prompt ?? "");
+
+ const prompt = text.trim();
+
+ const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (
+ event,
+ ) => {
+ setText(event.target.value);
+ setTtdGeneration((s) => ({
+ generatedResponse: s?.generatedResponse ?? null,
+ prompt: 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 (typeof generatedResponse === "string") {
+ setTtdGeneration((s) => ({
+ generatedResponse,
+ prompt: s?.prompt ?? null,
+ }));
+ }
+
+ 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,
+ mermaidDefinition: generatedResponse,
+ });
+ trackEvent("ai", "mermaid parse success", "ttd");
+ } catch (error: any) {
+ console.info(
+ `%cTTD mermaid render errror: ${error.message}`,
+ "color: red",
+ );
+ console.info(
+ `>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\nTTD mermaid definition render errror: ${error.message}`,
+ "color: yellow",
+ );
+ 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("@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={false}
+ {...rest}
+ autofocus={false}
+ >
+ <TTDDialogTabs dialog="ttd" tab={tab}>
+ {"__fallback" in rest && rest.__fallback ? (
+ <p className="dialog-mermaid-title">{t("mermaid.title")}</p>
+ ) : (
+ <TTDDialogTabTriggers>
+ <TTDDialogTabTrigger tab="text-to-diagram">
+ <div style={{ display: "flex", alignItems: "center" }}>
+ {t("labels.textToDiagram")}
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "1px 6px",
+ marginLeft: "10px",
+ fontSize: 10,
+ borderRadius: "12px",
+ background: "var(--color-promo)",
+ color: "var(--color-surface-lowest)",
+ }}
+ >
+ AI Beta
+ </div>
+ </div>
+ </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>
+ );
+ }}
+ renderSubmitShortcut={() => <TTDDialogSubmitShortcut />}
+ renderBottomRight={() => {
+ if (typeof ttdGeneration?.generatedResponse === "string") {
+ return (
+ <div
+ className="excalidraw-link"
+ style={{ marginLeft: "auto", fontSize: 14 }}
+ onClick={() => {
+ if (
+ typeof ttdGeneration?.generatedResponse ===
+ "string"
+ ) {
+ saveMermaidDataToStorage(
+ ttdGeneration.generatedResponse,
+ );
+ setAppState({
+ openDialog: { name: "ttd", tab: "mermaid" },
+ });
+ }
+ }}
+ >
+ View as Mermaid
+ <InlineIcon icon={ArrowRightIcon} />
+ </div>
+ );
+ }
+ 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>
+ );
+ },
+);
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx
new file mode 100644
index 0000000..e11d0dc
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx
@@ -0,0 +1,53 @@
+import type { ChangeEventHandler } from "react";
+import { 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}
+ />
+ );
+};
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogOutput.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogOutput.tsx
new file mode 100644
index 0000000..084a67f
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogOutput.tsx
@@ -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 | null>;
+ 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>
+ );
+};
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogPanel.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogPanel.tsx
new file mode 100644
index 0000000..0a78e49
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogPanel.tsx
@@ -0,0 +1,63 @@
+import type { 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;
+ renderSubmitShortcut?: () => ReactNode;
+ renderBottomRight?: () => ReactNode;
+}
+
+export const TTDDialogPanel = ({
+ label,
+ children,
+ panelAction,
+ panelActionDisabled = false,
+ onTextSubmitInProgess,
+ renderTopRight,
+ renderSubmitShortcut,
+ 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>
+ {!panelActionDisabled &&
+ !onTextSubmitInProgess &&
+ renderSubmitShortcut?.()}
+ {renderBottomRight?.()}
+ </div>
+ </div>
+ );
+};
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogPanels.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogPanels.tsx
new file mode 100644
index 0000000..0e5e2b5
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogPanels.tsx
@@ -0,0 +1,5 @@
+import type { ReactNode } from "react";
+
+export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
+ return <div className="ttd-dialog-panels">{children}</div>;
+};
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx
new file mode 100644
index 0000000..a8831e3
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx
@@ -0,0 +1,14 @@
+import { getShortcutKey } from "../../utils";
+
+export const TTDDialogSubmitShortcut = () => {
+ return (
+ <div className="ttd-dialog-submit-shortcut">
+ <div className="ttd-dialog-submit-shortcut__key">
+ {getShortcutKey("CtrlOrCmd")}
+ </div>
+ <div className="ttd-dialog-submit-shortcut__key">
+ {getShortcutKey("Enter")}
+ </div>
+ </div>
+ );
+};
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTab.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTab.tsx
new file mode 100644
index 0000000..b9758f1
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogTab.tsx
@@ -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";
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTabTrigger.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTabTrigger.tsx
new file mode 100644
index 0000000..ec975ff
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogTabTrigger.tsx
@@ -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";
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTabTriggers.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTabTriggers.tsx
new file mode 100644
index 0000000..509c746
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogTabTriggers.tsx
@@ -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";
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx
new file mode 100644
index 0000000..439f928
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx
@@ -0,0 +1,55 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import type { ReactNode } from "react";
+import { useRef } from "react";
+import { useExcalidrawSetAppState } from "../App";
+import { isMemberOf } from "../../utils";
+
+const TTDDialogTabs = (
+ props: {
+ children: ReactNode;
+ } & { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" },
+) => {
+ const setAppState = useExcalidrawSetAppState();
+
+ const rootRef = useRef<HTMLDivElement>(null);
+ const minHeightRef = useRef<number>(0);
+
+ return (
+ <RadixTabs.Root
+ ref={rootRef}
+ className="ttd-dialog-tabs-root"
+ value={props.tab}
+ onValueChange={(
+ // at least in test enviros, `tab` can be `undefined`
+ tab: string | undefined,
+ ) => {
+ if (!tab) {
+ return;
+ }
+ const modalContentNode =
+ rootRef.current?.closest<HTMLElement>(".Modal__content");
+ if (modalContentNode) {
+ const currHeight = modalContentNode.offsetHeight || 0;
+ if (currHeight > minHeightRef.current) {
+ minHeightRef.current = currHeight;
+ modalContentNode.style.minHeight = `min(${minHeightRef.current}px, 100%)`;
+ }
+ }
+ if (
+ props.dialog === "ttd" &&
+ isMemberOf(["text-to-diagram", "mermaid"], tab)
+ ) {
+ setAppState({
+ openDialog: { name: props.dialog, tab },
+ });
+ }
+ }}
+ >
+ {props.children}
+ </RadixTabs.Root>
+ );
+};
+
+TTDDialogTabs.displayName = "TTDDialogTabs";
+
+export default TTDDialogTabs;
diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx
new file mode 100644
index 0000000..a73f31a
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx
@@ -0,0 +1,35 @@
+import type { JSX } from "react";
+import type { 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";
diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts
new file mode 100644
index 0000000..4191126
--- /dev/null
+++ b/packages/excalidraw/components/TTDDialog/common.ts
@@ -0,0 +1,161 @@
+import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
+import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
+import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
+import { convertToExcalidrawElements, exportToCanvas } from "../../index";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+import type { AppClassProperties, BinaryFiles } from "../../types";
+import { canvasToBlob } from "../../data/blob";
+import { EditorLocalStorage } from "../../data/EditorLocalStorage";
+import { t } from "../../i18n";
+
+const resetPreview = ({
+ canvasRef,
+ setError,
+}: {
+ canvasRef: React.RefObject<HTMLDivElement | null>;
+ 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,
+ config?: MermaidConfig,
+ ) => Promise<MermaidToExcalidrawResult>;
+ }>;
+}
+
+interface ConvertMermaidToExcalidrawFormatProps {
+ canvasRef: React.RefObject<HTMLDivElement | null>;
+ mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
+ mermaidDefinition: string;
+ setError: (error: Error | null) => void;
+ data: React.MutableRefObject<{
+ elements: readonly NonDeletedExcalidrawElement[];
+ files: BinaryFiles | null;
+ }>;
+}
+
+export const convertMermaidToExcalidraw = async ({
+ canvasRef,
+ mermaidToExcalidrawLib,
+ mermaidDefinition,
+ setError,
+ data,
+}: ConvertMermaidToExcalidrawFormatProps) => {
+ const canvasNode = canvasRef.current;
+ const parent = canvasNode?.parentElement;
+
+ if (!canvasNode || !parent) {
+ return;
+ }
+
+ if (!mermaidDefinition) {
+ resetPreview({ canvasRef, setError });
+ return;
+ }
+
+ try {
+ const api = await mermaidToExcalidrawLib.api;
+
+ let ret;
+ try {
+ ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
+ } catch (err: any) {
+ ret = await api.parseMermaidToExcalidraw(
+ mermaidDefinition.replace(/"/g, "'"),
+ );
+ }
+ const { elements, files } = ret;
+ 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)
+ try {
+ await canvasToBlob(canvas);
+ } catch (e: any) {
+ if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
+ throw new Error(t("canvasError.canvasTooBig"));
+ }
+ throw e;
+ }
+ parent.style.background = "var(--default-bg-color)";
+ canvasNode.replaceChildren(canvas);
+ } catch (err: any) {
+ parent.style.background = "var(--default-bg-color)";
+ if (mermaidDefinition) {
+ setError(err);
+ }
+
+ throw err;
+ }
+};
+
+export const saveMermaidDataToStorage = (mermaidDefinition: string) => {
+ EditorLocalStorage.set(
+ EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW,
+ mermaidDefinition,
+ );
+};
+
+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);
+ }
+};