David Luzar 1 рік тому
батько
коміт
3d1631f375

+ 7 - 2
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,
     };

+ 15 - 7
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,

+ 9 - 3
src/components/Actions.tsx

@@ -339,7 +339,7 @@ export const ShapesSwitcher = ({
             Generate
           </div>
           <DropdownMenu.Item
-            onSelect={() => 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 && (
             <>
               <DropdownMenu.Item
-                onSelect={() => app.onMagicButtonSelect()}
+                onSelect={() => app.onMagicframeToolSelect()}
                 icon={MagicIcon}
                 data-testid="toolbar-magicframe"
               >
                 {t("toolBar.magicframe")}
               </DropdownMenu.Item>
               <DropdownMenu.Item
-                onSelect={() => app.setOpenDialog("magicSettings")}
+                onSelect={() => {
+                  trackEvent("ai", "d2c-settings", "settings");
+                  app.setOpenDialog({
+                    name: "magicSettings",
+                    source: "settings",
+                  });
+                }}
                 icon={OpenAIIcon}
                 data-testid="toolbar-magicSettings"
               >

+ 79 - 33
src/components/App.tsx

@@ -1435,7 +1435,7 @@ class App extends React.Component<AppProps, AppState> {
                           onMagicSettingsConfirm={this.onMagicSettingsConfirm}
                         >
                           {this.props.children}
-                          {this.state.openDialog === "mermaid" && (
+                          {this.state.openDialog?.name === "mermaid" && (
                             <MermaidToExcalidraw />
                           )}
                         </LayerUI>
@@ -1467,6 +1467,7 @@ class App extends React.Component<AppProps, AppState> {
                                 onChange={() =>
                                   this.onMagicFrameGenerate(
                                     firstSelectedElement,
+                                    "button",
                                   )
                                 }
                               />
@@ -1697,11 +1698,15 @@ class App extends React.Component<AppProps, AppState> {
     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<AppProps, AppState> {
     }).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<AppProps, AppState> {
 
     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<AppProps, AppState> {
     });
 
     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<AppProps, AppState> {
       });
       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<AppProps, AppState> {
   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<AppProps, AppState> {
     }
   };
 
-  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<AppProps, AppState> {
 
     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<AppProps, AppState> {
           opacity: 100,
           locked: false,
         });
-      }
 
-      this.scene.addNewElement(frame);
+        this.scene.addNewElement(frame);
 
-      for (const child of selectedElements) {
-        mutateElement(child, { frameId: frame.id });
-      }
+        for (const child of selectedElements) {
+          mutateElement(child, { frameId: frame.id });
+        }
 
-      this.setState({
-        selectedElementIds: { [frame.id]: true },
-      });
+        this.setState({
+          selectedElementIds: { [frame.id]: true },
+        });
+      }
 
-      this.onMagicFrameGenerate(frame);
+      this.onMagicFrameGenerate(frame, "upstream");
     }
   };
 
@@ -3551,7 +3597,7 @@ class App extends React.Component<AppProps, AppState> {
 
       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<AppProps, AppState> {
         event[KEYS.CTRL_OR_CMD]
       ) {
         event.preventDefault();
-        this.setState({ openDialog: "imageExport" });
+        this.setState({ openDialog: { name: "imageExport" } });
         return;
       }
 

+ 1 - 1
src/components/JSONExportDialog.tsx

@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
 
   return (
     <>
-      {appState.openDialog === "jsonExport" && (
+      {appState.openDialog?.name === "jsonExport" && (
         <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
           <JSONExportModal
             elements={elements}

+ 15 - 6
src/components/LayerUI.tsx

@@ -86,7 +86,11 @@ interface LayerUIProps {
   openAIKey: string | null;
   isOpenAIKeyPersisted: boolean;
   onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
-  onMagicSettingsConfirm: (apiKey: string, shouldPersist: boolean) => void;
+  onMagicSettingsConfirm: (
+    apiKey: string,
+    shouldPersist: boolean,
+    source: "tool" | "generation" | "settings",
+  ) => void;
 }
 
 const DefaultMainMenu: React.FC<{
@@ -177,7 +181,7 @@ const LayerUI = ({
   const renderImageExportDialog = () => {
     if (
       !UIOptions.canvasActions.saveAsImage ||
-      appState.openDialog !== "imageExport"
+      appState.openDialog?.name !== "imageExport"
     ) {
       return null;
     }
@@ -448,21 +452,26 @@ const LayerUI = ({
           }}
         />
       )}
-      {appState.openDialog === "help" && (
+      {appState.openDialog?.name === "help" && (
         <HelpDialog
           onClose={() => {
             setAppState({ openDialog: null });
           }}
         />
       )}
-      {appState.openDialog === "magicSettings" && (
+      {appState.openDialog?.name === "magicSettings" && (
         <MagicSettings
           openAIKey={openAIKey}
           isPersisted={isOpenAIKeyPersisted}
           onChange={onOpenAIAPIKeyChange}
           onConfirm={(apiKey, shouldPersist) => {
-            setAppState({ openDialog: null });
-            onMagicSettingsConfirm(apiKey, shouldPersist);
+            const source =
+              appState.openDialog?.name === "magicSettings"
+                ? appState.openDialog?.source
+                : "settings";
+            setAppState({ openDialog: null }, () => {
+              onMagicSettingsConfirm(apiKey, shouldPersist, source);
+            });
           }}
           onClose={() => {
             setAppState({ openDialog: null });

+ 1 - 1
src/components/MagicSettings.tsx

@@ -106,7 +106,7 @@ export const MagicSettings = (props: {
         own limit in your OpenAI account dashboard if needed.
       </p>
       <TextField
-        isPassword
+        isRedacted
         value={keyInputValue}
         placeholder="Paste your API key here"
         label="OpenAI API key"

+ 1 - 1
src/components/OverwriteConfirm/OverwriteConfirmActions.tsx

@@ -47,7 +47,7 @@ export const ExportToImage = () => {
       actionLabel={t("overwriteConfirm.action.exportToImage.button")}
       onClick={() => {
         actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
-        setAppState({ openDialog: "imageExport" });
+        setAppState({ openDialog: { name: "imageExport" } });
       }}
     >
       {t("overwriteConfirm.action.exportToImage.description")}

+ 13 - 8
src/components/TextField.tsx

@@ -25,7 +25,7 @@ type TextFieldProps = {
 
   label?: string;
   placeholder?: string;
-  isPassword?: boolean;
+  isRedacted?: boolean;
 };
 
 export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -39,7 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
       readonly,
       selectOnRender,
       onKeyDown,
-      isPassword = false,
+      isRedacted = false,
     },
     ref,
   ) => {
@@ -53,7 +53,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
       }
     }, [selectOnRender]);
 
-    const [isVisible, setIsVisible] = useState<boolean>(true);
+    const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
+      useState<boolean>(false);
 
     return (
       <div
@@ -71,7 +72,9 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
           })}
         >
           <input
-            type={isPassword && isVisible ? "password" : undefined}
+            className={clsx({
+              "is-redacted": value && isRedacted && !isTemporarilyUnredacted,
+            })}
             readOnly={readonly}
             value={value}
             placeholder={placeholder}
@@ -79,12 +82,14 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
             onChange={(event) => onChange?.(event.target.value)}
             onKeyDown={onKeyDown}
           />
-          {isPassword && (
+          {isRedacted && (
             <Button
-              onSelect={() => setIsVisible(!isVisible)}
-              style={{ border: 0 }}
+              onSelect={() =>
+                setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
+              }
+              style={{ border: 0, userSelect: "none" }}
             >
-              {isVisible ? eyeIcon : eyeClosedIcon}
+              {isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
             </Button>
           )}
         </div>

+ 2 - 2
src/components/main-menu/DefaultItems.tsx

@@ -107,7 +107,7 @@ export const SaveAsImage = () => {
     <DropdownMenuItem
       icon={ExportImageIcon}
       data-testid="image-export-button"
-      onSelect={() => setAppState({ openDialog: "imageExport" })}
+      onSelect={() => setAppState({ openDialog: { name: "imageExport" } })}
       shortcut={getShortcutFromShortcutName("imageExport")}
       aria-label={t("buttons.exportImage")}
     >
@@ -230,7 +230,7 @@ export const Export = () => {
     <DropdownMenuItem
       icon={ExportIcon}
       onSelect={() => {
-        setAppState({ openDialog: "jsonExport" });
+        setAppState({ openDialog: { name: "jsonExport" } });
       }}
       data-testid="json-export-button"
       aria-label={t("buttons.export")}

+ 6 - 0
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);

+ 6 - 0
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

+ 1 - 1
src/tests/MermaidToExcalidraw.test.tsx

@@ -102,7 +102,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
       <Excalidraw
         initialData={{
           appState: {
-            openDialog: "mermaid",
+            openDialog: { name: "mermaid" },
           },
         }}
       />,

+ 10 - 7
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<{