diff --git a/excalidraw-app/index.tsx b/excalidraw-app/index.tsx index a63c1034e..8423edb5f 100644 --- a/excalidraw-app/index.tsx +++ b/excalidraw-app/index.tsx @@ -104,6 +104,7 @@ import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog"; import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState"; import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm"; import Trans from "../src/components/Trans"; +import { drawingIcon } from "../src/components/icons"; polyfill(); @@ -776,12 +777,10 @@ const ExcalidrawWrapper = () => { { + onTextSubmit={async (input, type) => { try { const response = await fetch( - `${ - import.meta.env.VITE_APP_AI_BACKEND - }/v1/ai/text-to-diagram/generate`, + `${import.meta.env.VITE_APP_AI_BACKEND}/v1/ai/${type}/generate`, { method: "POST", headers: { @@ -833,6 +832,9 @@ const ExcalidrawWrapper = () => { }} /> + + {t("labels.textToDrawing")} + {isCollaborating && isOffline && ( {t("alerts.collabOfflineWarning")} diff --git a/src/components/App.tsx b/src/components/App.tsx index cf68d516c..2c3f7a6ef 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -397,7 +397,6 @@ import { COLOR_PALETTE } from "../colors"; import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; -import { TextToExcalidraw } from "./TextToExcalidraw/TextToExcalidraw"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); diff --git a/src/components/TTDDialog/MermaidToExcalidraw.tsx b/src/components/TTDDialog/MermaidToExcalidraw.tsx index 342bf38b5..fe4c89244 100644 --- a/src/components/TTDDialog/MermaidToExcalidraw.tsx +++ b/src/components/TTDDialog/MermaidToExcalidraw.tsx @@ -111,7 +111,7 @@ const MermaidToExcalidraw = ({ action: () => { insertToEditor({ app, - data, + data: data.current, text, shouldSaveMermaidDataToStorage: true, }); diff --git a/src/components/TTDDialog/TTDDialog.tsx b/src/components/TTDDialog/TTDDialog.tsx index 8572a930f..c1edb5aa8 100644 --- a/src/components/TTDDialog/TTDDialog.tsx +++ b/src/components/TTDDialog/TTDDialog.tsx @@ -2,58 +2,20 @@ import { Dialog } from "../Dialog"; import { useApp } from "../App"; import MermaidToExcalidraw from "./MermaidToExcalidraw"; import TTDDialogTabs from "./TTDDialogTabs"; -import { ChangeEventHandler, useEffect, useRef, useState } from "react"; +import { useEffect, 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 { CommonDialogProps, MermaidToExcalidrawLibProps } from "./common"; import "./TTDDialog.scss"; -import { isFiniteNumber } from "../../utils"; -import { atom, useAtom } from "jotai"; -import { trackEvent } from "../../analytics"; +import { TextToDiagram } from "./TextToDiagram"; +import { TextToDrawing } from "./TextToDrawing"; -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 }, -) => { +export const TTDDialog = (props: CommonDialogProps | { __fallback: true }) => { const appState = useUIAppState(); if (appState.openDialog?.name !== "ttd") { @@ -72,118 +34,10 @@ export const TTDDialogBase = withInternalFallback( tab, ...rest }: { - tab: "text-to-diagram" | "mermaid"; - } & ( - | { - onTextSubmit(value: string): Promise; - } - | { __fallback: true } - )) => { + tab: "text-to-diagram" | "mermaid" | "text-to-drawing"; + } & (CommonDialogProps | { __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, - mermaidDefinition: generatedResponse, - }); - trackEvent("ai", "mermaid parse success", "ttd"); - saveMermaidDataToStorage(generatedResponse); - } 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({ loaded: false, @@ -200,13 +54,6 @@ export const TTDDialogBase = withInternalFallback( fn(); }, [mermaidToExcalidrawLib.api]); - const data = useRef<{ - elements: readonly NonDeletedExcalidrawElement[]; - files: BinaryFiles | null; - }>({ elements: [], files: null }); - - const [error, setError] = useState(null); - return ( + + {t("labels.textToDrawing")} + Mermaid )} @@ -254,93 +104,15 @@ export const TTDDialogBase = withInternalFallback( {!("__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, - }} - > - - - + + + )} + {!("__fallback" in rest) && ( + + )} diff --git a/src/components/TTDDialog/TTDDialogTabs.tsx b/src/components/TTDDialog/TTDDialogTabs.tsx index 324f4e534..57c2ebdb8 100644 --- a/src/components/TTDDialog/TTDDialogTabs.tsx +++ b/src/components/TTDDialog/TTDDialogTabs.tsx @@ -7,8 +7,11 @@ const TTDDialogTabs = ( props: { children: ReactNode; } & ( - | { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" } - | { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" } + | { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" | "text-to-drawing" } + | { + dialog: "settings"; + tab: "text-to-diagram" | "diagram-to-code"; + } ), ) => { const setAppState = useExcalidrawSetAppState(); diff --git a/src/components/TTDDialog/TTDDialogTrigger.tsx b/src/components/TTDDialog/TTDDialogTrigger.tsx index 05bc303d9..4309776d5 100644 --- a/src/components/TTDDialog/TTDDialogTrigger.tsx +++ b/src/components/TTDDialog/TTDDialogTrigger.tsx @@ -9,9 +9,11 @@ import { trackEvent } from "../../analytics"; export const TTDDialogTrigger = ({ children, icon, + tab, }: { children?: ReactNode; icon?: JSX.Element; + tab?: string; }) => { const { TTDDialogTriggerTunnel } = useTunnels(); const setAppState = useExcalidrawSetAppState(); @@ -21,7 +23,9 @@ export const TTDDialogTrigger = ({ { trackEvent("ai", "dialog open", "ttd"); - setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } }); + setAppState({ + openDialog: { name: "ttd", tab: tab ?? "text-to-diagram" }, + }); }} icon={icon ?? brainIcon} > diff --git a/src/components/TTDDialog/TextToDiagram.tsx b/src/components/TTDDialog/TextToDiagram.tsx new file mode 100644 index 000000000..99ecbc091 --- /dev/null +++ b/src/components/TTDDialog/TextToDiagram.tsx @@ -0,0 +1,228 @@ +import { useAtom } from "jotai"; +import { useRef, useState, ChangeEventHandler } from "react"; +import { trackEvent } from "../../analytics"; +import { t } from "../../i18n"; +import { isFiniteNumber } from "../../utils"; +import { ArrowRightIcon } from "../icons"; +import { TTDDialogInput } from "./TTDDialogInput"; +import { TTDDialogOutput } from "./TTDDialogOutput"; +import { TTDDialogPanel } from "./TTDDialogPanel"; +import { TTDDialogPanels } from "./TTDDialogPanels"; +import { + CommonDialogProps, + MAX_PROMPT_LENGTH, + MIN_PROMPT_LENGTH, + MermaidToExcalidrawLibProps, + convertMermaidToExcalidraw, + insertToEditor, + rateLimitsAtom, + saveMermaidDataToStorage, +} from "./common"; +import { useApp } from "../App"; +import { NonDeletedExcalidrawElement } from "../../element/types"; +import { BinaryFiles } from "../../types"; + +export type TextToDiagramProps = CommonDialogProps & { + mermaidToExcalidrawLib: MermaidToExcalidrawLibProps; +}; + +export const TextToDiagram = ({ + onTextSubmit, + mermaidToExcalidrawLib, +}: TextToDiagramProps) => { + 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 data = useRef<{ + elements: readonly NonDeletedExcalidrawElement[]; + files: BinaryFiles | null; + }>({ elements: [], files: null }); + + const [error, setError] = useState(null); + + const onGenerate = async () => { + if ( + prompt.length > MAX_PROMPT_LENGTH || + prompt.length < MIN_PROMPT_LENGTH || + onTextSubmitInProgess || + rateLimits?.rateLimitRemaining === 0 + ) { + 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 onTextSubmit(prompt, "text-to-diagram"); + + 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"); + saveMermaidDataToStorage(generatedResponse); + } 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; + + return ( + <> + + 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: data.current }); + }, + label: "Insert", + icon: ArrowRightIcon, + }} + > + + + + > + ); +}; diff --git a/src/components/TTDDialog/TextToDrawing.tsx b/src/components/TTDDialog/TextToDrawing.tsx new file mode 100644 index 000000000..cbd592b33 --- /dev/null +++ b/src/components/TTDDialog/TextToDrawing.tsx @@ -0,0 +1,248 @@ +import { useAtom } from "jotai"; +import { useRef, useState, ChangeEventHandler } from "react"; +import { trackEvent } from "../../analytics"; +import { NonDeletedExcalidrawElement } from "../../element/types"; +import { t } from "../../i18n"; +import { isFiniteNumber } from "../../utils"; +import { useApp } from "../App"; +import { ArrowRightIcon } from "../icons"; +import { TTDDialogInput } from "./TTDDialogInput"; +import { TTDDialogOutput } from "./TTDDialogOutput"; +import { TTDDialogPanel } from "./TTDDialogPanel"; +import { TTDDialogPanels } from "./TTDDialogPanels"; +import { + CommonDialogProps, + MAX_PROMPT_LENGTH, + MIN_PROMPT_LENGTH, + insertToEditor, + rateLimitsAtom, + resetPreview, +} from "./common"; +import { + convertToExcalidrawElements, + exportToCanvas, +} from "../../packages/excalidraw/index"; +import { DEFAULT_EXPORT_PADDING } from "../../constants"; +import { canvasToBlob } from "../../data/blob"; + +export type TextToDrawingProps = CommonDialogProps; + +export const TextToDrawing = ({ onTextSubmit }: TextToDrawingProps) => { + const app = useApp(); + const containerRef = 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 [data, setData] = useState< + readonly NonDeletedExcalidrawElement[] | null + >(null); + + const [error, setError] = useState(null); + + const onGenerate = async () => { + if ( + prompt.length > MAX_PROMPT_LENGTH || + prompt.length < MIN_PROMPT_LENGTH || + onTextSubmitInProgess || + rateLimits?.rateLimitRemaining === 0 + ) { + 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", "text-to-drawing"); + + const { generatedResponse, error, rateLimit, rateLimitRemaining } = + await onTextSubmit(prompt, "text-to-drawing"); + + if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) { + setRateLimits({ rateLimit, rateLimitRemaining }); + } + + if (error) { + setError(error); + return; + } + if (!generatedResponse) { + setError(new Error("Generation failed")); + return; + } + + const canvasNode = containerRef.current; + const parent = canvasNode?.parentElement; + + if (!canvasNode || !parent) { + return; + } + + if (!text) { + resetPreview({ canvasRef: containerRef, setError }); + return; + } + + if (!Array.isArray(generatedResponse)) { + setError(new Error("Generation failed to return an array!")); + return; + } + + try { + const elements = convertToExcalidrawElements(generatedResponse, { + regenerateIds: true, + }); + + setData(elements); + + const canvas = await exportToCanvas({ + elements, + files: null, + 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; + } + } 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; + + return ( + <> + This is text to drawing. + + 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(); + }} + /> + + { + if (data) { + insertToEditor({ + app, + data: { + elements: data, + files: null, + }, + }); + } + }, + label: "Insert", + icon: ArrowRightIcon, + }} + > + + + + > + ); +}; diff --git a/src/components/TTDDialog/common.ts b/src/components/TTDDialog/common.ts index 9d90a0432..a17de1c4c 100644 --- a/src/components/TTDDialog/common.ts +++ b/src/components/TTDDialog/common.ts @@ -8,8 +8,9 @@ import { import { NonDeletedExcalidrawElement } from "../../element/types"; import { AppClassProperties, BinaryFiles } from "../../types"; import { canvasToBlob } from "../../data/blob"; +import { atom } from "jotai"; -const resetPreview = ({ +export const resetPreview = ({ canvasRef, setError, }: { @@ -30,6 +31,26 @@ const resetPreview = ({ canvasNode.replaceChildren(); }; +export type OnTestSubmitRetValue = { + rateLimit?: number | null; + rateLimitRemaining?: number | null; +} & ( + | { + generatedResponse: any | string | undefined; + error?: null | undefined; + } + | { + error: Error; + generatedResponse?: null | undefined; + } +); +export interface CommonDialogProps { + onTextSubmit( + value: string, + type: "text-to-diagram" | "text-to-drawing", + ): Promise; +} + export interface MermaidToExcalidrawLibProps { loaded: boolean; api: Promise<{ @@ -137,14 +158,14 @@ export const insertToEditor = ({ shouldSaveMermaidDataToStorage, }: { app: AppClassProperties; - data: React.MutableRefObject<{ + data: { elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles | null; - }>; + }; text?: string; shouldSaveMermaidDataToStorage?: boolean; }) => { - const { elements: newElements, files } = data.current; + const { elements: newElements, files } = data; if (!newElements.length) { return; @@ -162,3 +183,11 @@ export const insertToEditor = ({ saveMermaidDataToStorage(text); } }; + +export const MIN_PROMPT_LENGTH = 3; +export const MAX_PROMPT_LENGTH = 1000; + +export const rateLimitsAtom = atom<{ + rateLimit: number; + rateLimitRemaining: number; +} | null>(null); diff --git a/src/components/TextToExcalidraw/TextToExcalidraw.tsx b/src/components/TextToExcalidraw/TextToExcalidraw.tsx deleted file mode 100644 index 35cf88079..000000000 --- a/src/components/TextToExcalidraw/TextToExcalidraw.tsx +++ /dev/null @@ -1,589 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { t } from "../../i18n"; -import { useApp } from "../App"; -import { Dialog } from "../Dialog"; -import { TextField } from "../TextField"; -import Trans from "../Trans"; -import { - CloseIcon, - RedoIcon, - ZoomInIcon, - ZoomOutIcon, - playerPlayIcon, - playerStopFilledIcon, -} from "../icons"; -import { NonDeletedExcalidrawElement } from "../../element/types"; -import { convertToExcalidrawElements } from "../../data/transform"; -import { exportToCanvas } from "../../packages/utils"; -import { DEFAULT_EXPORT_PADDING } from "../../constants"; -import { canvasToBlob } from "../../data/blob"; - -const testResponse = `{ - "error": false, - "data": [ - { - "type": "ellipse", - "x": 200, - "y": 200, - "width": 100, - "height": 100, - "strokeColor": "transparent", - "backgroundColor": "yellow", - "strokeWidth": 2 - }, - { - "type": "line", - "x": 300, - "y": 250, - "points": [ - [ - 0, - 0 - ], - [ - 70, - 0 - ] - ], - "width": -70, - "height": 0, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 293.30127018922195, - "y": 275, - "points": [ - [ - 0, - 0 - ], - [ - 60.62177826491069, - 35 - ] - ], - "width": -60.62177826491069, - "height": -35, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 275, - "y": 293.30127018922195, - "points": [ - [ - 0, - 0 - ], - [ - 35, - 60.62177826491069 - ] - ], - "width": -35, - "height": -60.62177826491069, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 250, - "y": 300, - "points": [ - [ - 0, - 0 - ], - [ - 0, - 70 - ] - ], - "width": 0, - "height": -70, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 225, - "y": 293.30127018922195, - "points": [ - [ - 0, - 0 - ], - [ - -34.99999999999997, - 60.62177826491069 - ] - ], - "width": -34.99999999999997, - "height": -60.62177826491069, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 206.69872981077805, - "y": 275, - "points": [ - [ - 0, - 0 - ], - [ - -60.62177826491069, - 35 - ] - ], - "width": -60.62177826491069, - "height": -35, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 200, - "y": 250, - "points": [ - [ - 0, - 0 - ], - [ - -70, - 2.842170943040401e-14 - ] - ], - "width": -70, - "height": -2.842170943040401e-14, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 206.69872981077805, - "y": 225, - "points": [ - [ - 0, - 0 - ], - [ - -60.62177826491069, - -34.99999999999997 - ] - ], - "width": -60.62177826491069, - "height": -34.99999999999997, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 224.99999999999997, - "y": 206.69872981077808, - "points": [ - [ - 0, - 0 - ], - [ - -35.00000000000003, - -60.62177826491069 - ] - ], - "width": -35.00000000000003, - "height": -60.62177826491069, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 250, - "y": 200, - "points": [ - [ - 0, - 0 - ], - [ - -2.842170943040401e-14, - -70 - ] - ], - "width": -2.842170943040401e-14, - "height": -70, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 275, - "y": 206.69872981077808, - "points": [ - [ - 0, - 0 - ], - [ - 35, - -60.621778264910716 - ] - ], - "width": -35, - "height": -60.621778264910716, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - }, - { - "type": "line", - "x": 293.3012701892219, - "y": 224.99999999999997, - "points": [ - [ - 0, - 0 - ], - [ - 60.621778264910745, - -35.00000000000003 - ] - ], - "width": -60.621778264910745, - "height": -35.00000000000003, - "strokeColor": "yellow", - "backgroundColor": "transparent", - "strokeWidth": 5 - } - ] -}`; - -async function fetchData( - prompt: string, -): Promise { - const response = await fetch( - `http://localhost:3015/v1/ai/text-to-excalidraw/generate`, - { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ prompt }), - }, - ); - - const result = await response.json(); - - if (result.error) { - alert("Oops!"); - return []; - } - - return convertToExcalidrawElements(result.data); -} - -export const TextToExcalidraw = () => { - const app = useApp(); - - const [prompt, setPrompt] = useState(""); - const [isPanelOpen, setPanelOpen] = useState(false); - const [isLoading, setLoading] = useState(false); - const [data, setData] = useState< - readonly NonDeletedExcalidrawElement[] | null - >(null); - - const [previewCanvas, setPreviewCanvas] = useState( - null, - ); - - const containerRef = useRef(null); - - const onClose = () => { - app.setOpenDialog(null); - }; - - const onSubmit = async () => { - setPanelOpen(true); - setLoading(true); - - const elements = await fetchData(prompt); - - setData(elements); - - const canvas = await exportToCanvas({ - elements, - files: {}, - exportPadding: DEFAULT_EXPORT_PADDING, - }); - - await canvasToBlob(canvas); - - setPreviewCanvas(canvas); - setLoading(false); - }; - - const onInsert = async () => { - if (data) { - app.addElementsFromPasteOrLibrary({ - elements: data, - files: {}, - position: "center", - fitToContent: true, - }); - - onClose(); - } - }; - - useEffect(() => { - if (containerRef.current && previewCanvas) { - containerRef.current.replaceChildren(previewCanvas); - } - }, [previewCanvas]); - - // exportToCanvas([], {}, {}, {}); - // exportToSvg([], {exportBackground}, {}, {}) - - return ( - - - setPrompt(e.target.value)} - type="text" - style={{ - flexGrow: 1, - height: "100%", - boxSizing: "border-box", - border: 0, - outline: "none", - }} - placeholder="How can I help you today?" - /> - - - {CloseIcon} - - - - - - {isLoading ? playerStopFilledIcon : playerPlayIcon} - - - - - {isPanelOpen && ( - - {isLoading ? ( - "loading" - ) : ( - - - - - - {RedoIcon} - - - - - - - {ZoomOutIcon} - - - - - {ZoomInIcon} - - - - - - Insert into scene > - - - - )} - - )} - - ); -}; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 3449ccda0..ba044902d 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1755,3 +1755,13 @@ export const brainIcon = createIcon( , tablerIconProps, ); + +export const drawingIcon = createIcon( + + + + + + , + tablerIconProps, +); diff --git a/src/locales/en.json b/src/locales/en.json index 425893cd4..91b39074e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -134,7 +134,8 @@ "removeAllElementsFromFrame": "Remove all elements from frame", "eyeDropper": "Pick color from canvas", "textToDiagram": "Text to diagram", - "prompt": "Prompt" + "prompt": "Prompt", + "textToDrawing": "Text to drawing" }, "library": { "noItems": "No items added yet...",