feat: text-to-diagram (#7325)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
dd8a7d41e2
commit
14845a343b
@ -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!
|
||||||
|
@ -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=
|
||||||
|
@ -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,
|
||||||
@ -773,6 +775,64 @@ const ExcalidrawWrapper = () => {
|
|||||||
)}
|
)}
|
||||||
</OverwriteConfirmDialog>
|
</OverwriteConfirmDialog>
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
<TTDDialog
|
||||||
|
onTextSubmit={async (input) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_AI_BACKEND
|
||||||
|
}/v1/ai/text-to-diagram/generate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ prompt: input }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||||
|
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const rateLimitRemaining = response.headers.has(
|
||||||
|
"X-Ratelimit-Remaining",
|
||||||
|
)
|
||||||
|
? parseInt(
|
||||||
|
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
return {
|
||||||
|
rateLimit,
|
||||||
|
rateLimitRemaining,
|
||||||
|
error: new Error(
|
||||||
|
"Too many requests today, please try again tomorrow!",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(json.message || "Generation failed...");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedResponse = json.generatedResponse;
|
||||||
|
if (!generatedResponse) {
|
||||||
|
throw new Error("Generation failed...");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error("Request failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TTDDialogTrigger />
|
||||||
{isCollaborating && isOffline && (
|
{isCollaborating && isOffline && (
|
||||||
<div className="collab-offline-warning">
|
<div className="collab-offline-warning">
|
||||||
{t("alerts.collabOfflineWarning")}
|
{t("alerts.collabOfflineWarning")}
|
||||||
|
@ -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,10 +357,11 @@ 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: "magicSettings",
|
||||||
source: "settings",
|
source: "settings",
|
||||||
|
@ -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" />
|
||||||
@ -1706,7 +1702,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
openDialog: { name: "magicSettings", source: "generation" },
|
openDialog: { name: "magicSettings", source: "generation" },
|
||||||
});
|
});
|
||||||
trackEvent("ai", "d2c-generate", "missing-key");
|
trackEvent("ai", "generate (missing key)", "d2c");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1719,7 +1715,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 +1757,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 +1767,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 +1779,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({
|
||||||
@ -1877,7 +1873,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
openDialog: { name: "magicSettings", source: "tool" },
|
openDialog: { name: "magicSettings", source: "tool" },
|
||||||
});
|
});
|
||||||
trackEvent("ai", "d2c-tool", "missing-key");
|
trackEvent("ai", "tool-select (missing key)", "d2c");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1887,7 +1883,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 +1901,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) {
|
||||||
|
@ -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 */
|
||||||
|
@ -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} />}
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
10
src/components/TTDDialog/MermaidToExcalidraw.scss
Normal file
10
src/components/TTDDialog/MermaidToExcalidraw.scss
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.dialog-mermaid {
|
||||||
|
&-title {
|
||||||
|
margin-block: 0.25rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding-inline: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
133
src/components/TTDDialog/MermaidToExcalidraw.tsx
Normal file
133
src/components/TTDDialog/MermaidToExcalidraw.tsx
Normal file
@ -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,
|
||||||
|
text: deferredText,
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [deferredText, mermaidToExcalidrawLib]);
|
||||||
|
|
||||||
|
const textRef = useRef(text);
|
||||||
|
|
||||||
|
// slightly hacky but really quite simple
|
||||||
|
// essentially, we want to save the text to LS when the component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
textRef.current = text;
|
||||||
|
}, [text]);
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (textRef.current) {
|
||||||
|
saveMermaidDataToStorage(textRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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,
|
||||||
|
text,
|
||||||
|
shouldSaveMermaidDataToStorage: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
label: t("mermaid.button"),
|
||||||
|
icon: ArrowRightIcon,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TTDDialogOutput
|
||||||
|
canvasRef={canvasRef}
|
||||||
|
loaded={mermaidToExcalidrawLib.loaded}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
</TTDDialogPanel>
|
||||||
|
</TTDDialogPanels>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default MermaidToExcalidraw;
|
301
src/components/TTDDialog/TTDDialog.scss
Normal file
301
src/components/TTDDialog/TTDDialog.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
325
src/components/TTDDialog/TTDDialog.tsx
Normal file
325
src/components/TTDDialog/TTDDialog.tsx
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
import { Dialog } from "../Dialog";
|
||||||
|
import { useApp } from "../App";
|
||||||
|
import MermaidToExcalidraw from "./MermaidToExcalidraw";
|
||||||
|
import TTDDialogTabs from "./TTDDialogTabs";
|
||||||
|
import { ChangeEventHandler, useEffect, useRef, useState } from "react";
|
||||||
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
|
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||||
|
import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
|
||||||
|
import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
|
||||||
|
import { TTDDialogTab } from "./TTDDialogTab";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { TTDDialogInput } from "./TTDDialogInput";
|
||||||
|
import { TTDDialogOutput } from "./TTDDialogOutput";
|
||||||
|
import { TTDDialogPanel } from "./TTDDialogPanel";
|
||||||
|
import { TTDDialogPanels } from "./TTDDialogPanels";
|
||||||
|
import {
|
||||||
|
MermaidToExcalidrawLibProps,
|
||||||
|
convertMermaidToExcalidraw,
|
||||||
|
insertToEditor,
|
||||||
|
saveMermaidDataToStorage,
|
||||||
|
} from "./common";
|
||||||
|
import { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
|
import { BinaryFiles } from "../../types";
|
||||||
|
import { ArrowRightIcon } from "../icons";
|
||||||
|
|
||||||
|
import "./TTDDialog.scss";
|
||||||
|
import { isFiniteNumber } from "../../utils";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { trackEvent } from "../../analytics";
|
||||||
|
|
||||||
|
const MIN_PROMPT_LENGTH = 3;
|
||||||
|
const MAX_PROMPT_LENGTH = 1000;
|
||||||
|
|
||||||
|
const rateLimitsAtom = atom<{
|
||||||
|
rateLimit: number;
|
||||||
|
rateLimitRemaining: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
type OnTestSubmitRetValue = {
|
||||||
|
rateLimit?: number | null;
|
||||||
|
rateLimitRemaining?: number | null;
|
||||||
|
} & (
|
||||||
|
| { generatedResponse: string | undefined; error?: null | undefined }
|
||||||
|
| {
|
||||||
|
error: Error;
|
||||||
|
generatedResponse?: null | undefined;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TTDDialog = (
|
||||||
|
props:
|
||||||
|
| {
|
||||||
|
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
|
||||||
|
}
|
||||||
|
| { __fallback: true },
|
||||||
|
) => {
|
||||||
|
const appState = useUIAppState();
|
||||||
|
|
||||||
|
if (appState.openDialog?.name !== "ttd") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TTDDialogBase {...props} tab={appState.openDialog.tab} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text to diagram (TTD) dialog
|
||||||
|
*/
|
||||||
|
export const TTDDialogBase = withInternalFallback(
|
||||||
|
"TTDDialogBase",
|
||||||
|
({
|
||||||
|
tab,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
tab: string;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
onTextSubmit(value: string): Promise<OnTestSubmitRetValue>;
|
||||||
|
}
|
||||||
|
| { __fallback: true }
|
||||||
|
)) => {
|
||||||
|
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 onGenerate = async () => {
|
||||||
|
if (
|
||||||
|
prompt.length > MAX_PROMPT_LENGTH ||
|
||||||
|
prompt.length < MIN_PROMPT_LENGTH ||
|
||||||
|
onTextSubmitInProgess ||
|
||||||
|
rateLimits?.rateLimitRemaining === 0 ||
|
||||||
|
// means this is not a text-to-diagram dialog (needed for TS only)
|
||||||
|
"__fallback" in rest
|
||||||
|
) {
|
||||||
|
if (prompt.length < MIN_PROMPT_LENGTH) {
|
||||||
|
setError(
|
||||||
|
new Error(
|
||||||
|
`Prompt is too short (min ${MIN_PROMPT_LENGTH} characters)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (prompt.length > MAX_PROMPT_LENGTH) {
|
||||||
|
setError(
|
||||||
|
new Error(
|
||||||
|
`Prompt is too long (max ${MAX_PROMPT_LENGTH} characters)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setOnTextSubmitInProgess(true);
|
||||||
|
|
||||||
|
trackEvent("ai", "generate", "ttd");
|
||||||
|
|
||||||
|
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
|
||||||
|
await rest.onTextSubmit(prompt);
|
||||||
|
|
||||||
|
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
|
||||||
|
setRateLimits({ rateLimit, rateLimitRemaining });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!generatedResponse) {
|
||||||
|
setError(new Error("Generation failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await convertMermaidToExcalidraw({
|
||||||
|
canvasRef: someRandomDivRef,
|
||||||
|
data,
|
||||||
|
mermaidToExcalidrawLib,
|
||||||
|
setError,
|
||||||
|
text: generatedResponse,
|
||||||
|
});
|
||||||
|
trackEvent("ai", "mermaid parse success", "ttd");
|
||||||
|
saveMermaidDataToStorage(generatedResponse);
|
||||||
|
} catch (error: any) {
|
||||||
|
trackEvent("ai", "mermaid parse failed", "ttd");
|
||||||
|
setError(
|
||||||
|
new Error(
|
||||||
|
"Generated an invalid diagram :(. You may also try a different prompt.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
let message: string | undefined = error.message;
|
||||||
|
if (!message || message === "Failed to fetch") {
|
||||||
|
message = "Request failed";
|
||||||
|
}
|
||||||
|
setError(new Error(message));
|
||||||
|
} finally {
|
||||||
|
setOnTextSubmitInProgess(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refOnGenerate = useRef(onGenerate);
|
||||||
|
refOnGenerate.current = onGenerate;
|
||||||
|
|
||||||
|
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
|
||||||
|
useState<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]);
|
||||||
|
|
||||||
|
const data = useRef<{
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles | null;
|
||||||
|
}>({ elements: [], files: null });
|
||||||
|
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
className="ttd-dialog"
|
||||||
|
onCloseRequest={() => {
|
||||||
|
app.setOpenDialog(null);
|
||||||
|
}}
|
||||||
|
size={1200}
|
||||||
|
title=""
|
||||||
|
{...rest}
|
||||||
|
autofocus={false}
|
||||||
|
>
|
||||||
|
<TTDDialogTabs tab={tab}>
|
||||||
|
{"__fallback" in rest && rest.__fallback ? (
|
||||||
|
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
|
||||||
|
) : (
|
||||||
|
<TTDDialogTabTriggers>
|
||||||
|
<TTDDialogTabTrigger tab="text-to-diagram">
|
||||||
|
{t("labels.textToDiagram")}
|
||||||
|
</TTDDialogTabTrigger>
|
||||||
|
<TTDDialogTabTrigger tab="mermaid">Mermaid</TTDDialogTabTrigger>
|
||||||
|
</TTDDialogTabTriggers>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TTDDialogTab className="ttd-dialog-content" tab="mermaid">
|
||||||
|
<MermaidToExcalidraw
|
||||||
|
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
|
||||||
|
/>
|
||||||
|
</TTDDialogTab>
|
||||||
|
{!("__fallback" in rest) && (
|
||||||
|
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
|
||||||
|
<div className="ttd-dialog-desc">
|
||||||
|
Currently we use Mermaid as a middle step, so you'll get best
|
||||||
|
results if you describe a diagram, workflow, flow chart, and
|
||||||
|
similar.
|
||||||
|
</div>
|
||||||
|
<TTDDialogPanels>
|
||||||
|
<TTDDialogPanel
|
||||||
|
label={t("labels.prompt")}
|
||||||
|
panelAction={{
|
||||||
|
action: onGenerate,
|
||||||
|
label: "Generate",
|
||||||
|
icon: ArrowRightIcon,
|
||||||
|
}}
|
||||||
|
onTextSubmitInProgess={onTextSubmitInProgess}
|
||||||
|
panelActionDisabled={
|
||||||
|
prompt.length > MAX_PROMPT_LENGTH ||
|
||||||
|
rateLimits?.rateLimitRemaining === 0
|
||||||
|
}
|
||||||
|
renderTopRight={() => {
|
||||||
|
if (!rateLimits) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="ttd-dialog-rate-limit"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: "auto",
|
||||||
|
color:
|
||||||
|
rateLimits.rateLimitRemaining === 0
|
||||||
|
? "var(--color-danger)"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rateLimits.rateLimitRemaining} requests left today
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
label: "Insert",
|
||||||
|
icon: ArrowRightIcon,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TTDDialogOutput
|
||||||
|
canvasRef={someRandomDivRef}
|
||||||
|
error={error}
|
||||||
|
loaded={mermaidToExcalidrawLib.loaded}
|
||||||
|
/>
|
||||||
|
</TTDDialogPanel>
|
||||||
|
</TTDDialogPanels>
|
||||||
|
</TTDDialogTab>
|
||||||
|
)}
|
||||||
|
</TTDDialogTabs>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
52
src/components/TTDDialog/TTDDialogInput.tsx
Normal file
52
src/components/TTDDialog/TTDDialogInput.tsx
Normal file
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
39
src/components/TTDDialog/TTDDialogOutput.tsx
Normal file
39
src/components/TTDDialog/TTDDialogOutput.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
};
|
58
src/components/TTDDialog/TTDDialogPanel.tsx
Normal file
58
src/components/TTDDialog/TTDDialogPanel.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
};
|
5
src/components/TTDDialog/TTDDialogPanels.tsx
Normal file
5
src/components/TTDDialog/TTDDialogPanels.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
|
||||||
|
return <div className="ttd-dialog-panels">{children}</div>;
|
||||||
|
};
|
17
src/components/TTDDialog/TTDDialogTab.tsx
Normal file
17
src/components/TTDDialog/TTDDialogTab.tsx
Normal 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";
|
21
src/components/TTDDialog/TTDDialogTabTrigger.tsx
Normal file
21
src/components/TTDDialog/TTDDialogTabTrigger.tsx
Normal file
@ -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";
|
13
src/components/TTDDialog/TTDDialogTabTriggers.tsx
Normal file
13
src/components/TTDDialog/TTDDialogTabTriggers.tsx
Normal file
@ -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";
|
38
src/components/TTDDialog/TTDDialogTabs.tsx
Normal file
38
src/components/TTDDialog/TTDDialogTabs.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { useExcalidrawSetAppState } from "../App";
|
||||||
|
|
||||||
|
const TTDDialogTabs = ({
|
||||||
|
children,
|
||||||
|
tab,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
tab: string;
|
||||||
|
}) => {
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadixTabs.Root
|
||||||
|
className="ttd-dialog-tabs-root"
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(
|
||||||
|
// at least in test enviros, `tab` can be `undefined`
|
||||||
|
tab: string | undefined,
|
||||||
|
) => {
|
||||||
|
if (tab) {
|
||||||
|
setAppState({
|
||||||
|
openDialog: { name: "ttd", tab },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RadixTabs.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TTDDialogTabs.displayName = "TTDDialogTabs";
|
||||||
|
|
||||||
|
export default TTDDialogTabs;
|
34
src/components/TTDDialog/TTDDialogTrigger.tsx
Normal file
34
src/components/TTDDialog/TTDDialogTrigger.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { useTunnels } from "../../context/tunnels";
|
||||||
|
import DropdownMenu from "../dropdownMenu/DropdownMenu";
|
||||||
|
import { useExcalidrawSetAppState } from "../App";
|
||||||
|
import { brainIcon } from "../icons";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { trackEvent } from "../../analytics";
|
||||||
|
|
||||||
|
export const TTDDialogTrigger = ({
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
}) => {
|
||||||
|
const { TTDDialogTriggerTunnel } = useTunnels();
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TTDDialogTriggerTunnel.In>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => {
|
||||||
|
trackEvent("ai", "dialog open", "ttd");
|
||||||
|
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
|
||||||
|
}}
|
||||||
|
icon={icon ?? brainIcon}
|
||||||
|
>
|
||||||
|
{children ?? t("labels.textToDiagram")}
|
||||||
|
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</TTDDialogTriggerTunnel.In>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
TTDDialogTrigger.displayName = "TTDDialogTrigger";
|
153
src/components/TTDDialog/common.ts
Normal file
153
src/components/TTDDialog/common.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
|
||||||
|
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
|
||||||
|
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../../constants";
|
||||||
|
import {
|
||||||
|
convertToExcalidrawElements,
|
||||||
|
exportToCanvas,
|
||||||
|
} from "../../packages/excalidraw/index";
|
||||||
|
import { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
|
import { AppClassProperties, BinaryFiles } from "../../types";
|
||||||
|
import { canvasToBlob } from "../../data/blob";
|
||||||
|
|
||||||
|
const resetPreview = ({
|
||||||
|
canvasRef,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
canvasRef: React.RefObject<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 interface MermaidToExcalidrawLibProps {
|
||||||
|
loaded: boolean;
|
||||||
|
api: Promise<{
|
||||||
|
parseMermaidToExcalidraw: (
|
||||||
|
definition: string,
|
||||||
|
options: MermaidOptions,
|
||||||
|
) => Promise<MermaidToExcalidrawResult>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConvertMermaidToExcalidrawFormatProps {
|
||||||
|
canvasRef: React.RefObject<HTMLDivElement>;
|
||||||
|
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
|
||||||
|
text: string;
|
||||||
|
setError: (error: Error | null) => void;
|
||||||
|
data: React.MutableRefObject<{
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const convertMermaidToExcalidraw = async ({
|
||||||
|
canvasRef,
|
||||||
|
mermaidToExcalidrawLib,
|
||||||
|
text,
|
||||||
|
setError,
|
||||||
|
data,
|
||||||
|
}: ConvertMermaidToExcalidrawFormatProps) => {
|
||||||
|
const canvasNode = canvasRef.current;
|
||||||
|
const parent = canvasNode?.parentElement;
|
||||||
|
|
||||||
|
if (!canvasNode || !parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
resetPreview({ canvasRef, setError });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = await mermaidToExcalidrawLib.api;
|
||||||
|
|
||||||
|
const { elements, files } = await api.parseMermaidToExcalidraw(text, {
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
data.current = {
|
||||||
|
elements: convertToExcalidrawElements(elements, {
|
||||||
|
regenerateIds: true,
|
||||||
|
}),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
|
||||||
|
const canvas = await exportToCanvas({
|
||||||
|
elements: data.current.elements,
|
||||||
|
files: data.current.files,
|
||||||
|
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||||
|
maxWidthOrHeight:
|
||||||
|
Math.max(parent.offsetWidth, parent.offsetHeight) *
|
||||||
|
window.devicePixelRatio,
|
||||||
|
});
|
||||||
|
// if converting to blob fails, there's some problem that will
|
||||||
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
|
await canvasToBlob(canvas);
|
||||||
|
parent.style.background = "var(--default-bg-color)";
|
||||||
|
canvasNode.replaceChildren(canvas);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
parent.style.background = "var(--default-bg-color)";
|
||||||
|
if (text) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
|
||||||
|
export const saveMermaidDataToStorage = (data: string) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Unable to access window.localStorage
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertToEditor = ({
|
||||||
|
app,
|
||||||
|
data,
|
||||||
|
text,
|
||||||
|
shouldSaveMermaidDataToStorage,
|
||||||
|
}: {
|
||||||
|
app: AppClassProperties;
|
||||||
|
data: React.MutableRefObject<{
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
files: BinaryFiles | null;
|
||||||
|
}>;
|
||||||
|
text?: string;
|
||||||
|
shouldSaveMermaidDataToStorage?: boolean;
|
||||||
|
}) => {
|
||||||
|
const { elements: newElements, files } = data.current;
|
||||||
|
|
||||||
|
if (!newElements.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.addElementsFromPasteOrLibrary({
|
||||||
|
elements: newElements,
|
||||||
|
files,
|
||||||
|
position: "center",
|
||||||
|
fitToContent: true,
|
||||||
|
});
|
||||||
|
app.setOpenDialog(null);
|
||||||
|
|
||||||
|
if (shouldSaveMermaidDataToStorage && text) {
|
||||||
|
saveMermaidDataToStorage(text);
|
||||||
|
}
|
||||||
|
};
|
@ -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";
|
|
||||||
|
@ -1742,3 +1742,16 @@ 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,
|
||||||
|
);
|
||||||
|
@ -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(),
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -39,6 +39,7 @@
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
|
@ -132,7 +132,9 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"noItems": "No items added yet...",
|
"noItems": "No items added yet...",
|
||||||
|
@ -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()}
|
||||||
|
@ -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";
|
||||||
|
@ -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] -->|Get money| B(Go shopping)
|
A[Christmas] -->|Get money| B(Go shopping)
|
||||||
B --> C{Let me think}
|
B --> C{Let me think}
|
||||||
C -->|One| D[Laptop]
|
C -->|One| D[Laptop]
|
||||||
C -->|Two| E[iPhone]
|
C -->|Two| E[iPhone]
|
||||||
C -->|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 -->|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>"
|
||||||
`;
|
`;
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -246,14 +246,15 @@ 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: "magicSettings";
|
||||||
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
|
||||||
};
|
}
|
||||||
|
| { name: "ttd"; tab: string };
|
||||||
/**
|
/**
|
||||||
* Reflects user preference for whether the default sidebar should be docked.
|
* Reflects user preference for whether the default sidebar should be docked.
|
||||||
*
|
*
|
||||||
|
@ -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
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@ -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;
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user