diff --git a/.env.development b/.env.development index bee5d894..44955884 100644 --- a/.env.development +++ b/.env.development @@ -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! diff --git a/.env.production b/.env.production index 19df4b96..26b46a52 100644 --- a/.env.production +++ b/.env.production @@ -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= diff --git a/excalidraw-app/index.tsx b/excalidraw-app/index.tsx index 7708fc19..a63c1034 100644 --- a/excalidraw-app/index.tsx +++ b/excalidraw-app/index.tsx @@ -25,6 +25,8 @@ import { Excalidraw, defaultLang, LiveCollaborationTrigger, + TTDDialog, + TTDDialogTrigger, } from "../src/packages/excalidraw/index"; import { AppState, @@ -773,6 +775,64 @@ const ExcalidrawWrapper = () => { )} + { + 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"); + } + }} + /> + {isCollaborating && isOffline && ( {t("alerts.collabOfflineWarning")} diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 7c9a2ca7..f0c4d8db 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -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 = ({ Generate + {app.props.aiEnabled !== false && } app.setOpenDialog({ name: "mermaid" })} + onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })} icon={mermaidLogoIcon} data-testid="toolbar-embeddable" > {t("toolBar.mermaidToExcalidraw")} - {app.props.aiEnabled !== false && ( <> {t("toolBar.magicframe")} + AI { - trackEvent("ai", "d2c-settings", "settings"); + trackEvent("ai", "open-settings", "d2c"); app.setOpenDialog({ name: "magicSettings", source: "settings", diff --git a/src/components/App.tsx b/src/components/App.tsx index 486f32c2..87fc57e9 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 { onMagicSettingsConfirm={this.onMagicSettingsConfirm} > {this.props.children} - {this.state.openDialog?.name === "mermaid" && ( - - )} @@ -1706,7 +1702,7 @@ class App extends React.Component { 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 { 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 { 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 { }); 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 { }); 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 { 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 { 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 { return; } - trackEvent("ai", "d2c-tool", "existing-selection"); + trackEvent("ai", "tool-select (existing selection)", "d2c"); let frame: ExcalidrawMagicFrameElement; if (selectedMagicFrame) { diff --git a/src/components/Button.tsx b/src/components/Button.tsx index bf548d72..43b6de9e 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -2,7 +2,11 @@ import clsx from "clsx"; import { composeEventHandlers } from "../utils"; import "./Button.scss"; -interface ButtonProps extends React.HTMLAttributes { +interface ButtonProps + extends React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement + > { type?: "button" | "submit" | "reset"; onSelect: () => any; /** whether button is in active state */ diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 996fda15..4e5cc93e 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -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")} + {appState.openDialog?.name === "ttd" && } {/* ------------------------------------------------------------------ */} {appState.isLoading && } diff --git a/src/components/MermaidToExcalidraw.scss b/src/components/MermaidToExcalidraw.scss deleted file mode 100644 index 59f44ba1..00000000 --- a/src/components/MermaidToExcalidraw.scss +++ /dev/null @@ -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; - } - } - } -} diff --git a/src/components/MermaidToExcalidraw.tsx b/src/components/MermaidToExcalidraw.tsx deleted file mode 100644 index b53d0780..00000000 --- a/src/components/MermaidToExcalidraw.tsx +++ /dev/null @@ -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 ( - - Error! {error} - - ); -}; - -const MermaidToExcalidraw = () => { - const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{ - loaded: boolean; - api: { - parseMermaidToExcalidraw: ( - defination: string, - options: MermaidOptions, - ) => Promise; - } | null; - }>({ loaded: false, api: null }); - - const [text, setText] = useState(""); - const deferredText = useDeferredValue(text.trim()); - const [error, setError] = useState(null); - - const canvasRef = useRef(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 ( - - {t("mermaid.title")} - - ( - {el} - )} - sequenceLink={(el) => ( - - {el} - - )} - /> - - - > - } - > - - - - {t("mermaid.syntax")} - - setText(event.target.value)} - value={text} - /> - - - {t("mermaid.preview")} - - {error && } - {mermaidToExcalidrawLib.loaded ? ( - - ) : ( - - )} - - - - - - {t("mermaid.button")} - {ArrowRightIcon} - - - - - ); -}; -export default MermaidToExcalidraw; diff --git a/src/components/TTDDialog/MermaidToExcalidraw.scss b/src/components/TTDDialog/MermaidToExcalidraw.scss new file mode 100644 index 00000000..2a9a40bf --- /dev/null +++ b/src/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/src/components/TTDDialog/MermaidToExcalidraw.tsx b/src/components/TTDDialog/MermaidToExcalidraw.tsx new file mode 100644 index 00000000..a5074fdb --- /dev/null +++ b/src/components/TTDDialog/MermaidToExcalidraw.tsx @@ -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(null); + + const canvasRef = useRef(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 ( + <> + + ( + {el} + )} + sequenceLink={(el) => ( + + {el} + + )} + /> + + + + setText(event.target.value)} + /> + + { + insertToEditor({ + app, + data, + text, + shouldSaveMermaidDataToStorage: true, + }); + }, + label: t("mermaid.button"), + icon: ArrowRightIcon, + }} + > + + + + > + ); +}; +export default MermaidToExcalidraw; diff --git a/src/components/TTDDialog/TTDDialog.scss b/src/components/TTDDialog/TTDDialog.scss new file mode 100644 index 00000000..d756e2a5 --- /dev/null +++ b/src/components/TTDDialog/TTDDialog.scss @@ -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; + } + } + } +} diff --git a/src/components/TTDDialog/TTDDialog.tsx b/src/components/TTDDialog/TTDDialog.tsx new file mode 100644 index 00000000..3a32ee55 --- /dev/null +++ b/src/components/TTDDialog/TTDDialog.tsx @@ -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; + } + | { __fallback: true }, +) => { + const appState = useUIAppState(); + + if (appState.openDialog?.name !== "ttd") { + return null; + } + + return ; +}; + +/** + * Text to diagram (TTD) dialog + */ +export const TTDDialogBase = withInternalFallback( + "TTDDialogBase", + ({ + tab, + ...rest + }: { + tab: string; + } & ( + | { + onTextSubmit(value: string): Promise; + } + | { __fallback: true } + )) => { + const app = useApp(); + + const someRandomDivRef = useRef(null); + + const [text, setText] = useState(""); + + const prompt = text.trim(); + + const handleTextChange: ChangeEventHandler = ( + 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({ + 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(null); + + return ( + { + app.setOpenDialog(null); + }} + size={1200} + title="" + {...rest} + autofocus={false} + > + + {"__fallback" in rest && rest.__fallback ? ( + {t("mermaid.title")} + ) : ( + + + {t("labels.textToDiagram")} + + Mermaid + + )} + + + + + {!("__fallback" in rest) && ( + + + Currently we use Mermaid as a middle step, so you'll get best + results if you describe a diagram, workflow, flow chart, and + similar. + + + MAX_PROMPT_LENGTH || + rateLimits?.rateLimitRemaining === 0 + } + renderTopRight={() => { + if (!rateLimits) { + return null; + } + + return ( + + {rateLimits.rateLimitRemaining} requests left today + + ); + }} + renderBottomRight={() => { + const ratio = prompt.length / MAX_PROMPT_LENGTH; + if (ratio > 0.8) { + return ( + 1 ? "var(--color-danger)" : undefined, + }} + > + Length: {prompt.length}/{MAX_PROMPT_LENGTH} + + ); + } + + return null; + }} + > + { + refOnGenerate.current(); + }} + /> + + { + console.info("Panel action clicked"); + insertToEditor({ app, data }); + }, + label: "Insert", + icon: ArrowRightIcon, + }} + > + + + + + )} + + + ); + }, +); diff --git a/src/components/TTDDialog/TTDDialogInput.tsx b/src/components/TTDDialog/TTDDialogInput.tsx new file mode 100644 index 00000000..8ac464f9 --- /dev/null +++ b/src/components/TTDDialog/TTDDialogInput.tsx @@ -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; + onKeyboardSubmit?: () => void; +} + +export const TTDDialogInput = ({ + input, + placeholder, + onChange, + onKeyboardSubmit, +}: TTDDialogInputProps) => { + const ref = useRef(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 ( + + ); +}; diff --git a/src/components/TTDDialog/TTDDialogOutput.tsx b/src/components/TTDDialog/TTDDialogOutput.tsx new file mode 100644 index 00000000..c05093fe --- /dev/null +++ b/src/components/TTDDialog/TTDDialogOutput.tsx @@ -0,0 +1,39 @@ +import Spinner from "../Spinner"; + +const ErrorComp = ({ error }: { error: string }) => { + return ( + + Error! {error} + + ); +}; + +interface TTDDialogOutputProps { + error: Error | null; + canvasRef: React.RefObject; + loaded: boolean; +} + +export const TTDDialogOutput = ({ + error, + canvasRef, + loaded, +}: TTDDialogOutputProps) => { + return ( + + {error && } + {loaded ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/components/TTDDialog/TTDDialogPanel.tsx b/src/components/TTDDialog/TTDDialogPanel.tsx new file mode 100644 index 00000000..b74b7489 --- /dev/null +++ b/src/components/TTDDialog/TTDDialogPanel.tsx @@ -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 ( + + + {label} + {renderTopRight?.()} + + + {children} + + {}} + disabled={panelActionDisabled || onTextSubmitInProgess} + > + + {panelAction?.label} + {panelAction?.icon && {panelAction.icon}} + + {onTextSubmitInProgess && } + + {renderBottomRight?.()} + + + ); +}; diff --git a/src/components/TTDDialog/TTDDialogPanels.tsx b/src/components/TTDDialog/TTDDialogPanels.tsx new file mode 100644 index 00000000..00e57342 --- /dev/null +++ b/src/components/TTDDialog/TTDDialogPanels.tsx @@ -0,0 +1,5 @@ +import { ReactNode } from "react"; + +export const TTDDialogPanels = ({ children }: { children: ReactNode }) => { + return {children}; +}; diff --git a/src/components/TTDDialog/TTDDialogTab.tsx b/src/components/TTDDialog/TTDDialogTab.tsx new file mode 100644 index 00000000..b9758f15 --- /dev/null +++ b/src/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) => { + return ( + + {children} + + ); +}; +TTDDialogTab.displayName = "TTDDialogTab"; diff --git a/src/components/TTDDialog/TTDDialogTabTrigger.tsx b/src/components/TTDDialog/TTDDialogTabTrigger.tsx new file mode 100644 index 00000000..ec975ff2 --- /dev/null +++ b/src/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 | undefined; +} & Omit, "onSelect">) => { + return ( + + + {children} + + + ); +}; +TTDDialogTabTrigger.displayName = "TTDDialogTabTrigger"; diff --git a/src/components/TTDDialog/TTDDialogTabTriggers.tsx b/src/components/TTDDialog/TTDDialogTabTriggers.tsx new file mode 100644 index 00000000..509c7469 --- /dev/null +++ b/src/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) => { + return ( + + {children} + + ); +}; +TTDDialogTabTriggers.displayName = "TTDDialogTabTriggers"; diff --git a/src/components/TTDDialog/TTDDialogTabs.tsx b/src/components/TTDDialog/TTDDialogTabs.tsx new file mode 100644 index 00000000..87a9c168 --- /dev/null +++ b/src/components/TTDDialog/TTDDialogTabs.tsx @@ -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 ( + { + if (tab) { + setAppState({ + openDialog: { name: "ttd", tab }, + }); + } + }} + {...rest} + > + {children} + + ); +}; + +TTDDialogTabs.displayName = "TTDDialogTabs"; + +export default TTDDialogTabs; diff --git a/src/components/TTDDialog/TTDDialogTrigger.tsx b/src/components/TTDDialog/TTDDialogTrigger.tsx new file mode 100644 index 00000000..05bc303d --- /dev/null +++ b/src/components/TTDDialog/TTDDialogTrigger.tsx @@ -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 ( + + { + trackEvent("ai", "dialog open", "ttd"); + setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } }); + }} + icon={icon ?? brainIcon} + > + {children ?? t("labels.textToDiagram")} + AI + + + ); +}; +TTDDialogTrigger.displayName = "TTDDialogTrigger"; diff --git a/src/components/TTDDialog/common.ts b/src/components/TTDDialog/common.ts new file mode 100644 index 00000000..4e11931e --- /dev/null +++ b/src/components/TTDDialog/common.ts @@ -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; + 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; + }>; +} + +interface ConvertMermaidToExcalidrawFormatProps { + canvasRef: React.RefObject; + 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); + } +}; diff --git a/src/components/dropdownMenu/DropdownMenu.scss b/src/components/dropdownMenu/DropdownMenu.scss index 8234ae27..5af7b2ec 100644 --- a/src/components/dropdownMenu/DropdownMenu.scss +++ b/src/components/dropdownMenu/DropdownMenu.scss @@ -63,9 +63,13 @@ } &__text { + display: flex; + align-items: center; + width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; + gap: 0.75rem; } &__shortcut { diff --git a/src/components/dropdownMenu/DropdownMenuItem.tsx b/src/components/dropdownMenu/DropdownMenuItem.tsx index 93108a9f..1d92e1f1 100644 --- a/src/components/dropdownMenu/DropdownMenuItem.tsx +++ b/src/components/dropdownMenu/DropdownMenuItem.tsx @@ -37,6 +37,32 @@ const DropdownMenuItem = ({ ); }; +DropdownMenuItem.displayName = "DropdownMenuItem"; + +export const DropDownMenuItemBadge = ({ + children, +}: { + children: React.ReactNode; +}) => { + return ( + + {children} + + ); +}; +DropDownMenuItemBadge.displayName = "DropdownMenuItemBadge"; + +DropdownMenuItem.Badge = DropDownMenuItemBadge; export default DropdownMenuItem; -DropdownMenuItem.displayName = "DropdownMenuItem"; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 7f591e7d..3449ccda 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1742,3 +1742,16 @@ export const eyeClosedIcon = createIcon( , tablerIconProps, ); + +export const brainIcon = createIcon( + + + + + + + + + , + tablerIconProps, +); diff --git a/src/context/tunnels.ts b/src/context/tunnels.ts index fa807a60..8dc325ff 100644 --- a/src/context/tunnels.ts +++ b/src/context/tunnels.ts @@ -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(), }; }, []); diff --git a/src/css/styles.scss b/src/css/styles.scss index 341370f2..43f30b40 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -39,6 +39,7 @@ button { cursor: pointer; + user-select: none; } &:focus { diff --git a/src/locales/en.json b/src/locales/en.json index 9855e429..8b4a1df2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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...", diff --git a/src/packages/excalidraw/example/App.tsx b/src/packages/excalidraw/example/App.tsx index 0f905678..e404f36e 100644 --- a/src/packages/excalidraw/example/App.tsx +++ b/src/packages/excalidraw/example/App.tsx @@ -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 {renderMenu()} + {excalidrawAPI && ( + 😀}> + Text to diagram + + )} + { + console.info("submit"); + // sleep for 2s + await new Promise((resolve) => setTimeout(resolve, 2000)); + throw new Error("error, go away now"); + // return "dummy"; + }} + /> {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()} {comment && renderComment()} diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index bf82817a..689efe61 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -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"; diff --git a/src/tests/MermaidToExcalidraw.test.tsx b/src/tests/MermaidToExcalidraw.test.tsx index 758831a6..e12df4b2 100644 --- a/src/tests/MermaidToExcalidraw.test.tsx +++ b/src/tests/MermaidToExcalidraw.test.tsx @@ -102,7 +102,7 @@ describe("Test ", () => { , @@ -110,16 +110,16 @@ describe("Test ", () => { }); 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 ", () => { }); 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 ", () => { editor = await getTextEditor(selector, false); expect(editor.textContent).toBe("flowchart TD1"); - expect(dialog.querySelector('[data-testid="mermaid-error"]')) - .toMatchInlineSnapshot(` - - Error! - - ERROR - - - `); + expect( + dialog.querySelector('[data-testid="mermaid-error"]'), + ).toMatchInlineSnapshot("null"); }); }); diff --git a/src/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/src/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap index 3cdf25af..ce35ead1 100644 --- a/src/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap +++ b/src/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap @@ -1,10 +1,10 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Test > should open mermaid popup when active tool is mermaid 1`] = ` -"Mermaid to ExcalidrawCurrently only Flowcharts and Sequence Diagrams are supported. The other types will be rendered as image in Excalidraw.Mermaid Syntaxflowchart TD +"Mermaid to ExcalidrawCurrently only Flowcharts and Sequence Diagrams are supported. The other types will be rendered as image in Excalidraw.Mermaid Syntaxflowchart TD A[Christmas] -->|Get money| B(Go shopping) B --> C{Let me think} C -->|One| D[Laptop] C -->|Two| E[iPhone] - C -->|Three| F[Car]PreviewInsert" + C -->|Three| F[Car]PreviewInsert" `; diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx index 1e7ebeb0..9b11bfbd 100644 --- a/src/tests/linearElementEditor.test.tsx +++ b/src/tests/linearElementEditor.test.tsx @@ -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); diff --git a/src/types.ts b/src/types.ts index 507123ba..94a48460 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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. * diff --git a/src/utils.ts b/src/utils.ts index 223cbab9..c092fea7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -925,3 +925,7 @@ export const isMemberOf = ( }; export const cloneJSON = (obj: T): T => JSON.parse(JSON.stringify(obj)); + +export const isFiniteNumber = (value: any): value is number => { + return typeof value === "number" && Number.isFinite(value); +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index cd4030b1..c4ab79c3 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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;
{error}
{t("mermaid.title")}
- ERROR -
Mermaid to Excalidraw