diff options
Diffstat (limited to 'packages/excalidraw/components/TTDDialog')
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); + } +}; |
