Compare commits

...
Author SHA1 Message Date
are e72721e39f feat: sync with upstream changes 2023-11-28 19:00:00 +01:00
are f6ac19bce9 feat: remove extranneous translations 2023-11-28 18:48:54 +01:00
are 530e92189f feat: add text-to-drawing 2023-11-28 18:48:47 +01:00
are 0958241589 feat: initial testing version 2023-11-28 18:41:35 +01:00
Aakansha DoshiandGitHub 42d8c5a040 chore: update changelog and package.json for v0.17.1 (#7351) 2023-11-28 19:12:39 +05:30
Aakansha DoshiandGitHub f299514e44 fix: umd build so it can be used in browser (#7349)
* fix: umd build so it can be used in browser

* fix lint

* increase size limit

* update changelog

* use json.stringify for env preact variable so its accessible as string

* update changelog
2023-11-28 18:11:16 +05:30
David LuzarandGitHub dd220bcaea feat: TTD dialog tweaks (#7346)
* tweaks to TTD dialog ~ prepping for settings dialog

* tweaks to ttd parsing & error logging
2023-11-27 16:03:03 +01:00
David LuzarandGitHub fe75f29c15 fix: disable caching bounds for arrow labels (#7343) 2023-11-25 23:32:05 +01:00
14845a343b feat: text-to-diagram (#7325)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2023-11-25 21:11:03 +00:00
49 changed files with 1928 additions and 594 deletions
+2
View File
@@ -13,6 +13,8 @@ VITE_APP_PORTAL_URL=
VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.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"}' 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! # put these in your .env.local, or make sure you don't commit!
+2
View File
@@ -9,6 +9,8 @@ VITE_APP_PORTAL_URL=https://portal.excalidraw.com
VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.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. # Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow # Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
VITE_APP_WS_SERVER_URL= VITE_APP_WS_SERVER_URL=
+1 -1
View File
@@ -39,7 +39,7 @@ Since Vite removes env variables by default, you can update the vite config to e
``` ```
define: { define: {
"process.env.IS_PREACT": process.env.IS_PREACT, "process.env.IS_PREACT": JSON.stringify("true"),
}, },
``` ```
@@ -93,7 +93,7 @@ Since Vite removes env variables by default, you can update the vite config to e
``` ```
define: { define: {
"process.env.IS_PREACT": process.env.IS_PREACT, "process.env.IS_PREACT": JSON.stringify("true"),
}, },
``` ```
::: :::
+62
View File
@@ -25,6 +25,8 @@ import {
Excalidraw, Excalidraw,
defaultLang, defaultLang,
LiveCollaborationTrigger, LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
} from "../src/packages/excalidraw/index"; } from "../src/packages/excalidraw/index";
import { import {
AppState, AppState,
@@ -102,6 +104,7 @@ import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState"; import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm"; import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../src/components/Trans"; import Trans from "../src/components/Trans";
import { drawingIcon } from "../src/components/icons";
polyfill(); polyfill();
@@ -773,6 +776,65 @@ const ExcalidrawWrapper = () => {
)} )}
</OverwriteConfirmDialog> </OverwriteConfirmDialog>
<AppFooter /> <AppFooter />
<TTDDialog
onTextSubmit={async (input, type) => {
try {
const response = await fetch(
`${import.meta.env.VITE_APP_AI_BACKEND}/v1/ai/${type}/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");
}
}}
/>
<TTDDialogTrigger />
<TTDDialogTrigger tab="text-to-drawing" icon={drawingIcon}>
{t("labels.textToDrawing")}
</TTDDialogTrigger>
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")} {t("alerts.collabOfflineWarning")}
+9 -4
View File
@@ -40,6 +40,7 @@ import {
MagicIcon, MagicIcon,
} from "./icons"; } from "./icons";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@@ -235,6 +236,8 @@ export const ShapesSwitcher = ({
const laserToolSelected = activeTool.type === "laser"; const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable"; const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels();
return ( return (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@@ -338,14 +341,14 @@ export const ShapesSwitcher = ({
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}> <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate Generate
</div> </div>
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setOpenDialog({ name: "mermaid" })} onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
icon={mermaidLogoIcon} icon={mermaidLogoIcon}
data-testid="toolbar-embeddable" data-testid="toolbar-embeddable"
> >
{t("toolBar.mermaidToExcalidraw")} {t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item> </DropdownMenu.Item>
{app.props.aiEnabled !== false && ( {app.props.aiEnabled !== false && (
<> <>
<DropdownMenu.Item <DropdownMenu.Item
@@ -354,13 +357,15 @@ export const ShapesSwitcher = ({
data-testid="toolbar-magicframe" data-testid="toolbar-magicframe"
> >
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
trackEvent("ai", "d2c-settings", "settings"); trackEvent("ai", "open-settings", "d2c");
app.setOpenDialog({ app.setOpenDialog({
name: "magicSettings", name: "settings",
source: "settings", source: "settings",
tab: "diagram-to-code",
}); });
}} }}
icon={OpenAIIcon} icon={OpenAIIcon}
+18 -14
View File
@@ -381,7 +381,6 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer"; import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import { LaserToolOverlay } from "./LaserTool/LaserTool"; import { LaserToolOverlay } from "./LaserTool/LaserTool";
import { LaserPathManager } from "./LaserTool/LaserPathManager"; import { LaserPathManager } from "./LaserTool/LaserPathManager";
import { import {
@@ -1435,9 +1434,6 @@ class App extends React.Component<AppProps, AppState> {
onMagicSettingsConfirm={this.onMagicSettingsConfirm} onMagicSettingsConfirm={this.onMagicSettingsConfirm}
> >
{this.props.children} {this.props.children}
{this.state.openDialog?.name === "mermaid" && (
<MermaidToExcalidraw />
)}
</LayerUI> </LayerUI>
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
@@ -1704,9 +1700,13 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
if (!this.OPENAI_KEY) { if (!this.OPENAI_KEY) {
this.setState({ this.setState({
openDialog: { name: "magicSettings", source: "generation" }, openDialog: {
name: "settings",
tab: "diagram-to-code",
source: "generation",
},
}); });
trackEvent("ai", "d2c-generate", "missing-key"); trackEvent("ai", "generate (missing key)", "d2c");
return; return;
} }
@@ -1719,7 +1719,7 @@ class App extends React.Component<AppProps, AppState> {
if (!magicFrameChildren.length) { if (!magicFrameChildren.length) {
if (source === "button") { if (source === "button") {
this.setState({ errorMessage: "Cannot generate from an empty frame" }); this.setState({ errorMessage: "Cannot generate from an empty frame" });
trackEvent("ai", "d2c-generate", "no-children"); trackEvent("ai", "generate (no-children)", "d2c");
} else { } else {
this.setActiveTool({ type: "magicframe" }); this.setActiveTool({ type: "magicframe" });
} }
@@ -1761,7 +1761,7 @@ class App extends React.Component<AppProps, AppState> {
const textFromFrameChildren = this.getTextFromElements(magicFrameChildren); const textFromFrameChildren = this.getTextFromElements(magicFrameChildren);
trackEvent("ai", "d2c-generate", "generating"); trackEvent("ai", "generate (start)", "d2c");
const result = await diagramToHTML({ const result = await diagramToHTML({
image: dataURL, image: dataURL,
@@ -1771,7 +1771,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
if (!result.ok) { if (!result.ok) {
trackEvent("ai", "d2c-generate", "generating-failed"); trackEvent("ai", "generate (failed)", "d2c");
console.error(result.error); console.error(result.error);
this.updateMagicGeneration({ this.updateMagicGeneration({
frameElement, frameElement,
@@ -1783,7 +1783,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
return; return;
} }
trackEvent("ai", "d2c-generate", "generating-done"); trackEvent("ai", "generate (success)", "d2c");
if (result.choices[0].message.content == null) { if (result.choices[0].message.content == null) {
this.updateMagicGeneration({ this.updateMagicGeneration({
@@ -1875,9 +1875,13 @@ class App extends React.Component<AppProps, AppState> {
public onMagicframeToolSelect = () => { public onMagicframeToolSelect = () => {
if (!this.OPENAI_KEY) { if (!this.OPENAI_KEY) {
this.setState({ this.setState({
openDialog: { name: "magicSettings", source: "tool" }, openDialog: {
name: "settings",
tab: "diagram-to-code",
source: "tool",
},
}); });
trackEvent("ai", "d2c-tool", "missing-key"); trackEvent("ai", "tool-select (missing key)", "d2c");
return; return;
} }
@@ -1887,7 +1891,7 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 0) { if (selectedElements.length === 0) {
this.setActiveTool({ type: TOOL_TYPE.magicframe }); this.setActiveTool({ type: TOOL_TYPE.magicframe });
trackEvent("ai", "d2c-tool", "empty-selection"); trackEvent("ai", "tool-select (empty-selection)", "d2c");
} else { } else {
const selectedMagicFrame: ExcalidrawMagicFrameElement | false = const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -1905,7 +1909,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
trackEvent("ai", "d2c-tool", "existing-selection"); trackEvent("ai", "tool-select (existing selection)", "d2c");
let frame: ExcalidrawMagicFrameElement; let frame: ExcalidrawMagicFrameElement;
if (selectedMagicFrame) { if (selectedMagicFrame) {
+5 -1
View File
@@ -2,7 +2,11 @@ import clsx from "clsx";
import { composeEventHandlers } from "../utils"; import { composeEventHandlers } from "../utils";
import "./Button.scss"; import "./Button.scss";
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> { interface ButtonProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
onSelect: () => any; onSelect: () => any;
/** whether button is in active state */ /** whether button is in active state */
+4 -2
View File
@@ -62,6 +62,7 @@ import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton"; import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
import { MagicSettings } from "./MagicSettings"; import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -396,6 +397,7 @@ const LayerUI = ({
{t("toolBar.library")} {t("toolBar.library")}
</DefaultSidebar.Trigger> </DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog /> <DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage delay={250} />}
@@ -459,14 +461,14 @@ const LayerUI = ({
}} }}
/> />
)} )}
{appState.openDialog?.name === "magicSettings" && ( {appState.openDialog?.name === "settings" && (
<MagicSettings <MagicSettings
openAIKey={openAIKey} openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted} isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange} onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => { onConfirm={(apiKey, shouldPersist) => {
const source = const source =
appState.openDialog?.name === "magicSettings" appState.openDialog?.name === "settings"
? appState.openDialog?.source ? appState.openDialog?.source
: "settings"; : "settings";
setAppState({ openDialog: null }, () => { setAppState({ openDialog: null }, () => {
+9
View File
@@ -1,9 +1,18 @@
.excalidraw { .excalidraw {
.MagicSettings {
.Island {
height: 100%;
display: flex;
flex-direction: column;
}
}
.MagicSettings-confirm { .MagicSettings-confirm {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
.MagicSettings__confirm { .MagicSettings__confirm {
margin-top: 2rem; margin-top: 2rem;
margin-right: auto;
} }
} }
+48 -18
View File
@@ -10,6 +10,8 @@ import { InlineIcon } from "./InlineIcon";
import { Paragraph } from "./Paragraph"; import { Paragraph } from "./Paragraph";
import "./MagicSettings.scss"; import "./MagicSettings.scss";
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
export const MagicSettings = (props: { export const MagicSettings = (props: {
openAIKey: string | null; openAIKey: string | null;
@@ -18,16 +20,21 @@ export const MagicSettings = (props: {
onConfirm: (key: string, shouldPersist: boolean) => void; onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void; onClose: () => void;
}) => { }) => {
const { theme } = useUIAppState();
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || ""); const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>( const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted, props.isPersisted,
); );
const appState = useUIAppState();
const onConfirm = () => { const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist); props.onConfirm(keyInputValue.trim(), shouldPersist);
}; };
if (appState.openDialog?.name !== "settings") {
return null;
}
return ( return (
<Dialog <Dialog
onCloseRequest={() => { onCloseRequest={() => {
@@ -36,7 +43,7 @@ export const MagicSettings = (props: {
}} }}
title={ title={
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
Diagram to Code (AI){" "} Wireframe to Code (AI){" "}
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -46,7 +53,8 @@ export const MagicSettings = (props: {
marginLeft: "1rem", marginLeft: "1rem",
fontSize: 14, fontSize: 14,
borderRadius: "12px", borderRadius: "12px",
background: theme === "light" ? "#FFCCCC" : "#703333", color: "#000",
background: "pink",
}} }}
> >
Experimental Experimental
@@ -56,19 +64,39 @@ export const MagicSettings = (props: {
className="MagicSettings" className="MagicSettings"
autofocus={false} autofocus={false}
> >
<Paragraph {/* <h2
style={{ style={{
display: "inline-flex", margin: 0,
alignItems: "center", fontSize: "1.25rem",
marginBottom: 0, paddingLeft: "2.5rem",
}} }}
> >
For the diagram-to-code feature we use <InlineIcon icon={OpenAIIcon} /> AI Settings
</h2> */}
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
{/* <TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<InlineIcon icon={brainIcon} /> Text to diagram
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="diagram-to-code">
<InlineIcon icon={MagicIcon} /> Wireframe to code
</TTDDialogTabTrigger>
</TTDDialogTabTriggers> */}
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
TODO
</TTDDialogTab> */}
<TTDDialogTab
// className="ttd-dialog-content"
tab="diagram-to-code"
>
<Paragraph>
For the diagram-to-code feature we use{" "}
<InlineIcon icon={OpenAIIcon} />
OpenAI. OpenAI.
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
While the OpenAI API is in beta, its use is strictly limited as such While the OpenAI API is in beta, its use is strictly limited as
we require you use your own API key. You can create an{" "} such we require you use your own API key. You can create an{" "}
<a <a
href="https://platform.openai.com/login?launch" href="https://platform.openai.com/login?launch"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -87,8 +115,8 @@ export const MagicSettings = (props: {
. .
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
Your OpenAI key does not leave the browser, and you can also set your Your OpenAI key does not leave the browser, and you can also set
own limit in your OpenAI account dashboard if needed. your own limit in your OpenAI account dashboard if needed.
</Paragraph> </Paragraph>
<TextField <TextField
isRedacted isRedacted
@@ -103,9 +131,9 @@ export const MagicSettings = (props: {
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()} onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
/> />
<Paragraph> <Paragraph>
By default, your API token is not persisted anywhere so you'll need to By default, your API token is not persisted anywhere so you'll need
insert it again after reload. But, you can persist locally in your to insert it again after reload. But, you can persist locally in
browser below. your browser below.
</Paragraph> </Paragraph>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}> <CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
@@ -114,9 +142,9 @@ export const MagicSettings = (props: {
<Paragraph> <Paragraph>
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "} Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
tool to wrap your elements in a frame that will then allow you to turn tool to wrap your elements in a frame that will then allow you to
it into code. This dialog can be accessed using the <b>AI Settings</b>{" "} turn it into code. This dialog can be accessed using the{" "}
<InlineIcon icon={OpenAIIcon} />. <b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
</Paragraph> </Paragraph>
<FilledButton <FilledButton
@@ -125,6 +153,8 @@ export const MagicSettings = (props: {
label="Confirm" label="Confirm"
onClick={onConfirm} onClick={onConfirm}
/> />
</TTDDialogTab>
</TTDDialogTabs>
</Dialog> </Dialog>
); );
}; };
-221
View File
@@ -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;
}
}
}
}
-243
View File
@@ -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 (
<div data-testid="mermaid-error" className="mermaid-error">
Error! <p>{error}</p>
</div>
);
};
const MermaidToExcalidraw = () => {
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
loaded: boolean;
api: {
parseMermaidToExcalidraw: (
defination: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
} | null;
}>({ loaded: false, api: null });
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState(null);
const canvasRef = useRef<HTMLDivElement>(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 (
<Dialog
className="dialog-mermaid"
onCloseRequest={onClose}
size={1200}
title={
<>
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
<span className="dialog-mermaid-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>
)}
/>
<br />
</span>
</>
}
>
<div className="dialog-mermaid-body">
<div className="dialog-mermaid-panels">
<div className="dialog-mermaid-panels-text">
<label>{t("mermaid.syntax")}</label>
<textarea
onChange={(event) => setText(event.target.value)}
value={text}
/>
</div>
<div className="dialog-mermaid-panels-preview">
<label>{t("mermaid.preview")}</label>
<div className="dialog-mermaid-panels-preview-wrapper">
{error && <ErrorComp error={error} />}
{mermaidToExcalidrawLib.loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="dialog-mermaid-panels-preview-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
</div>
</div>
<div className="dialog-mermaid-buttons">
<Button className="dialog-mermaid-insert" onSelect={onSelect}>
{t("mermaid.button")}
<span>{ArrowRightIcon}</span>
</Button>
</div>
</div>
</Dialog>
);
};
export default MermaidToExcalidraw;
+4 -1
View File
@@ -18,8 +18,11 @@
overflow: auto; overflow: auto;
padding: calc(var(--space-factor) * 10); padding: calc(var(--space-factor) * 10);
display: flex;
flex-direction: column;
.Island { .Island {
padding: 2.5rem !important; padding: 2.5rem;
} }
} }
@@ -0,0 +1,10 @@
.excalidraw {
.dialog-mermaid {
&-title {
margin-block: 0.25rem;
font-size: 1.25rem;
font-weight: 700;
padding-inline: 2.5rem;
}
}
}
@@ -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<Error | null>(null);
const canvasRef = useRef<HTMLDivElement>(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,
mermaidDefinition: 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 (
<>
<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>
)}
/>
</div>
<TTDDialogPanels>
<TTDDialogPanel label={t("mermaid.syntax")}>
<TTDDialogInput
input={text}
placeholder={"Write Mermaid diagram defintion here..."}
onChange={(event) => setText(event.target.value)}
/>
</TTDDialogPanel>
<TTDDialogPanel
label={t("mermaid.preview")}
panelAction={{
action: () => {
insertToEditor({
app,
data: data.current,
text,
shouldSaveMermaidDataToStorage: true,
});
},
label: t("mermaid.button"),
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={canvasRef}
loaded={mermaidToExcalidrawLib.loaded}
error={error}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
export default MermaidToExcalidraw;
+301
View File
@@ -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;
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
import { Dialog } from "../Dialog";
import { useApp } from "../App";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import TTDDialogTabs from "./TTDDialogTabs";
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 { CommonDialogProps, MermaidToExcalidrawLibProps } from "./common";
import "./TTDDialog.scss";
import { TextToDiagram } from "./TextToDiagram";
import { TextToDrawing } from "./TextToDrawing";
export const TTDDialog = (props: CommonDialogProps | { __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" | "text-to-drawing";
} & (CommonDialogProps | { __fallback: true })) => {
const app = useApp();
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
useState<MermaidToExcalidrawLibProps>({
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]);
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: "pink",
color: "#000",
}}
>
AI Beta
</div>
</div>
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="text-to-drawing">
<div style={{ display: "flex", alignItems: "center" }}>
{t("labels.textToDrawing")}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1px 6px",
marginLeft: "10px",
fontSize: 10,
borderRadius: "12px",
background: "pink",
color: "#000",
}}
>
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">
<TextToDiagram
onTextSubmit={rest.onTextSubmit}
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
/>
</TTDDialogTab>
)}
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-drawing">
<TextToDrawing onTextSubmit={rest.onTextSubmit} />
</TTDDialogTab>
)}
</TTDDialogTabs>
</Dialog>
);
},
);
@@ -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<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}
/>
);
};
@@ -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>;
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>
);
};
@@ -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 (
<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>
{renderBottomRight?.()}
</div>
</div>
);
};
@@ -0,0 +1,5 @@
import { ReactNode } from "react";
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
return <div className="ttd-dialog-panels">{children}</div>;
};
+17
View File
@@ -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";
@@ -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";
@@ -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";
@@ -0,0 +1,67 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { ReactNode, useRef } from "react";
import { useExcalidrawSetAppState } from "../App";
import { isMemberOf } from "../../utils";
const TTDDialogTabs = (
props: {
children: ReactNode;
} & (
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" | "text-to-drawing" }
| {
dialog: "settings";
tab: "text-to-diagram" | "diagram-to-code";
}
),
) => {
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 === "settings" &&
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab, source: "settings" },
});
} else if (
props.dialog === "ttd" &&
isMemberOf(["text-to-diagram", "mermaid", "text-to-drawing"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab },
});
}
}}
>
{props.children}
</RadixTabs.Root>
);
};
TTDDialogTabs.displayName = "TTDDialogTabs";
export default TTDDialogTabs;
@@ -0,0 +1,38 @@
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,
tab,
}: {
children?: ReactNode;
icon?: JSX.Element;
tab?: "mermaid" | "text-to-diagram" | "text-to-drawing";
}) => {
const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState();
return (
<TTDDialogTriggerTunnel.In>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "dialog open", "ttd");
setAppState({
openDialog: { name: "ttd", tab: 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";
+228
View File
@@ -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<HTMLDivElement>(null);
const [text, setText] = useState("");
const prompt = text.trim();
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (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<Error | null>(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 (
<>
<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>
);
}}
renderBottomRight={() => {
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: data.current });
},
label: "Insert",
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={someRandomDivRef}
error={error}
loaded={mermaidToExcalidrawLib.loaded}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
+248
View File
@@ -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<HTMLDivElement>(null);
const [text, setText] = useState("");
const prompt = text.trim();
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (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<Error | null>(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 (
<>
<div className="ttd-dialog-desc">This is text to drawing.</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>
);
}}
renderBottomRight={() => {
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: () => {
if (data) {
insertToEditor({
app,
data: {
elements: data,
files: null,
},
});
}
},
label: "Insert",
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={containerRef}
error={error}
loaded={true}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
+193
View File
@@ -0,0 +1,193 @@
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";
import { atom } from "jotai";
export const resetPreview = ({
canvasRef,
setError,
}: {
canvasRef: React.RefObject<HTMLDivElement>;
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 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<OnTestSubmitRetValue>;
}
export interface MermaidToExcalidrawLibProps {
loaded: boolean;
api: Promise<{
parseMermaidToExcalidraw: (
definition: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
}>;
}
interface ConvertMermaidToExcalidrawFormatProps {
canvasRef: React.RefObject<HTMLDivElement>;
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, {
fontSize: DEFAULT_FONT_SIZE,
});
} catch (err: any) {
ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"),
{
fontSize: DEFAULT_FONT_SIZE,
},
);
}
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)
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 (mermaidDefinition) {
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: {
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
};
text?: string;
shouldSaveMermaidDataToStorage?: boolean;
}) => {
const { elements: newElements, files } = data;
if (!newElements.length) {
return;
}
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
app.setOpenDialog(null);
if (shouldSaveMermaidDataToStorage && text) {
saveMermaidDataToStorage(text);
}
};
export const MIN_PROMPT_LENGTH = 3;
export const MAX_PROMPT_LENGTH = 1000;
export const rateLimitsAtom = atom<{
rateLimit: number;
rateLimitRemaining: number;
} | null>(null);
@@ -63,9 +63,13 @@
} }
&__text { &__text {
display: flex;
align-items: center;
width: 100%;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
gap: 0.75rem;
} }
&__shortcut { &__shortcut {
@@ -37,6 +37,32 @@ const DropdownMenuItem = ({
</button> </button>
); );
}; };
DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropDownMenuItemBadge = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div
style={{
display: "inline-flex",
marginLeft: "auto",
padding: "1px 4px",
background: "pink",
borderRadius: 6,
fontSize: 11,
color: "black",
fontFamily: "monospace",
}}
>
{children}
</div>
);
};
DropDownMenuItemBadge.displayName = "DropdownMenuItemBadge";
DropdownMenuItem.Badge = DropDownMenuItemBadge;
export default DropdownMenuItem; export default DropdownMenuItem;
DropdownMenuItem.displayName = "DropdownMenuItem";
+23
View File
@@ -1742,3 +1742,26 @@ export const eyeClosedIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const brainIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" />
<path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" />
<path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" />
<path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" />
<path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" />
<path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" />
</g>,
tablerIconProps,
);
export const drawingIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M20 17v-12c0 -1.121 -.879 -2 -2 -2s-2 .879 -2 2v12l2 2l2 -2z" />
<path d="M16 7h4" />
<path d="M18 19h-13a2 2 0 1 1 0 -4h4a2 2 0 1 0 0 -4h-3" />
</g>,
tablerIconProps,
);
+2
View File
@@ -13,6 +13,7 @@ type TunnelsContextValue = {
DefaultSidebarTriggerTunnel: Tunnel; DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel; DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel; OverwriteConfirmDialogTunnel: Tunnel;
TTDDialogTriggerTunnel: Tunnel;
jotaiScope: symbol; jotaiScope: symbol;
}; };
@@ -32,6 +33,7 @@ export const useInitializeTunnels = () => {
DefaultSidebarTriggerTunnel: tunnel(), DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(), DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(), OverwriteConfirmDialogTunnel: tunnel(),
TTDDialogTriggerTunnel: tunnel(),
jotaiScope: Symbol(), jotaiScope: Symbol(),
}; };
}, []); }, []);
+14 -4
View File
@@ -39,6 +39,7 @@
button { button {
cursor: pointer; cursor: pointer;
user-select: none;
} }
&:focus { &:focus {
@@ -651,6 +652,19 @@
--button-bg: var(--color-surface-high); --button-bg: var(--color-surface-high);
} }
} }
.excalidraw__paragraph {
margin: 1rem 0;
}
.Modal__content {
.excalidraw__paragraph:first-child {
margin-top: 0;
}
.excalidraw__paragraph + .excalidraw__paragraph {
margin-top: 0rem;
}
}
} }
.ErrorSplash.excalidraw { .ErrorSplash.excalidraw {
@@ -734,8 +748,4 @@
letter-spacing: 0.6px; letter-spacing: 0.6px;
font-family: "Assistant"; font-family: "Assistant";
} }
.excalidraw__paragraph {
margin: 1rem 0;
}
} }
+8 -1
View File
@@ -13,6 +13,7 @@ import { Point } from "../types";
import { generateRoughOptions } from "../scene/Shape"; import { generateRoughOptions } from "../scene/Shape";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
@@ -54,7 +55,13 @@ export class ElementBounds {
static getBounds(element: ExcalidrawElement) { static getBounds(element: ExcalidrawElement) {
const cachedBounds = ElementBounds.boundsCache.get(element); const cachedBounds = ElementBounds.boundsCache.get(element);
if (cachedBounds?.version && cachedBounds.version === element.version) { if (
cachedBounds?.version &&
cachedBounds.version === element.version &&
// we don't invalidate cache when we update containers and not labels,
// which is causing problems down the line. Fix TBA.
!isBoundToContainer(element)
) {
return cachedBounds.bounds; return cachedBounds.bounds;
} }
+4 -1
View File
@@ -132,7 +132,10 @@
"sidebarLock": "Keep sidebar open", "sidebarLock": "Keep sidebar open",
"selectAllElementsInFrame": "Select all elements in frame", "selectAllElementsInFrame": "Select all elements in frame",
"removeAllElementsFromFrame": "Remove all elements from frame", "removeAllElementsFromFrame": "Remove all elements from frame",
"eyeDropper": "Pick color from canvas" "eyeDropper": "Pick color from canvas",
"textToDiagram": "Text to diagram",
"prompt": "Prompt",
"textToDrawing": "Text to drawing"
}, },
"library": { "library": {
"noItems": "No items added yet...", "noItems": "No items added yet...",
+1 -1
View File
@@ -1,7 +1,7 @@
[ [
{ {
"path": "dist/excalidraw.production.min.js", "path": "dist/excalidraw.production.min.js",
"limit": "325 kB" "limit": "335 kB"
}, },
{ {
"path": "dist/excalidraw-assets/locales", "path": "dist/excalidraw-assets/locales",
+20
View File
@@ -17,6 +17,26 @@ Please add the latest change on the top under the correct section.
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336) - `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
## 0.17.1 (2023-11-28)
### Fixes
- Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean.
```
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
}
```
## Excalidraw Library
### Fixes
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
---
## 0.17.0 (2023-11-14) ## 0.17.0 (2023-11-14)
### Features ### Features
+17 -1
View File
@@ -76,6 +76,8 @@ const {
MainMenu, MainMenu,
LiveCollaborationTrigger, LiveCollaborationTrigger,
convertToExcalidrawElements, convertToExcalidrawElements,
TTDDialog,
TTDDialogTrigger,
} = window.ExcalidrawLib; } = window.ExcalidrawLib;
const COMMENT_ICON_DIMENSION = 32; const COMMENT_ICON_DIMENSION = 32;
@@ -681,7 +683,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
} }
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
onChange={(elements, state) => { onChange={(elements, state) => {
console.info("Elements :", elements, "State : ", state); // console.info("Elements :", elements, "State : ", state);
}} }}
onPointerUpdate={(payload: { onPointerUpdate={(payload: {
pointer: { x: number; y: number }; pointer: { x: number; y: number };
@@ -737,6 +739,20 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
Toggle Custom Sidebar Toggle Custom Sidebar
</Sidebar.Trigger> </Sidebar.Trigger>
{renderMenu()} {renderMenu()}
{excalidrawAPI && (
<TTDDialogTrigger icon={<span>😀</span>}>
Text to diagram
</TTDDialogTrigger>
)}
<TTDDialog
onTextSubmit={async (_) => {
console.info("submit");
// sleep for 2s
await new Promise((resolve) => setTimeout(resolve, 2000));
throw new Error("error, go away now");
// return "dummy";
}}
/>
</Excalidraw> </Excalidraw>
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()} {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
{comment && renderComment()} {comment && renderComment()}
+2
View File
@@ -246,6 +246,8 @@ export { WelcomeScreen };
export { LiveCollaborationTrigger }; export { LiveCollaborationTrigger };
export { DefaultSidebar } from "../../components/DefaultSidebar"; export { DefaultSidebar } from "../../components/DefaultSidebar";
export { TTDDialog } from "../../components/TTDDialog/TTDDialog";
export { TTDDialogTrigger } from "../../components/TTDDialog/TTDDialogTrigger";
export { normalizeLink } from "../../data/url"; export { normalizeLink } from "../../data/url";
export { convertToExcalidrawElements } from "../../data/transform"; export { convertToExcalidrawElements } from "../../data/transform";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@excalidraw/excalidraw", "name": "@excalidraw/excalidraw",
"version": "0.17.0", "version": "0.17.1",
"main": "main.js", "main": "main.js",
"types": "types/packages/excalidraw/index.d.ts", "types": "types/packages/excalidraw/index.d.ts",
"files": [ "files": [
@@ -1,5 +1,3 @@
const { merge } = require("webpack-merge");
const prodConfig = require("./webpack.prod.config"); const prodConfig = require("./webpack.prod.config");
const devConfig = require("./webpack.dev.config"); const devConfig = require("./webpack.dev.config");
@@ -11,6 +9,7 @@ const outputFile = isProd
: "excalidraw-with-preact.development"; : "excalidraw-with-preact.development";
const preactWebpackConfig = { const preactWebpackConfig = {
...config,
entry: { entry: {
[outputFile]: "./entry.js", [outputFile]: "./entry.js",
}, },
@@ -30,4 +29,4 @@ const preactWebpackConfig = {
}, },
}, },
}; };
module.exports = merge(config, preactWebpackConfig); module.exports = preactWebpackConfig;
+13 -19
View File
@@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
<Excalidraw <Excalidraw
initialData={{ initialData={{
appState: { appState: {
openDialog: { name: "mermaid" }, openDialog: { name: "ttd", tab: "mermaid" },
}, },
}} }}
/>, />,
@@ -110,16 +110,16 @@ describe("Test <MermaidToExcalidraw/>", () => {
}); });
it("should open mermaid popup when active tool is mermaid", async () => { 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")); await waitFor(() => dialog.querySelector("canvas"));
expect(dialog.outerHTML).toMatchSnapshot(); expect(dialog.outerHTML).toMatchSnapshot();
}); });
it("should close the popup and set the tool to selection when close button clicked", () => { 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")!; const closeBtn = dialog.querySelector(".Dialog__close")!;
fireEvent.click(closeBtn); fireEvent.click(closeBtn);
expect(document.querySelector(".dialog-mermaid")).toBe(null); expect(document.querySelector(".ttd-dialog")).toBe(null);
expect(window.h.state.activeTool).toStrictEqual({ expect(window.h.state.activeTool).toStrictEqual({
customType: null, customType: null,
lastActiveTool: null, lastActiveTool: null,
@@ -129,9 +129,12 @@ describe("Test <MermaidToExcalidraw/>", () => {
}); });
it("should show error in preview when mermaid library throws error", async () => { it("should show error in preview when mermaid library throws error", async () => {
const dialog = document.querySelector(".dialog-mermaid")!; const dialog = document.querySelector(".ttd-dialog")!;
const selector = ".dialog-mermaid-panels-text textarea";
let editor = await getTextEditor(selector, false); expect(dialog).not.toBeNull();
const selector = ".ttd-dialog-input";
let editor = await getTextEditor(selector, true);
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull(); expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
@@ -151,17 +154,8 @@ describe("Test <MermaidToExcalidraw/>", () => {
editor = await getTextEditor(selector, false); editor = await getTextEditor(selector, false);
expect(editor.textContent).toBe("flowchart TD1"); expect(editor.textContent).toBe("flowchart TD1");
expect(dialog.querySelector('[data-testid="mermaid-error"]')) expect(
.toMatchInlineSnapshot(` dialog.querySelector('[data-testid="mermaid-error"]'),
<div ).toMatchInlineSnapshot("null");
class="mermaid-error"
data-testid="mermaid-error"
>
Error!
<p>
ERROR
</p>
</div>
`);
}); });
}); });
@@ -1,10 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = ` exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
"<div class=\\"Modal Dialog dialog-mermaid\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 1200px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><h2 id=\\"test-id-dialog-title\\" class=\\"Dialog__title\\"><span class=\\"Dialog__titleContent\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><span class=\\"dialog-mermaid-desc\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">Flowcharts</a> and <a href=\\"https://mermaid.js.org/syntax/sequenceDiagram.html\\">Sequence Diagrams</a> are supported. The other types will be rendered as image in Excalidraw.<br></span></span></h2><button class=\\"Dialog__close\\" title=\\"Close\\" aria-label=\\"Close\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g clip-path=\\"url(#a)\\" stroke=\\"currentColor\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><path d=\\"M15 5 5 15M5 5l10 10\\"></path></g><defs><clipPath id=\\"a\\"><path fill=\\"#fff\\" d=\\"M0 0h20v20H0z\\"></path></clipPath></defs></svg></button><div class=\\"Dialog__content\\"><div class=\\"dialog-mermaid-body\\"><div class=\\"dialog-mermaid-panels\\"><div class=\\"dialog-mermaid-panels-text\\"><label>Mermaid Syntax</label><textarea>flowchart TD "<div class=\\"Modal Dialog ttd-dialog\\" role=\\"dialog\\" aria-modal=\\"true\\" aria-labelledby=\\"dialog-title\\" data-prevent-outside-click=\\"true\\"><div class=\\"Modal__background\\"></div><div class=\\"Modal__content\\" style=\\"--max-width: 1200px;\\" tabindex=\\"0\\"><div class=\\"Island\\"><button class=\\"Dialog__close\\" title=\\"Close\\" aria-label=\\"Close\\"><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g clip-path=\\"url(#a)\\" stroke=\\"currentColor\\" stroke-width=\\"1.25\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><path d=\\"M15 5 5 15M5 5l10 10\\"></path></g><defs><clipPath id=\\"a\\"><path fill=\\"#fff\\" d=\\"M0 0h20v20H0z\\"></path></clipPath></defs></svg></button><div class=\\"Dialog__content\\"><div dir=\\"ltr\\" data-orientation=\\"horizontal\\" class=\\"ttd-dialog-tabs-root\\"><p class=\\"dialog-mermaid-title\\">Mermaid to Excalidraw</p><div data-state=\\"active\\" data-orientation=\\"horizontal\\" role=\\"tabpanel\\" aria-labelledby=\\"radix-:r0:-trigger-mermaid\\" id=\\"radix-:r0:-content-mermaid\\" tabindex=\\"0\\" class=\\"ttd-dialog-content\\" style=\\"animation-duration: 0s;\\"><div class=\\"ttd-dialog-desc\\">Currently only <a href=\\"https://mermaid.js.org/syntax/flowchart.html\\">Flowcharts</a> and <a href=\\"https://mermaid.js.org/syntax/sequenceDiagram.html\\">Sequence Diagrams</a> are supported. The other types will be rendered as image in Excalidraw.</div><div class=\\"ttd-dialog-panels\\"><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Mermaid Syntax</label></div><textarea class=\\"ttd-dialog-input\\" placeholder=\\"Write Mermaid diagram defintion here...\\">flowchart TD
A[Christmas] --&gt;|Get money| B(Go shopping) A[Christmas] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think} B --&gt; C{Let me think}
C --&gt;|One| D[Laptop] C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone] C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea></div><div class=\\"dialog-mermaid-panels-preview\\"><label>Preview</label><div class=\\"dialog-mermaid-panels-preview-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"dialog-mermaid-panels-preview-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div></div></div><div class=\\"dialog-mermaid-buttons\\"><button type=\\"button\\" class=\\"excalidraw-button dialog-mermaid-insert\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></button></div></div></div></div></div></div>" C --&gt;|Three| F[Car]</textarea><div class=\\"ttd-dialog-panel-button-container invisible\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\"></div></button></div></div><div class=\\"ttd-dialog-panel\\"><div class=\\"ttd-dialog-panel__header\\"><label>Preview</label></div><div class=\\"ttd-dialog-output-wrapper\\"><div style=\\"opacity: 1;\\" class=\\"ttd-dialog-output-canvas-container\\"><canvas width=\\"89\\" height=\\"158\\" dir=\\"ltr\\"></canvas></div></div><div class=\\"ttd-dialog-panel-button-container\\" style=\\"display: flex; align-items: center;\\"><button type=\\"button\\" class=\\"excalidraw-button ttd-dialog-panel-button\\"><div class=\\"\\">Insert<span><svg aria-hidden=\\"true\\" focusable=\\"false\\" role=\\"img\\" viewBox=\\"0 0 20 20\\" class=\\"\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><g stroke-width=\\"1.25\\"><path d=\\"M4.16602 10H15.8327\\"></path><path d=\\"M12.5 13.3333L15.8333 10\\"></path><path d=\\"M12.5 6.66666L15.8333 9.99999\\"></path></g></svg></span></div></button></div></div></div></div></div></div></div></div></div>"
`; `;
+4 -4
View File
@@ -273,7 +273,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(13); expect(renderInteractiveScene).toHaveBeenCalledTimes(14);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
@@ -416,7 +416,7 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[1] + delta, lastSegmentMidpoint[1] + delta,
]); ]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19); expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -519,7 +519,7 @@ describe("Test Linear Elements", () => {
// delete 3rd point // delete 3rd point
deletePoint(points[2]); deletePoint(points[2]);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(renderInteractiveScene).toHaveBeenCalledTimes(20); expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(renderStaticScene).toHaveBeenCalledTimes(9);
const newMidPoints = LinearElementEditor.getEditorMidPoints( const newMidPoints = LinearElementEditor.getEditorMidPoints(
@@ -566,7 +566,7 @@ describe("Test Linear Elements", () => {
lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[0] + delta,
lastSegmentMidpoint[1] + delta, lastSegmentMidpoint[1] + delta,
]); ]);
expect(renderInteractiveScene).toHaveBeenCalledTimes(19); expect(renderInteractiveScene).toHaveBeenCalledTimes(21);
expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
+5 -3
View File
@@ -246,14 +246,16 @@ export interface AppState {
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null; openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog: openDialog:
| null | null
| { name: "imageExport" | "help" | "jsonExport" | "mermaid" } | { name: "imageExport" | "help" | "jsonExport" }
| { | {
name: "magicSettings"; name: "settings";
source: source:
| "tool" // when magicframe tool is selected | "tool" // when magicframe tool is selected
| "generation" // when magicframe generate button is clicked | "generation" // when magicframe generate button is clicked
| "settings"; // when AI settings dialog is explicitly invoked | "settings"; // when AI settings dialog is explicitly invoked
}; tab: "text-to-diagram" | "diagram-to-code";
}
| { name: "ttd"; tab: "text-to-diagram" | "mermaid" | "text-to-drawing" };
/** /**
* Reflects user preference for whether the default sidebar should be docked. * Reflects user preference for whether the default sidebar should be docked.
* *
+4
View File
@@ -925,3 +925,7 @@ export const isMemberOf = <T extends string>(
}; };
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)); export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
export const isFiniteNumber = (value: any): value is number => {
return typeof value === "number" && Number.isFinite(value);
};
+1
View File
@@ -17,6 +17,7 @@ interface ImportMetaEnv {
// set this only if using the collaboration workflow we use on excalidraw.com // set this only if using the collaboration workflow we use on excalidraw.com
VITE_APP_PORTAL_URL: string; VITE_APP_PORTAL_URL: string;
VITE_APP_AI_BACKEND: string;
VITE_APP_FIREBASE_CONFIG: string; VITE_APP_FIREBASE_CONFIG: string;