feat: text-to-diagram (#7325)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>pull/7343/head
parent
dd8a7d41e2
commit
14845a343b
@ -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;
|
@ -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,
|
||||
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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
@ -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>;
|
||||
};
|
@ -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,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;
|
@ -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";
|
@ -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);
|
||||
}
|
||||
};
|
@ -1,10 +1,10 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
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)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
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>"
|
||||
`;
|
||||
|
Loading…
Reference in New Issue