From 6ec259a0e71174651bae95d4628138bf6fd68742 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 15 Mar 2026 16:19:35 -0400 Subject: refactor: packages/ --- .../components/TTDDialog/MermaidToExcalidraw.scss | 10 + .../components/TTDDialog/MermaidToExcalidraw.tsx | 132 +++++++ .../excalidraw/components/TTDDialog/TTDDialog.scss | 315 ++++++++++++++++ .../excalidraw/components/TTDDialog/TTDDialog.tsx | 394 +++++++++++++++++++++ .../components/TTDDialog/TTDDialogInput.tsx | 53 +++ .../components/TTDDialog/TTDDialogOutput.tsx | 39 ++ .../components/TTDDialog/TTDDialogPanel.tsx | 63 ++++ .../components/TTDDialog/TTDDialogPanels.tsx | 5 + .../TTDDialog/TTDDialogSubmitShortcut.tsx | 14 + .../components/TTDDialog/TTDDialogTab.tsx | 17 + .../components/TTDDialog/TTDDialogTabTrigger.tsx | 21 ++ .../components/TTDDialog/TTDDialogTabTriggers.tsx | 13 + .../components/TTDDialog/TTDDialogTabs.tsx | 55 +++ .../components/TTDDialog/TTDDialogTrigger.tsx | 35 ++ packages/excalidraw/components/TTDDialog/common.ts | 161 +++++++++ 15 files changed, 1327 insertions(+) create mode 100644 packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.scss create mode 100644 packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialog.scss create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialog.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogInput.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogOutput.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogPanel.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogPanels.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogTab.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogTabTrigger.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogTabTriggers.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx create mode 100644 packages/excalidraw/components/TTDDialog/TTDDialogTrigger.tsx create mode 100644 packages/excalidraw/components/TTDDialog/common.ts (limited to 'packages/excalidraw/components/TTDDialog') 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(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) || + MERMAID_EXAMPLE, + ); + 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(() => { + 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 ( + <> +
+ ( + {el} + )} + sequenceLink={(el) => ( + + {el} + + )} + classLink={(el) => ( + {el} + )} + /> +
+ + + setText(event.target.value)} + onKeyboardSubmit={() => { + onInsertToEditor(); + }} + /> + + { + onInsertToEditor(); + }, + label: t("mermaid.button"), + icon: ArrowRightIcon, + }} + renderSubmitShortcut={() => } + > + + + + + ); +}; +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; + } + | { __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: "text-to-diagram" | "mermaid"; + } & ( + | { + onTextSubmit(value: string): Promise; + } + | { __fallback: true } + )) => { + const app = useApp(); + const setAppState = useExcalidrawSetAppState(); + + const someRandomDivRef = useRef(null); + + const [ttdGeneration, setTtdGeneration] = useAtom(ttdGenerationAtom); + + const [text, setText] = useState(ttdGeneration?.prompt ?? ""); + + const prompt = text.trim(); + + const handleTextChange: ChangeEventHandler = ( + 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({ + 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(null); + + return ( + { + app.setOpenDialog(null); + }} + size={1200} + title={false} + {...rest} + autofocus={false} + > + + {"__fallback" in rest && rest.__fallback ? ( +

{t("mermaid.title")}

+ ) : ( + + +
+ {t("labels.textToDiagram")} +
+ AI Beta +
+
+
+ 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 +
+ ); + }} + renderSubmitShortcut={() => } + renderBottomRight={() => { + if (typeof ttdGeneration?.generatedResponse === "string") { + return ( +
{ + if ( + typeof ttdGeneration?.generatedResponse === + "string" + ) { + saveMermaidDataToStorage( + ttdGeneration.generatedResponse, + ); + setAppState({ + openDialog: { name: "ttd", tab: "mermaid" }, + }); + } + }} + > + View as Mermaid + +
+ ); + } + 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/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; + 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 ( +