mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-06 15:50:00 +02:00
Compare commits
1 Commits
dwelle/obs
...
dwelle/ai
Author | SHA1 | Date | |
---|---|---|---|
![]() |
eb62a9612d |
@@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
|
||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
VITE_APP_ENABLE_TRACKING=true
|
||||
VITE_APP_DISABLE_TRACKING=true
|
||||
|
||||
FAST_REFRESH=false
|
||||
|
||||
|
@@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
|
||||
|
||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
||||
VITE_APP_ENABLE_TRACKING=false
|
||||
VITE_APP_DISABLE_TRACKING=
|
||||
|
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -9,9 +9,9 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Install and test
|
||||
|
@@ -90,7 +90,7 @@ function App() {
|
||||
<img src={canvasUrl} alt="" />
|
||||
</div>
|
||||
<div style={{ height: "400px" }}>
|
||||
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
@@ -22,7 +22,6 @@ import { t } from "../packages/excalidraw/i18n";
|
||||
import {
|
||||
Excalidraw,
|
||||
LiveCollaborationTrigger,
|
||||
TTDDialog,
|
||||
TTDDialogTrigger,
|
||||
StoreAction,
|
||||
reconcileElements,
|
||||
@@ -121,6 +120,7 @@ import {
|
||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||
import { useAppLangCode } from "./app-language/language-state";
|
||||
import { AIComponents } from "./components/AI";
|
||||
|
||||
polyfill();
|
||||
|
||||
@@ -846,63 +846,8 @@ const ExcalidrawWrapper = () => {
|
||||
)}
|
||||
</OverwriteConfirmDialog>
|
||||
<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 }),
|
||||
},
|
||||
);
|
||||
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
||||
|
||||
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 && (
|
||||
<div className="collab-offline-warning">
|
||||
|
152
excalidraw-app/components/AI.tsx
Normal file
152
excalidraw-app/components/AI.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
DiagramToCodePlugin,
|
||||
exportToBlob,
|
||||
getTextFromElements,
|
||||
MIME_TYPES,
|
||||
TTDDialog,
|
||||
} from "../../packages/excalidraw";
|
||||
import { getDataURL } from "../../packages/excalidraw/data/blob";
|
||||
import { safelyParseJSON } from "../../packages/excalidraw/utils";
|
||||
|
||||
export const AIComponents = ({
|
||||
excalidrawAPI,
|
||||
}: {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<DiagramToCodePlugin
|
||||
generate={async ({ frame, children }) => {
|
||||
const appState = excalidrawAPI.getAppState();
|
||||
|
||||
const blob = await exportToBlob({
|
||||
elements: children,
|
||||
appState: {
|
||||
...appState,
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
},
|
||||
exportingFrame: frame,
|
||||
files: excalidrawAPI.getFiles(),
|
||||
mimeType: MIME_TYPES.jpg,
|
||||
});
|
||||
|
||||
const dataURL = await getDataURL(blob);
|
||||
|
||||
const textFromFrameChildren = getTextFromElements(children);
|
||||
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/diagram-to-code/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
texts: textFromFrameChildren,
|
||||
image: dataURL,
|
||||
theme: appState.theme,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const error = safelyParseJSON(text);
|
||||
|
||||
if (!error) {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
if (error.statusCode === 429) {
|
||||
return {
|
||||
html: `<html>
|
||||
<body style="margin: 0; text-align: center">
|
||||
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
|
||||
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
|
||||
</br>
|
||||
</br>
|
||||
<div>You can also try <a href="${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(error.message || text);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
return {
|
||||
html,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<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");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@@ -31,8 +31,8 @@
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
|
@@ -64,12 +64,7 @@ export default defineConfig({
|
||||
|
||||
workbox: {
|
||||
// Don't push fonts and locales to app precache
|
||||
globIgnores: [
|
||||
"fonts.css",
|
||||
"**/locales/**",
|
||||
"service-worker.js",
|
||||
"lz-string",
|
||||
],
|
||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||
|
@@ -10,7 +10,7 @@ import {
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { isTextElement } from "../element";
|
||||
import { getTextFromElements, isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
@@ -239,16 +239,8 @@ export const copyText = register({
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
const text = selectedElements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
try {
|
||||
copyTextToSystemClipboard(text);
|
||||
copyTextToSystemClipboard(getTextFromElements(selectedElements));
|
||||
} catch (e) {
|
||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
// place here categories that you want to track. We want to track just a
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
@@ -9,20 +9,17 @@ export const trackEvent = (
|
||||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// prettier-ignore
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
import.meta.env.VITE_WORKER_ID ||
|
||||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|
||||
typeof window === "undefined"
|
||||
|| import.meta.env.VITE_WORKER_ID
|
||||
// comment out to debug locally
|
||||
|| import.meta.env.PROD
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// comment out to debug in dev
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@@ -44,7 +44,6 @@ import {
|
||||
frameToolIcon,
|
||||
mermaidLogoIcon,
|
||||
laserPointerToolIcon,
|
||||
OpenAIIcon,
|
||||
MagicIcon,
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
@@ -395,7 +394,7 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.mermaidToExcalidraw")}
|
||||
</DropdownMenu.Item>
|
||||
{app.props.aiEnabled !== false && (
|
||||
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
|
||||
<>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.onMagicframeToolSelect()}
|
||||
@@ -405,20 +404,6 @@ export const ShapesSwitcher = ({
|
||||
{t("toolBar.magicframe")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
trackEvent("ai", "open-settings", "d2c");
|
||||
app.setOpenDialog({
|
||||
name: "settings",
|
||||
source: "settings",
|
||||
tab: "diagram-to-code",
|
||||
});
|
||||
}}
|
||||
icon={OpenAIIcon}
|
||||
data-testid="toolbar-magicSettings"
|
||||
>
|
||||
{t("toolBar.magicSettings")}
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
|
@@ -49,6 +49,7 @@ import {
|
||||
import type { PastedMixedContent } from "../clipboard";
|
||||
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
|
||||
import type { EXPORT_IMAGE_TYPES } from "../constants";
|
||||
import { DEFAULT_FONT_SIZE } from "../constants";
|
||||
import {
|
||||
APP_NAME,
|
||||
CURSOR_TYPE,
|
||||
@@ -84,7 +85,6 @@ import {
|
||||
ZOOM_STEP,
|
||||
POINTER_EVENTS,
|
||||
TOOL_TYPE,
|
||||
EDITOR_LS_KEYS,
|
||||
isIOS,
|
||||
supportsResizeObserver,
|
||||
DEFAULT_COLLISION_THRESHOLD,
|
||||
@@ -182,6 +182,7 @@ import type {
|
||||
ExcalidrawIframeElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
Ordered,
|
||||
MagicGenerationData,
|
||||
} from "../element/types";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import {
|
||||
@@ -252,6 +253,7 @@ import type {
|
||||
UnsubscribeCallback,
|
||||
EmbedsValidationStatus,
|
||||
ElementsPendingErasure,
|
||||
GenerateDiagramToCode,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@@ -398,13 +400,9 @@ import {
|
||||
} from "../cursor";
|
||||
import { Emitter } from "../emitter";
|
||||
import { ElementCanvasButtons } from "../element/ElementCanvasButtons";
|
||||
import type { MagicCacheData } from "../data/magic";
|
||||
import { diagramToHTML } from "../data/magic";
|
||||
import { exportToBlob } from "../../utils/export";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import { ElementCanvasButton } from "./MagicButton";
|
||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||
import FollowMode from "./FollowMode/FollowMode";
|
||||
import { Store, StoreAction } from "../store";
|
||||
import { AnimationFrameHandler } from "../animation-frame-handler";
|
||||
@@ -992,7 +990,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (isIframeElement(el)) {
|
||||
src = null;
|
||||
|
||||
const data: MagicCacheData = (el.customData?.generationData ??
|
||||
const data: MagicGenerationData = (el.customData?.generationData ??
|
||||
this.magicGenerations.get(el.id)) || {
|
||||
status: "error",
|
||||
message: "No generation data",
|
||||
@@ -1542,10 +1540,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
app={this}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
openAIKey={this.OPENAI_KEY}
|
||||
isOpenAIKeyPersisted={this.OPENAI_KEY_IS_PERSISTED}
|
||||
onOpenAIAPIKeyChange={this.onOpenAIKeyChange}
|
||||
onMagicSettingsConfirm={this.onMagicSettingsConfirm}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
@@ -1788,7 +1782,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private magicGenerations = new Map<
|
||||
ExcalidrawIframeElement["id"],
|
||||
MagicCacheData
|
||||
MagicGenerationData
|
||||
>();
|
||||
|
||||
private updateMagicGeneration = ({
|
||||
@@ -1796,7 +1790,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
data,
|
||||
}: {
|
||||
frameElement: ExcalidrawIframeElement;
|
||||
data: MagicCacheData;
|
||||
data: MagicGenerationData;
|
||||
}) => {
|
||||
if (data.status === "pending") {
|
||||
// We don't wanna persist pending state to storage. It should be in-app
|
||||
@@ -1819,31 +1813,26 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.triggerRender();
|
||||
};
|
||||
|
||||
private getTextFromElements(elements: readonly ExcalidrawElement[]) {
|
||||
const text = elements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
return text;
|
||||
public plugins: {
|
||||
diagramToCode?: {
|
||||
generate: GenerateDiagramToCode;
|
||||
};
|
||||
} = {};
|
||||
|
||||
public setPlugins(plugins: Partial<App["plugins"]>) {
|
||||
Object.assign(this.plugins, plugins);
|
||||
}
|
||||
|
||||
private async onMagicFrameGenerate(
|
||||
magicFrame: ExcalidrawMagicFrameElement,
|
||||
source: "button" | "upstream",
|
||||
) {
|
||||
if (!this.OPENAI_KEY) {
|
||||
const generateDiagramToCode = this.plugins.diagramToCode?.generate;
|
||||
|
||||
if (!generateDiagramToCode) {
|
||||
this.setState({
|
||||
openDialog: {
|
||||
name: "settings",
|
||||
tab: "diagram-to-code",
|
||||
source: "generation",
|
||||
},
|
||||
errorMessage: "No diagram to code plugin found",
|
||||
});
|
||||
trackEvent("ai", "generate (missing key)", "d2c");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1882,68 +1871,50 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedElementIds: { [frameElement.id]: true },
|
||||
});
|
||||
|
||||
const blob = await exportToBlob({
|
||||
elements: this.scene.getNonDeletedElements(),
|
||||
appState: {
|
||||
...this.state,
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||
},
|
||||
exportingFrame: magicFrame,
|
||||
files: this.files,
|
||||
});
|
||||
|
||||
const dataURL = await getDataURL(blob);
|
||||
|
||||
const textFromFrameChildren = this.getTextFromElements(magicFrameChildren);
|
||||
|
||||
trackEvent("ai", "generate (start)", "d2c");
|
||||
try {
|
||||
const { html } = await generateDiagramToCode({
|
||||
frame: magicFrame,
|
||||
children: magicFrameChildren,
|
||||
});
|
||||
|
||||
const result = await diagramToHTML({
|
||||
image: dataURL,
|
||||
apiKey: this.OPENAI_KEY,
|
||||
text: textFromFrameChildren,
|
||||
theme: this.state.theme,
|
||||
});
|
||||
trackEvent("ai", "generate (success)", "d2c");
|
||||
|
||||
if (!result.ok) {
|
||||
if (!html.trim()) {
|
||||
this.updateMagicGeneration({
|
||||
frameElement,
|
||||
data: {
|
||||
status: "error",
|
||||
code: "ERR_OAI",
|
||||
message: "Nothing genereated :(",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedHtml =
|
||||
html.includes("<!DOCTYPE html>") && html.includes("</html>")
|
||||
? html.slice(
|
||||
html.indexOf("<!DOCTYPE html>"),
|
||||
html.indexOf("</html>") + "</html>".length,
|
||||
)
|
||||
: html;
|
||||
|
||||
this.updateMagicGeneration({
|
||||
frameElement,
|
||||
data: { status: "done", html: parsedHtml },
|
||||
});
|
||||
} catch (error: any) {
|
||||
trackEvent("ai", "generate (failed)", "d2c");
|
||||
console.error(result.error);
|
||||
this.updateMagicGeneration({
|
||||
frameElement,
|
||||
data: {
|
||||
status: "error",
|
||||
code: "ERR_OAI",
|
||||
message: result.error?.message || "Unknown error during generation",
|
||||
message: error.message || "Unknown error during generation",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
trackEvent("ai", "generate (success)", "d2c");
|
||||
|
||||
if (result.choices[0].message.content == null) {
|
||||
this.updateMagicGeneration({
|
||||
frameElement,
|
||||
data: {
|
||||
status: "error",
|
||||
code: "ERR_OAI",
|
||||
message: "Nothing genereated :(",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const message = result.choices[0].message.content;
|
||||
|
||||
const html = message.slice(
|
||||
message.indexOf("<!DOCTYPE html>"),
|
||||
message.indexOf("</html>") + "</html>".length,
|
||||
);
|
||||
|
||||
this.updateMagicGeneration({
|
||||
frameElement,
|
||||
data: { status: "done", html },
|
||||
});
|
||||
}
|
||||
|
||||
private onIframeSrcCopy(element: ExcalidrawIframeElement) {
|
||||
@@ -1957,70 +1928,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
private OPENAI_KEY: string | null = EditorLocalStorage.get(
|
||||
EDITOR_LS_KEYS.OAI_API_KEY,
|
||||
);
|
||||
private OPENAI_KEY_IS_PERSISTED: boolean =
|
||||
EditorLocalStorage.has(EDITOR_LS_KEYS.OAI_API_KEY) || false;
|
||||
|
||||
private onOpenAIKeyChange = (
|
||||
openAIKey: string | null,
|
||||
shouldPersist: boolean,
|
||||
) => {
|
||||
this.OPENAI_KEY = openAIKey || null;
|
||||
if (shouldPersist) {
|
||||
const didPersist = EditorLocalStorage.set(
|
||||
EDITOR_LS_KEYS.OAI_API_KEY,
|
||||
openAIKey,
|
||||
);
|
||||
this.OPENAI_KEY_IS_PERSISTED = didPersist;
|
||||
} else {
|
||||
this.OPENAI_KEY_IS_PERSISTED = false;
|
||||
}
|
||||
};
|
||||
|
||||
private onMagicSettingsConfirm = (
|
||||
apiKey: string,
|
||||
shouldPersist: boolean,
|
||||
source: "tool" | "generation" | "settings",
|
||||
) => {
|
||||
this.OPENAI_KEY = apiKey || null;
|
||||
this.onOpenAIKeyChange(this.OPENAI_KEY, shouldPersist);
|
||||
|
||||
if (source === "settings") {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElements = this.scene.getSelectedElements({
|
||||
selectedElementIds: this.state.selectedElementIds,
|
||||
});
|
||||
|
||||
if (apiKey) {
|
||||
if (selectedElements.length) {
|
||||
this.onMagicframeToolSelect();
|
||||
} else {
|
||||
this.setActiveTool({ type: "magicframe" });
|
||||
}
|
||||
} else if (!isMagicFrameElement(selectedElements[0])) {
|
||||
// even if user didn't end up setting api key, let's pick the tool
|
||||
// so they can draw up a frame and move forward
|
||||
this.setActiveTool({ type: "magicframe" });
|
||||
}
|
||||
};
|
||||
|
||||
public onMagicframeToolSelect = () => {
|
||||
if (!this.OPENAI_KEY) {
|
||||
this.setState({
|
||||
openDialog: {
|
||||
name: "settings",
|
||||
tab: "diagram-to-code",
|
||||
source: "tool",
|
||||
},
|
||||
});
|
||||
trackEvent("ai", "tool-select (missing key)", "d2c");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElements = this.scene.getSelectedElements({
|
||||
selectedElementIds: this.state.selectedElementIds,
|
||||
});
|
||||
@@ -3054,7 +2962,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
try {
|
||||
const { elements: skeletonElements, files } =
|
||||
await api.parseMermaidToExcalidraw(data.text);
|
||||
await api.parseMermaidToExcalidraw(data.text, {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
});
|
||||
|
||||
const elements = convertToExcalidrawElements(skeletonElements, {
|
||||
regenerateIds: true,
|
||||
|
@@ -0,0 +1,17 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useApp } from "../App";
|
||||
import type { GenerateDiagramToCode } from "../../types";
|
||||
|
||||
export const DiagramToCodePlugin = (props: {
|
||||
generate: GenerateDiagramToCode;
|
||||
}) => {
|
||||
const app = useApp();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
app.setPlugins({
|
||||
diagramToCode: { generate: props.generate },
|
||||
});
|
||||
}, [app, props.generate]);
|
||||
|
||||
return null;
|
||||
};
|
@@ -60,7 +60,6 @@ import { mutateElement } from "../element/mutateElement";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
import { LaserPointerButton } from "./LaserPointerButton";
|
||||
import { MagicSettings } from "./MagicSettings";
|
||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
@@ -85,14 +84,6 @@ interface LayerUIProps {
|
||||
children?: React.ReactNode;
|
||||
app: AppClassProperties;
|
||||
isCollaborating: boolean;
|
||||
openAIKey: string | null;
|
||||
isOpenAIKeyPersisted: boolean;
|
||||
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
|
||||
onMagicSettingsConfirm: (
|
||||
apiKey: string,
|
||||
shouldPersist: boolean,
|
||||
source: "tool" | "generation" | "settings",
|
||||
) => void;
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
@@ -149,10 +140,6 @@ const LayerUI = ({
|
||||
children,
|
||||
app,
|
||||
isCollaborating,
|
||||
openAIKey,
|
||||
isOpenAIKeyPersisted,
|
||||
onOpenAIAPIKeyChange,
|
||||
onMagicSettingsConfirm,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
@@ -482,25 +469,6 @@ const LayerUI = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appState.openDialog?.name === "settings" && (
|
||||
<MagicSettings
|
||||
openAIKey={openAIKey}
|
||||
isPersisted={isOpenAIKeyPersisted}
|
||||
onChange={onOpenAIAPIKeyChange}
|
||||
onConfirm={(apiKey, shouldPersist) => {
|
||||
const source =
|
||||
appState.openDialog?.name === "settings"
|
||||
? appState.openDialog?.source
|
||||
: "settings";
|
||||
setAppState({ openDialog: null }, () => {
|
||||
onMagicSettingsConfirm(apiKey, shouldPersist, source);
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
setAppState({ openDialog: null });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ActiveConfirmDialog />
|
||||
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||
{renderImageExportDialog()}
|
||||
|
@@ -1,18 +0,0 @@
|
||||
.excalidraw {
|
||||
.MagicSettings {
|
||||
.Island {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.MagicSettings-confirm {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.MagicSettings__confirm {
|
||||
margin-top: 2rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
@@ -1,160 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { TextField } from "./TextField";
|
||||
import { MagicIcon, OpenAIIcon } from "./icons";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { KEYS } from "../keys";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { InlineIcon } from "./InlineIcon";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
import "./MagicSettings.scss";
|
||||
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
|
||||
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
|
||||
|
||||
export const MagicSettings = (props: {
|
||||
openAIKey: string | null;
|
||||
isPersisted: boolean;
|
||||
onChange: (key: string, shouldPersist: boolean) => void;
|
||||
onConfirm: (key: string, shouldPersist: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
|
||||
const [shouldPersist, setShouldPersist] = useState<boolean>(
|
||||
props.isPersisted,
|
||||
);
|
||||
|
||||
const appState = useUIAppState();
|
||||
|
||||
const onConfirm = () => {
|
||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||
};
|
||||
|
||||
if (appState.openDialog?.name !== "settings") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => {
|
||||
props.onClose();
|
||||
props.onConfirm(keyInputValue.trim(), shouldPersist);
|
||||
}}
|
||||
title={
|
||||
<div style={{ display: "flex" }}>
|
||||
Wireframe to Code (AI){" "}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0.1rem 0.5rem",
|
||||
marginLeft: "1rem",
|
||||
fontSize: 14,
|
||||
borderRadius: "12px",
|
||||
background: "var(--color-promo)",
|
||||
color: "var(--color-surface-lowest)",
|
||||
}}
|
||||
>
|
||||
Experimental
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="MagicSettings"
|
||||
autofocus={false}
|
||||
>
|
||||
{/* <h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "1.25rem",
|
||||
paddingLeft: "2.5rem",
|
||||
}}
|
||||
>
|
||||
AI Settings
|
||||
</h2> */}
|
||||
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
|
||||
{/* <TTDDialogTabTriggers>
|
||||
<TTDDialogTabTrigger tab="text-to-diagram">
|
||||
<InlineIcon icon={brainIcon} /> Text to diagram
|
||||
</TTDDialogTabTrigger>
|
||||
<TTDDialogTabTrigger tab="diagram-to-code">
|
||||
<InlineIcon icon={MagicIcon} /> Wireframe to code
|
||||
</TTDDialogTabTrigger>
|
||||
</TTDDialogTabTriggers> */}
|
||||
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
|
||||
TODO
|
||||
</TTDDialogTab> */}
|
||||
<TTDDialogTab
|
||||
// className="ttd-dialog-content"
|
||||
tab="diagram-to-code"
|
||||
>
|
||||
<Paragraph>
|
||||
For the diagram-to-code feature we use{" "}
|
||||
<InlineIcon icon={OpenAIIcon} />
|
||||
OpenAI.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
While the OpenAI API is in beta, its use is strictly limited — as
|
||||
such we require you use your own API key. You can create an{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/login?launch"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
OpenAI account
|
||||
</a>
|
||||
, add a small credit (5 USD minimum), and{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
generate your own API key
|
||||
</a>
|
||||
.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Your OpenAI key does not leave the browser, and you can also set
|
||||
your own limit in your OpenAI account dashboard if needed.
|
||||
</Paragraph>
|
||||
<TextField
|
||||
isRedacted
|
||||
value={keyInputValue}
|
||||
placeholder="Paste your API key here"
|
||||
label="OpenAI API key"
|
||||
onChange={(value) => {
|
||||
setKeyInputValue(value);
|
||||
props.onChange(value.trim(), shouldPersist);
|
||||
}}
|
||||
selectOnRender
|
||||
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
|
||||
/>
|
||||
<Paragraph>
|
||||
By default, your API token is not persisted anywhere so you'll need
|
||||
to insert it again after reload. But, you can persist locally in
|
||||
your browser below.
|
||||
</Paragraph>
|
||||
|
||||
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
|
||||
Persist API key in browser storage
|
||||
</CheckboxItem>
|
||||
|
||||
<Paragraph>
|
||||
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "}
|
||||
tool to wrap your elements in a frame that will then allow you to
|
||||
turn it into code. This dialog can be accessed using the{" "}
|
||||
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />.
|
||||
</Paragraph>
|
||||
|
||||
<FilledButton
|
||||
className="MagicSettings__confirm"
|
||||
size="large"
|
||||
label="Confirm"
|
||||
onClick={onConfirm}
|
||||
/>
|
||||
</TTDDialogTab>
|
||||
</TTDDialogTabs>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@@ -7,10 +7,7 @@ import { isMemberOf } from "../../utils";
|
||||
const TTDDialogTabs = (
|
||||
props: {
|
||||
children: ReactNode;
|
||||
} & (
|
||||
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" }
|
||||
| { dialog: "settings"; tab: "text-to-diagram" | "diagram-to-code" }
|
||||
),
|
||||
} & { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" },
|
||||
) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
@@ -39,13 +36,6 @@ const TTDDialogTabs = (
|
||||
}
|
||||
}
|
||||
if (
|
||||
props.dialog === "settings" &&
|
||||
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
|
||||
) {
|
||||
setAppState({
|
||||
openDialog: { name: props.dialog, tab, source: "settings" },
|
||||
});
|
||||
} else if (
|
||||
props.dialog === "ttd" &&
|
||||
isMemberOf(["text-to-diagram", "mermaid"], tab)
|
||||
) {
|
||||
|
@@ -1,6 +1,10 @@
|
||||
import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
|
||||
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
EDITOR_LS_KEYS,
|
||||
} from "../../constants";
|
||||
import { convertToExcalidrawElements, exportToCanvas } from "../../index";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import type { AppClassProperties, BinaryFiles } from "../../types";
|
||||
@@ -34,7 +38,7 @@ export interface MermaidToExcalidrawLibProps {
|
||||
api: Promise<{
|
||||
parseMermaidToExcalidraw: (
|
||||
definition: string,
|
||||
config?: MermaidConfig,
|
||||
options: MermaidOptions,
|
||||
) => Promise<MermaidToExcalidrawResult>;
|
||||
}>;
|
||||
}
|
||||
@@ -74,10 +78,15 @@ export const convertMermaidToExcalidraw = async ({
|
||||
|
||||
let ret;
|
||||
try {
|
||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
|
||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
});
|
||||
} catch (err: any) {
|
||||
ret = await api.parseMermaidToExcalidraw(
|
||||
mermaidDefinition.replace(/"/g, "'"),
|
||||
{
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
},
|
||||
);
|
||||
}
|
||||
const { elements, files } = ret;
|
||||
|
@@ -274,7 +274,7 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
|
@@ -123,26 +123,10 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
fileHandle?: FileSystemHandle | null,
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
|
||||
let data;
|
||||
|
||||
// assume Obsidian excalidraw plugin file
|
||||
if (blob.name?.endsWith(".excalidraw.md")) {
|
||||
if (contents.indexOf("```compressed-json") > -1) {
|
||||
let str = contents.slice(
|
||||
contents.indexOf("```compressed-json") + '"```compressed-json'.length,
|
||||
);
|
||||
str = str.slice(0, str.indexOf("```"));
|
||||
str = str.replace(/\n/g, "").replace(/\r/g, "");
|
||||
const LZString = await import("lz-string");
|
||||
|
||||
data = JSON.parse(LZString.decompressFromBase64(str));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
data = data || JSON.parse(contents);
|
||||
data = JSON.parse(contents);
|
||||
} catch (error: any) {
|
||||
if (isSupportedImageFile(blob)) {
|
||||
throw new ImageSceneDataError(
|
||||
|
@@ -1,105 +0,0 @@
|
||||
import { THEME } from "../constants";
|
||||
import type { Theme } from "../element/types";
|
||||
import type { DataURL } from "../types";
|
||||
import type { OpenAIInput, OpenAIOutput } from "./ai/types";
|
||||
|
||||
export type MagicCacheData =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| { status: "done"; html: string }
|
||||
| {
|
||||
status: "error";
|
||||
message?: string;
|
||||
code: "ERR_GENERATION_INTERRUPTED" | string;
|
||||
};
|
||||
|
||||
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
|
||||
Your role is to transform low-fidelity wireframes into working front-end HTML code.
|
||||
|
||||
YOU MUST FOLLOW FOLLOWING RULES:
|
||||
|
||||
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
|
||||
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
|
||||
- Inline JavaScript when needed
|
||||
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
|
||||
- Source images from Unsplash or create applicable placeholders
|
||||
- Interpret annotations as intended vs literal UI
|
||||
- Fill gaps using your expertise in UX and business logic
|
||||
- generate primarily for desktop UI, but make it responsive.
|
||||
- Use grid and flexbox wherever applicable.
|
||||
- Convert the wireframe in its entirety, don't omit elements if possible.
|
||||
|
||||
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
|
||||
|
||||
Your goal is a production-ready prototype that brings the wireframes to life.
|
||||
|
||||
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
|
||||
|
||||
export async function diagramToHTML({
|
||||
image,
|
||||
apiKey,
|
||||
text,
|
||||
theme = THEME.LIGHT,
|
||||
}: {
|
||||
image: DataURL;
|
||||
apiKey: string;
|
||||
text: string;
|
||||
theme?: Theme;
|
||||
}) {
|
||||
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
|
||||
model: "gpt-4-vision-preview",
|
||||
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
|
||||
max_tokens: 4096,
|
||||
temperature: 0.1,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: image,
|
||||
detail: "high",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let result:
|
||||
| ({ ok: true } & OpenAIOutput.ChatCompletion)
|
||||
| ({ ok: false } & OpenAIOutput.APIError);
|
||||
|
||||
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const json: OpenAIOutput.ChatCompletion = await resp.json();
|
||||
result = { ...json, ok: true };
|
||||
} else {
|
||||
const json: OpenAIOutput.APIError = await resp.json();
|
||||
result = { ...json, ok: false };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
@@ -45,7 +45,7 @@ export {
|
||||
dragNewElement,
|
||||
} from "./dragElements";
|
||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
export { redrawTextBoundingBox, getTextFromElements } from "./textElement";
|
||||
export {
|
||||
getPerfectElementSize,
|
||||
getLockedLinearCursorAlignSize,
|
||||
|
@@ -945,3 +945,19 @@ export const getMinTextElementWidth = (
|
||||
) => {
|
||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
/** retrieves text from text elements and concatenates to a single string */
|
||||
export const getTextFromElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
separator = "\n\n",
|
||||
) => {
|
||||
const text = elements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join(separator);
|
||||
return text;
|
||||
};
|
||||
|
@@ -7,7 +7,6 @@ import type {
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import type { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types";
|
||||
import type { MagicCacheData } from "../data/magic";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
||||
@@ -96,11 +95,22 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
type: "embeddable";
|
||||
}>;
|
||||
|
||||
export type MagicGenerationData =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| { status: "done"; html: string }
|
||||
| {
|
||||
status: "error";
|
||||
message?: string;
|
||||
code: "ERR_GENERATION_INTERRUPTED" | string;
|
||||
};
|
||||
|
||||
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "iframe";
|
||||
// TODO move later to AI-specific frame
|
||||
customData?: { generationData?: MagicCacheData };
|
||||
customData?: { generationData?: MagicGenerationData };
|
||||
}>;
|
||||
|
||||
export type ExcalidrawIframeLikeElement =
|
||||
|
@@ -763,7 +763,7 @@ export const getFrameLikeTitle = (
|
||||
return element.name === null
|
||||
? isFrameElement(element)
|
||||
? `Frame ${frameIdx}`
|
||||
: `AI Frame $${frameIdx}`
|
||||
: `AI Frame ${frameIdx}`
|
||||
: element.name;
|
||||
};
|
||||
|
||||
|
@@ -211,6 +211,7 @@ export {
|
||||
hashString,
|
||||
isInvisiblySmallElement,
|
||||
getNonDeletedElements,
|
||||
getTextFromElements,
|
||||
} from "./element";
|
||||
export { defaultLang, useI18n, languages } from "./i18n";
|
||||
export {
|
||||
@@ -284,3 +285,5 @@ export {
|
||||
isElementInsideBBox,
|
||||
elementPartiallyOverlapsWithOrContainsBBox,
|
||||
} from "../utils/withinBounds";
|
||||
|
||||
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
||||
|
@@ -267,8 +267,7 @@
|
||||
"laser": "Laser pointer",
|
||||
"hand": "Hand (panning tool)",
|
||||
"extraTools": "More tools",
|
||||
"mermaidToExcalidraw": "Mermaid to Excalidraw",
|
||||
"magicSettings": "AI settings"
|
||||
"mermaidToExcalidraw": "Mermaid to Excalidraw"
|
||||
},
|
||||
"element": {
|
||||
"rectangle": "Rectangle",
|
||||
|
@@ -58,7 +58,7 @@
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/laser-pointer": "1.3.1",
|
||||
"@excalidraw/mermaid-to-excalidraw": "1.1.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "1.0.0",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
@@ -72,7 +72,6 @@
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"jotai": "1.13.1",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"lz-string": "1.5.0",
|
||||
"nanoid": "3.3.3",
|
||||
"open-color": "1.9.1",
|
||||
"pako": "1.0.11",
|
||||
|
@@ -471,7 +471,16 @@ const drawElementFromCanvas = (
|
||||
const element = elementWithCanvas.element;
|
||||
const padding = getCanvasPadding(element);
|
||||
const zoom = elementWithCanvas.scale;
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
|
||||
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
|
||||
|
||||
// Free draw elements will otherwise "shuffle" as the min x and y change
|
||||
if (isFreeDrawElement(element)) {
|
||||
x1 = Math.floor(x1);
|
||||
x2 = Math.ceil(x2);
|
||||
y1 = Math.floor(y1);
|
||||
y2 = Math.ceil(y2);
|
||||
}
|
||||
|
||||
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
|
||||
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
|
||||
|
||||
|
@@ -294,14 +294,6 @@ export interface AppState {
|
||||
openDialog:
|
||||
| null
|
||||
| { name: "imageExport" | "help" | "jsonExport" }
|
||||
| {
|
||||
name: "settings";
|
||||
source:
|
||||
| "tool" // when magicframe tool is selected
|
||||
| "generation" // when magicframe generate button is clicked
|
||||
| "settings"; // when AI settings dialog is explicitly invoked
|
||||
tab: "text-to-diagram" | "diagram-to-code";
|
||||
}
|
||||
| { name: "ttd"; tab: "text-to-diagram" | "mermaid" }
|
||||
| { name: "commandPalette" };
|
||||
/**
|
||||
@@ -615,6 +607,8 @@ export type AppClassProperties = {
|
||||
insertEmbeddableElement: App["insertEmbeddableElement"];
|
||||
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
||||
getName: App["getName"];
|
||||
setPlugins: App["setPlugins"];
|
||||
plugins: App["plugins"];
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
@@ -795,3 +789,8 @@ export type EmbedsValidationStatus = Map<
|
||||
>;
|
||||
|
||||
export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
|
||||
|
||||
export type GenerateDiagramToCode = (props: {
|
||||
frame: ExcalidrawMagicFrameElement;
|
||||
children: readonly ExcalidrawElement[];
|
||||
}) => MaybePromise<{ html: string }>;
|
||||
|
@@ -1124,3 +1124,11 @@ export const promiseTry = async <TValue, TArgs extends unknown[]>(
|
||||
resolve(fn(...args));
|
||||
});
|
||||
};
|
||||
|
||||
export const safelyParseJSON = (json: string): Record<string, any> | null => {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
1
packages/excalidraw/vite-env.d.ts
vendored
1
packages/excalidraw/vite-env.d.ts
vendored
@@ -43,7 +43,6 @@ interface ImportMetaEnv {
|
||||
VITE_APP_COLLAPSE_OVERLAY: string;
|
||||
// Enable eslint in dev server
|
||||
VITE_APP_ENABLE_ESLINT: string;
|
||||
VITE_APP_ENABLE_TRACKING: string;
|
||||
|
||||
VITE_PKG_NAME: string;
|
||||
VITE_PKG_VERSION: string;
|
||||
|
12
yarn.lock
12
yarn.lock
@@ -1930,10 +1930,10 @@
|
||||
resolved "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
|
||||
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
|
||||
|
||||
"@excalidraw/mermaid-to-excalidraw@1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.1.0.tgz#a24a7aa3ad2e4f671054fdb670a8508bab463814"
|
||||
integrity sha512-YP2roqrImzek1SpUAeToSTNhH5Gfw9ogdI5KHp7c+I/mX7SEW8oNqqX7CP+oHcUgNF6RrYIkqSrnMRN9/3EGLg==
|
||||
"@excalidraw/mermaid-to-excalidraw@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.0.0.tgz#8c058d2a43230425cba96d01e4a669a2d7c586a2"
|
||||
integrity sha512-RGSoJBY2gFag6mQOIwa3OakTrvAZYx0bwvnr5ojuCZInih8Fxhje4X1WZfsaQx+GATEH8Ioq3O3b1FPDg4nKjQ==
|
||||
dependencies:
|
||||
"@excalidraw/markdown-to-text" "0.1.2"
|
||||
mermaid "10.9.0"
|
||||
@@ -7753,9 +7753,9 @@ lru-cache@^6.0.0:
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
lz-string@1.5.0, lz-string@^1.5.0:
|
||||
lz-string@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
|
||||
resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
|
||||
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
|
||||
|
||||
magic-string@^0.25.0, magic-string@^0.25.7:
|
||||
|
Reference in New Issue
Block a user