diff --git a/src/actions/actionMenu.tsx b/src/actions/actionMenu.tsx
index b259d726..fa8dcbea 100644
--- a/src/actions/actionMenu.tsx
+++ b/src/actions/actionMenu.tsx
@@ -56,13 +56,18 @@ export const actionShortcuts = register({
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
- if (appState.openDialog === "help") {
+ if (appState.openDialog?.name === "help") {
focusContainer();
}
return {
appState: {
...appState,
- openDialog: appState.openDialog === "help" ? null : "help",
+ openDialog:
+ appState.openDialog?.name === "help"
+ ? null
+ : {
+ name: "help",
+ },
},
commitToHistory: false,
};
diff --git a/src/analytics.ts b/src/analytics.ts
index c7b4f844..671f5920 100644
--- a/src/analytics.ts
+++ b/src/analytics.ts
@@ -1,3 +1,7 @@
+// 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 = ["ai"] as string[];
+
export const trackEvent = (
category: string,
action: string,
@@ -5,13 +9,13 @@ export const trackEvent = (
value?: number,
) => {
try {
- // place here categories that you want to track as events
- // KEEP IN MIND THE PRICING
- const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
- // Uncomment the next line to track locally
- // console.log("Track Event", { category, action, label, value });
-
- if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
+ // prettier-ignore
+ if (
+ typeof window === "undefined"
+ || import.meta.env.VITE_WORKER_ID
+ // comment out to debug locally
+ || import.meta.env.PROD
+ ) {
return;
}
@@ -19,6 +23,10 @@ export const trackEvent = (
return;
}
+ if (!import.meta.env.PROD) {
+ console.info("trackEvent", { category, action, label, value });
+ }
+
if (window.sa_event) {
window.sa_event(action, {
category,
diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx
index 556dc4af..7c9a2ca7 100644
--- a/src/components/Actions.tsx
+++ b/src/components/Actions.tsx
@@ -339,7 +339,7 @@ export const ShapesSwitcher = ({
Generate
app.setOpenDialog("mermaid")}
+ onSelect={() => app.setOpenDialog({ name: "mermaid" })}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
@@ -349,14 +349,20 @@ export const ShapesSwitcher = ({
{app.props.aiEnabled !== false && (
<>
app.onMagicButtonSelect()}
+ onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
app.setOpenDialog("magicSettings")}
+ onSelect={() => {
+ trackEvent("ai", "d2c-settings", "settings");
+ app.setOpenDialog({
+ name: "magicSettings",
+ source: "settings",
+ });
+ }}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
diff --git a/src/components/App.tsx b/src/components/App.tsx
index c579d8b1..486f32c2 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1435,7 +1435,7 @@ class App extends React.Component {
onMagicSettingsConfirm={this.onMagicSettingsConfirm}
>
{this.props.children}
- {this.state.openDialog === "mermaid" && (
+ {this.state.openDialog?.name === "mermaid" && (
)}
@@ -1467,6 +1467,7 @@ class App extends React.Component {
onChange={() =>
this.onMagicFrameGenerate(
firstSelectedElement,
+ "button",
)
}
/>
@@ -1697,11 +1698,15 @@ class App extends React.Component {
return text;
}
- private async onMagicFrameGenerate(magicFrame: ExcalidrawMagicFrameElement) {
+ private async onMagicFrameGenerate(
+ magicFrame: ExcalidrawMagicFrameElement,
+ source: "button" | "upstream",
+ ) {
if (!this.OPENAI_KEY) {
this.setState({
- openDialog: "magicSettings",
+ openDialog: { name: "magicSettings", source: "generation" },
});
+ trackEvent("ai", "d2c-generate", "missing-key");
return;
}
@@ -1712,7 +1717,12 @@ class App extends React.Component {
}).filter((el) => !isMagicFrameElement(el));
if (!magicFrameChildren.length) {
- this.setState({ errorMessage: "Cannot generate from an empty frame" });
+ if (source === "button") {
+ this.setState({ errorMessage: "Cannot generate from an empty frame" });
+ trackEvent("ai", "d2c-generate", "no-children");
+ } else {
+ this.setActiveTool({ type: "magicframe" });
+ }
return;
}
@@ -1751,6 +1761,8 @@ class App extends React.Component {
const textFromFrameChildren = this.getTextFromElements(magicFrameChildren);
+ trackEvent("ai", "d2c-generate", "generating");
+
const result = await diagramToHTML({
image: dataURL,
apiKey: this.OPENAI_KEY,
@@ -1759,6 +1771,7 @@ class App extends React.Component {
});
if (!result.ok) {
+ trackEvent("ai", "d2c-generate", "generating-failed");
console.error(result.error);
this.updateMagicGeneration({
frameElement,
@@ -1770,6 +1783,7 @@ class App extends React.Component {
});
return;
}
+ trackEvent("ai", "d2c-generate", "generating-done");
if (result.choices[0].message.content == null) {
this.updateMagicGeneration({
@@ -1813,7 +1827,10 @@ class App extends React.Component {
private OPENAI_KEY_IS_PERSISTED: boolean =
EditorLocalStorage.has(EDITOR_LS_KEYS.OAI_API_KEY) || false;
- private onOpenAIKeyChange = (openAIKey: string, shouldPersist: boolean) => {
+ private onOpenAIKeyChange = (
+ openAIKey: string | null,
+ shouldPersist: boolean,
+ ) => {
this.OPENAI_KEY = openAIKey || null;
if (shouldPersist) {
const didPersist = EditorLocalStorage.set(
@@ -1826,26 +1843,41 @@ class App extends React.Component {
}
};
- private onMagicSettingsConfirm = (apiKey: string, shouldPersist: boolean) => {
- this.onOpenAIKeyChange(apiKey, shouldPersist);
+ 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) {
- const selectedElements = this.scene.getSelectedElements({
- selectedElementIds: this.state.selectedElementIds,
- });
if (selectedElements.length) {
- this.onMagicButtonSelect();
+ this.onMagicframeToolSelect();
+ } else {
+ this.setActiveTool({ type: "magicframe" });
}
- } else {
- this.OPENAI_KEY = null;
+ } 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 onMagicButtonSelect = () => {
+ public onMagicframeToolSelect = () => {
if (!this.OPENAI_KEY) {
this.setState({
- openDialog: "magicSettings",
+ openDialog: { name: "magicSettings", source: "tool" },
});
+ trackEvent("ai", "d2c-tool", "missing-key");
return;
}
@@ -1855,19 +1887,33 @@ class App extends React.Component {
if (selectedElements.length === 0) {
this.setActiveTool({ type: TOOL_TYPE.magicframe });
+ trackEvent("ai", "d2c-tool", "empty-selection");
} else {
- if (selectedElements.some((el) => isFrameLikeElement(el))) {
+ const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
+ selectedElements.length === 1 &&
+ isMagicFrameElement(selectedElements[0]) &&
+ selectedElements[0];
+
+ // case: user selected elements containing frame-like(s) or are frame
+ // members, we don't want to wrap into another magicframe
+ // (unless the only selected element is a magic frame which we reuse)
+ if (
+ !selectedMagicFrame &&
+ selectedElements.some((el) => isFrameLikeElement(el) || el.frameId)
+ ) {
this.setActiveTool({ type: TOOL_TYPE.magicframe });
return;
}
- let frame: ExcalidrawMagicFrameElement | null = null;
- if (
- selectedElements.length === 1 &&
- isMagicFrameElement(selectedElements[0])
- ) {
- frame = selectedElements[0];
+ trackEvent("ai", "d2c-tool", "existing-selection");
+
+ let frame: ExcalidrawMagicFrameElement;
+ if (selectedMagicFrame) {
+ // a single magicframe already selected -> use it
+ frame = selectedMagicFrame;
} else {
+ // selected elements aren't wrapped in magic frame yet -> wrap now
+
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
const padding = 50;
@@ -1880,19 +1926,19 @@ class App extends React.Component {
opacity: 100,
locked: false,
});
+
+ this.scene.addNewElement(frame);
+
+ for (const child of selectedElements) {
+ mutateElement(child, { frameId: frame.id });
+ }
+
+ this.setState({
+ selectedElementIds: { [frame.id]: true },
+ });
}
- this.scene.addNewElement(frame);
-
- for (const child of selectedElements) {
- mutateElement(child, { frameId: frame.id });
- }
-
- this.setState({
- selectedElementIds: { [frame.id]: true },
- });
-
- this.onMagicFrameGenerate(frame);
+ this.onMagicFrameGenerate(frame, "upstream");
}
};
@@ -3551,7 +3597,7 @@ class App extends React.Component {
if (event.key === KEYS.QUESTION_MARK) {
this.setState({
- openDialog: "help",
+ openDialog: { name: "help" },
});
return;
} else if (
@@ -3560,7 +3606,7 @@ class App extends React.Component {
event[KEYS.CTRL_OR_CMD]
) {
event.preventDefault();
- this.setState({ openDialog: "imageExport" });
+ this.setState({ openDialog: { name: "imageExport" } });
return;
}
diff --git a/src/components/JSONExportDialog.tsx b/src/components/JSONExportDialog.tsx
index 59cffbba..b5cea4af 100644
--- a/src/components/JSONExportDialog.tsx
+++ b/src/components/JSONExportDialog.tsx
@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
return (
<>
- {appState.openDialog === "jsonExport" && (
+ {appState.openDialog?.name === "jsonExport" && (
{
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
- setAppState({ openDialog: "imageExport" });
+ setAppState({ openDialog: { name: "imageExport" } });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}
diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx
index bae1b40a..10b3d9b5 100644
--- a/src/components/TextField.tsx
+++ b/src/components/TextField.tsx
@@ -25,7 +25,7 @@ type TextFieldProps = {
label?: string;
placeholder?: string;
- isPassword?: boolean;
+ isRedacted?: boolean;
};
export const TextField = forwardRef(
@@ -39,7 +39,7 @@ export const TextField = forwardRef(
readonly,
selectOnRender,
onKeyDown,
- isPassword = false,
+ isRedacted = false,
},
ref,
) => {
@@ -53,7 +53,8 @@ export const TextField = forwardRef(
}
}, [selectOnRender]);
- const [isVisible, setIsVisible] = useState(true);
+ const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
+ useState(false);
return (
(
})}
>
(
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown}
/>
- {isPassword && (
+ {isRedacted && (
)}
diff --git a/src/components/main-menu/DefaultItems.tsx b/src/components/main-menu/DefaultItems.tsx
index 2908d6b4..9191bbe8 100644
--- a/src/components/main-menu/DefaultItems.tsx
+++ b/src/components/main-menu/DefaultItems.tsx
@@ -107,7 +107,7 @@ export const SaveAsImage = () => {
setAppState({ openDialog: "imageExport" })}
+ onSelect={() => setAppState({ openDialog: { name: "imageExport" } })}
shortcut={getShortcutFromShortcutName("imageExport")}
aria-label={t("buttons.exportImage")}
>
@@ -230,7 +230,7 @@ export const Export = () => {
{
- setAppState({ openDialog: "jsonExport" });
+ setAppState({ openDialog: { name: "jsonExport" } });
}}
data-testid="json-export-button"
aria-label={t("buttons.export")}
diff --git a/src/css/styles.scss b/src/css/styles.scss
index 2f690a82..032c9abb 100644
--- a/src/css/styles.scss
+++ b/src/css/styles.scss
@@ -533,6 +533,12 @@
}
}
+ input.is-redacted {
+ // we don't use type=password because browsers (chrome?) prompt
+ // you to save it which is annoying
+ -webkit-text-security: disc;
+ }
+
input[type="text"],
textarea:not(.excalidraw-wysiwyg) {
color: var(--text-primary-color);
diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md
index 8110aad8..368ff414 100644
--- a/src/packages/excalidraw/CHANGELOG.md
+++ b/src/packages/excalidraw/CHANGELOG.md
@@ -11,6 +11,12 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section.
-->
+## Unreleased
+
+### Breaking Changes
+
+- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
+
## 0.17.0 (2023-11-14)
### Features
diff --git a/src/tests/MermaidToExcalidraw.test.tsx b/src/tests/MermaidToExcalidraw.test.tsx
index fc3cb8f3..758831a6 100644
--- a/src/tests/MermaidToExcalidraw.test.tsx
+++ b/src/tests/MermaidToExcalidraw.test.tsx
@@ -102,7 +102,7 @@ describe("Test ", () => {
,
diff --git a/src/types.ts b/src/types.ts
index a0690f95..507123ba 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -245,12 +245,15 @@ export interface AppState {
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog:
- | "imageExport"
- | "help"
- | "jsonExport"
- | "mermaid"
- | "magicSettings"
- | null;
+ | null
+ | { name: "imageExport" | "help" | "jsonExport" | "mermaid" }
+ | {
+ name: "magicSettings";
+ source:
+ | "tool" // when magicframe tool is selected
+ | "generation" // when magicframe generate button is clicked
+ | "settings"; // when AI settings dialog is explicitly invoked
+ };
/**
* Reflects user preference for whether the default sidebar should be docked.
*
@@ -549,7 +552,7 @@ export type AppClassProperties = {
setActiveTool: App["setActiveTool"];
setOpenDialog: App["setOpenDialog"];
insertEmbeddableElement: App["insertEmbeddableElement"];
- onMagicButtonSelect: App["onMagicButtonSelect"];
+ onMagicframeToolSelect: App["onMagicframeToolSelect"];
};
export type PointerDownState = Readonly<{