Kaynağa Gözat

chore: add ga for most actions (#4829)

David Luzar 3 yıl önce
ebeveyn
işleme
f242721f3b
40 değiştirilmiş dosya ile 221 ekleme ve 93 silme
  1. 1 0
      src/actions/actionAddToLibrary.ts
  2. 8 0
      src/actions/actionAlign.tsx
  3. 2 0
      src/actions/actionBoundText.tsx
  4. 9 0
      src/actions/actionCanvas.tsx
  5. 4 0
      src/actions/actionClipboard.tsx
  6. 1 0
      src/actions/actionDeleteSelected.tsx
  7. 2 0
      src/actions/actionDistribute.tsx
  8. 1 0
      src/actions/actionDuplicateSelection.tsx
  9. 8 2
      src/actions/actionExport.tsx
  10. 1 0
      src/actions/actionFinalize.tsx
  11. 2 0
      src/actions/actionFlip.ts
  12. 2 0
      src/actions/actionGroup.tsx
  13. 2 0
      src/actions/actionHistory.tsx
  14. 4 0
      src/actions/actionMenu.tsx
  15. 1 0
      src/actions/actionNavigate.tsx
  16. 15 0
      src/actions/actionProperties.tsx
  17. 1 0
      src/actions/actionSelectAll.ts
  18. 2 0
      src/actions/actionStyles.ts
  19. 4 2
      src/actions/actionToggleGridMode.tsx
  20. 1 0
      src/actions/actionToggleStats.tsx
  21. 4 2
      src/actions/actionToggleViewMode.tsx
  22. 4 3
      src/actions/actionToggleZenMode.tsx
  23. 5 0
      src/actions/actionZindex.tsx
  24. 37 36
      src/actions/manager.tsx
  25. 21 11
      src/actions/types.ts
  26. 10 6
      src/analytics.ts
  27. 4 0
      src/components/Actions.tsx
  28. 20 9
      src/components/App.tsx
  29. 3 1
      src/components/ContextMenu.tsx
  30. 3 3
      src/components/ImageExportDialog.tsx
  31. 10 7
      src/components/JSONExportDialog.tsx
  32. 4 2
      src/components/LayerUI.tsx
  33. 2 0
      src/components/LibraryMenu.tsx
  34. 1 3
      src/element/Hyperlink.tsx
  35. 1 5
      src/element/newElement.test.ts
  36. 2 1
      src/excalidraw-app/collab/CollabWrapper.tsx
  37. 3 0
      src/excalidraw-app/components/ExportToExcalidrawPlus.tsx
  38. 2 0
      src/excalidraw-app/index.tsx
  39. 1 0
      src/types.ts
  40. 13 0
      src/utils.ts

+ 1 - 0
src/actions/actionAddToLibrary.ts

@@ -7,6 +7,7 @@ import { t } from "../i18n";
 
 export const actionAddToLibrary = register({
   name: "addToLibrary",
+  trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
     const selectedElements = getSelectedElements(
       getNonDeletedElements(elements),

+ 8 - 0
src/actions/actionAlign.tsx

@@ -43,6 +43,7 @@ const alignSelectedElements = (
 
 export const actionAlignTop = register({
   name: "alignTop",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       appState,
@@ -72,6 +73,7 @@ export const actionAlignTop = register({
 
 export const actionAlignBottom = register({
   name: "alignBottom",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       appState,
@@ -101,6 +103,7 @@ export const actionAlignBottom = register({
 
 export const actionAlignLeft = register({
   name: "alignLeft",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       appState,
@@ -130,6 +133,8 @@ export const actionAlignLeft = register({
 
 export const actionAlignRight = register({
   name: "alignRight",
+  trackEvent: { category: "element" },
+
   perform: (elements, appState) => {
     return {
       appState,
@@ -159,6 +164,8 @@ export const actionAlignRight = register({
 
 export const actionAlignVerticallyCentered = register({
   name: "alignVerticallyCentered",
+  trackEvent: { category: "element" },
+
   perform: (elements, appState) => {
     return {
       appState,
@@ -184,6 +191,7 @@ export const actionAlignVerticallyCentered = register({
 
 export const actionAlignHorizontallyCentered = register({
   name: "alignHorizontallyCentered",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       appState,

+ 2 - 0
src/actions/actionBoundText.tsx

@@ -21,6 +21,7 @@ import { register } from "./register";
 export const actionUnbindText = register({
   name: "unbindText",
   contextItemLabel: "labels.unbindText",
+  trackEvent: { category: "element" },
   contextItemPredicate: (elements, appState) => {
     const selectedElements = getSelectedElements(elements, appState);
     return selectedElements.some((element) => hasBoundTextElement(element));
@@ -62,6 +63,7 @@ export const actionUnbindText = register({
 export const actionBindText = register({
   name: "bindText",
   contextItemLabel: "labels.bindText",
+  trackEvent: { category: "element" },
   contextItemPredicate: (elements, appState) => {
     const selectedElements = getSelectedElements(elements, appState);
 

+ 9 - 0
src/actions/actionCanvas.tsx

@@ -21,6 +21,7 @@ import clsx from "clsx";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",
+  trackEvent: false,
   perform: (_, appState, value) => {
     return {
       appState: { ...appState, ...value },
@@ -50,6 +51,7 @@ export const actionChangeViewBackgroundColor = register({
 
 export const actionClearCanvas = register({
   name: "clearCanvas",
+  trackEvent: { category: "canvas" },
   perform: (elements, appState, _, app) => {
     app.imageCache.clear();
     return {
@@ -82,6 +84,7 @@ export const actionClearCanvas = register({
 
 export const actionZoomIn = register({
   name: "zoomIn",
+  trackEvent: { category: "canvas" },
   perform: (_elements, appState, _, app) => {
     return {
       appState: {
@@ -117,6 +120,7 @@ export const actionZoomIn = register({
 
 export const actionZoomOut = register({
   name: "zoomOut",
+  trackEvent: { category: "canvas" },
   perform: (_elements, appState, _, app) => {
     return {
       appState: {
@@ -152,6 +156,7 @@ export const actionZoomOut = register({
 
 export const actionResetZoom = register({
   name: "resetZoom",
+  trackEvent: { category: "canvas" },
   perform: (_elements, appState, _, app) => {
     return {
       appState: {
@@ -250,6 +255,7 @@ const zoomToFitElements = (
 
 export const actionZoomToSelected = register({
   name: "zoomToSelection",
+  trackEvent: { category: "canvas" },
   perform: (elements, appState) => zoomToFitElements(elements, appState, true),
   keyTest: (event) =>
     event.code === CODES.TWO &&
@@ -260,6 +266,7 @@ export const actionZoomToSelected = register({
 
 export const actionZoomToFit = register({
   name: "zoomToFit",
+  trackEvent: { category: "canvas" },
   perform: (elements, appState) => zoomToFitElements(elements, appState, false),
   keyTest: (event) =>
     event.code === CODES.ONE &&
@@ -270,6 +277,7 @@ export const actionZoomToFit = register({
 
 export const actionToggleTheme = register({
   name: "toggleTheme",
+  trackEvent: { category: "canvas" },
   perform: (_, appState, value) => {
     return {
       appState: {
@@ -295,6 +303,7 @@ export const actionToggleTheme = register({
 
 export const actionErase = register({
   name: "eraser",
+  trackEvent: { category: "toolbar" },
   perform: (elements, appState) => {
     return {
       appState: {

+ 4 - 0
src/actions/actionClipboard.tsx

@@ -9,6 +9,7 @@ import { t } from "../i18n";
 
 export const actionCopy = register({
   name: "copy",
+  trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
     copyToClipboard(getNonDeletedElements(elements), appState, app.files);
 
@@ -23,6 +24,7 @@ export const actionCopy = register({
 
 export const actionCut = register({
   name: "cut",
+  trackEvent: { category: "element" },
   perform: (elements, appState, data, app) => {
     actionCopy.perform(elements, appState, data, app);
     return actionDeleteSelected.perform(elements, appState);
@@ -33,6 +35,7 @@ export const actionCut = register({
 
 export const actionCopyAsSvg = register({
   name: "copyAsSvg",
+  trackEvent: { category: "element" },
   perform: async (elements, appState, _data, app) => {
     if (!app.canvas) {
       return {
@@ -73,6 +76,7 @@ export const actionCopyAsSvg = register({
 
 export const actionCopyAsPng = register({
   name: "copyAsPng",
+  trackEvent: { category: "element" },
   perform: async (elements, appState, _data, app) => {
     if (!app.canvas) {
       return {

+ 1 - 0
src/actions/actionDeleteSelected.tsx

@@ -58,6 +58,7 @@ const handleGroupEditingState = (
 
 export const actionDeleteSelected = register({
   name: "deleteSelectedElements",
+  trackEvent: { category: "element", action: "delete" },
   perform: (elements, appState) => {
     if (appState.editingLinearElement) {
       const {

+ 2 - 0
src/actions/actionDistribute.tsx

@@ -39,6 +39,7 @@ const distributeSelectedElements = (
 
 export const distributeHorizontally = register({
   name: "distributeHorizontally",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       appState,
@@ -68,6 +69,7 @@ export const distributeHorizontally = register({
 
 export const distributeVertically = register({
   name: "distributeVertically",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       appState,

+ 1 - 0
src/actions/actionDuplicateSelection.tsx

@@ -22,6 +22,7 @@ import { isBoundToContainer } from "../element/typeChecks";
 
 export const actionDuplicateSelection = register({
   name: "duplicateSelection",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     // duplicate selected point(s) if editing a line
     if (appState.editingLinearElement) {

+ 8 - 2
src/actions/actionExport.tsx

@@ -1,4 +1,3 @@
-import { trackEvent } from "../analytics";
 import { load, questionCircle, saveAs } from "../components/icons";
 import { ProjectName } from "../components/ProjectName";
 import { ToolButton } from "../components/ToolButton";
@@ -23,8 +22,8 @@ import { Theme } from "../element/types";
 
 export const actionChangeProjectName = register({
   name: "changeProjectName",
+  trackEvent: false,
   perform: (_elements, appState, value) => {
-    trackEvent("change", "title");
     return { appState: { ...appState, name: value }, commitToHistory: false };
   },
   PanelComponent: ({ appState, updateData, appProps }) => (
@@ -41,6 +40,7 @@ export const actionChangeProjectName = register({
 
 export const actionChangeExportScale = register({
   name: "changeExportScale",
+  trackEvent: { category: "export", action: "scale" },
   perform: (_elements, appState, value) => {
     return {
       appState: { ...appState, exportScale: value },
@@ -89,6 +89,7 @@ export const actionChangeExportScale = register({
 
 export const actionChangeExportBackground = register({
   name: "changeExportBackground",
+  trackEvent: { category: "export", action: "toggleBackground" },
   perform: (_elements, appState, value) => {
     return {
       appState: { ...appState, exportBackground: value },
@@ -107,6 +108,7 @@ export const actionChangeExportBackground = register({
 
 export const actionChangeExportEmbedScene = register({
   name: "changeExportEmbedScene",
+  trackEvent: { category: "export", action: "embedScene" },
   perform: (_elements, appState, value) => {
     return {
       appState: { ...appState, exportEmbedScene: value },
@@ -128,6 +130,7 @@ export const actionChangeExportEmbedScene = register({
 
 export const actionSaveToActiveFile = register({
   name: "saveToActiveFile",
+  trackEvent: { category: "export" },
   perform: async (elements, appState, value, app) => {
     const fileHandleExists = !!appState.fileHandle;
 
@@ -172,6 +175,7 @@ export const actionSaveToActiveFile = register({
 
 export const actionSaveFileToDisk = register({
   name: "saveFileToDisk",
+  trackEvent: { category: "export" },
   perform: async (elements, appState, value, app) => {
     try {
       const { fileHandle } = await saveAsJSON(
@@ -210,6 +214,7 @@ export const actionSaveFileToDisk = register({
 
 export const actionLoadScene = register({
   name: "loadScene",
+  trackEvent: { category: "export" },
   perform: async (elements, appState, _, app) => {
     try {
       const {
@@ -252,6 +257,7 @@ export const actionLoadScene = register({
 
 export const actionExportWithDarkMode = register({
   name: "exportWithDarkMode",
+  trackEvent: { category: "export", action: "toggleTheme" },
   perform: (_elements, appState, value) => {
     return {
       appState: { ...appState, exportWithDarkMode: value },

+ 1 - 0
src/actions/actionFinalize.tsx

@@ -17,6 +17,7 @@ import { isBindingElement } from "../element/typeChecks";
 
 export const actionFinalize = register({
   name: "finalize",
+  trackEvent: false,
   perform: (elements, appState, _, { canvas, focusContainer }) => {
     if (appState.editingLinearElement) {
       const { elementId, startBindingElement, endBindingElement } =

+ 2 - 0
src/actions/actionFlip.ts

@@ -35,6 +35,7 @@ const enableActionFlipVertical = (
 
 export const actionFlipHorizontal = register({
   name: "flipHorizontal",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       elements: flipSelectedElements(elements, appState, "horizontal"),
@@ -50,6 +51,7 @@ export const actionFlipHorizontal = register({
 
 export const actionFlipVertical = register({
   name: "flipVertical",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       elements: flipSelectedElements(elements, appState, "vertical"),

+ 2 - 0
src/actions/actionGroup.tsx

@@ -54,6 +54,7 @@ const enableActionGroup = (
 
 export const actionGroup = register({
   name: "group",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     const selectedElements = getSelectedElements(
       getNonDeletedElements(elements),
@@ -147,6 +148,7 @@ export const actionGroup = register({
 
 export const actionUngroup = register({
   name: "ungroup",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     const groupIds = getSelectedGroupIds(appState);
     if (groupIds.length === 0) {

+ 2 - 0
src/actions/actionHistory.tsx

@@ -62,6 +62,7 @@ type ActionCreator = (history: History) => Action;
 
 export const createUndoAction: ActionCreator = (history) => ({
   name: "undo",
+  trackEvent: { category: "history" },
   perform: (elements, appState) =>
     writeData(elements, appState, () => history.undoOnce()),
   keyTest: (event) =>
@@ -82,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
 
 export const createRedoAction: ActionCreator = (history) => ({
   name: "redo",
+  trackEvent: { category: "history" },
   perform: (elements, appState) =>
     writeData(elements, appState, () => history.redoOnce()),
   keyTest: (event) =>

+ 4 - 0
src/actions/actionMenu.tsx

@@ -9,6 +9,7 @@ import { HelpIcon } from "../components/HelpIcon";
 
 export const actionToggleCanvasMenu = register({
   name: "toggleCanvasMenu",
+  trackEvent: { category: "menu" },
   perform: (_, appState) => ({
     appState: {
       ...appState,
@@ -29,6 +30,7 @@ export const actionToggleCanvasMenu = register({
 
 export const actionToggleEditMenu = register({
   name: "toggleEditMenu",
+  trackEvent: { category: "menu" },
   perform: (_elements, appState) => ({
     appState: {
       ...appState,
@@ -53,6 +55,7 @@ export const actionToggleEditMenu = register({
 
 export const actionFullScreen = register({
   name: "toggleFullScreen",
+  trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
   perform: () => {
     if (!isFullScreen()) {
       allowFullScreen();
@@ -69,6 +72,7 @@ export const actionFullScreen = register({
 
 export const actionShortcuts = register({
   name: "toggleShortcuts",
+  trackEvent: { category: "menu", action: "toggleHelpDialog" },
   perform: (_elements, appState, _, { focusContainer }) => {
     if (appState.showHelpDialog) {
       focusContainer();

+ 1 - 0
src/actions/actionNavigate.tsx

@@ -6,6 +6,7 @@ import { register } from "./register";
 
 export const actionGoToCollaborator = register({
   name: "goToCollaborator",
+  trackEvent: { category: "collab" },
   perform: (_elements, appState, value) => {
     const point = value as Collaborator["pointer"];
     if (!point) {

+ 15 - 0
src/actions/actionProperties.tsx

@@ -194,6 +194,7 @@ const changeFontSize = (
 
 export const actionChangeStrokeColor = register({
   name: "changeStrokeColor",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return {
       ...(value.currentItemStrokeColor && {
@@ -243,6 +244,7 @@ export const actionChangeStrokeColor = register({
 
 export const actionChangeBackgroundColor = register({
   name: "changeBackgroundColor",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return {
       ...(value.currentItemBackgroundColor && {
@@ -285,6 +287,7 @@ export const actionChangeBackgroundColor = register({
 
 export const actionChangeFillStyle = register({
   name: "changeFillStyle",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return {
       elements: changeProperty(elements, appState, (el) =>
@@ -334,6 +337,7 @@ export const actionChangeFillStyle = register({
 
 export const actionChangeStrokeWidth = register({
   name: "changeStrokeWidth",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return {
       elements: changeProperty(elements, appState, (el) =>
@@ -381,6 +385,7 @@ export const actionChangeStrokeWidth = register({
 
 export const actionChangeSloppiness = register({
   name: "changeSloppiness",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return {
       elements: changeProperty(elements, appState, (el) =>
@@ -429,6 +434,7 @@ export const actionChangeSloppiness = register({
 
 export const actionChangeStrokeStyle = register({
   name: "changeStrokeStyle",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return {
       elements: changeProperty(elements, appState, (el) =>
@@ -476,6 +482,7 @@ export const actionChangeStrokeStyle = register({
 
 export const actionChangeOpacity = register({
   name: "changeOpacity",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return {
       elements: changeProperty(elements, appState, (el) =>
@@ -525,6 +532,7 @@ export const actionChangeOpacity = register({
 
 export const actionChangeFontSize = register({
   name: "changeFontSize",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return changeFontSize(elements, appState, () => value, value);
   },
@@ -582,6 +590,7 @@ export const actionChangeFontSize = register({
 
 export const actionDecreaseFontSize = register({
   name: "decreaseFontSize",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return changeFontSize(elements, appState, (element) =>
       Math.round(
@@ -603,6 +612,7 @@ export const actionDecreaseFontSize = register({
 
 export const actionIncreaseFontSize = register({
   name: "increaseFontSize",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return changeFontSize(elements, appState, (element) =>
       Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
@@ -620,6 +630,7 @@ export const actionIncreaseFontSize = register({
 
 export const actionChangeFontFamily = register({
   name: "changeFontFamily",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return {
       elements: changeProperty(
@@ -701,6 +712,7 @@ export const actionChangeFontFamily = register({
 
 export const actionChangeTextAlign = register({
   name: "changeTextAlign",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     return {
       elements: changeProperty(
@@ -773,6 +785,7 @@ export const actionChangeTextAlign = register({
 });
 export const actionChangeVerticalAlign = register({
   name: "changeVerticalAlign",
+  trackEvent: { category: "element" },
   perform: (elements, appState, value) => {
     return {
       elements: changeProperty(
@@ -840,6 +853,7 @@ export const actionChangeVerticalAlign = register({
 
 export const actionChangeSharpness = register({
   name: "changeSharpness",
+  trackEvent: false,
   perform: (elements, appState, value) => {
     const targetElements = getTargetElements(
       getNonDeletedElements(elements),
@@ -904,6 +918,7 @@ export const actionChangeSharpness = register({
 
 export const actionChangeArrowhead = register({
   name: "changeArrowhead",
+  trackEvent: false,
   perform: (
     elements,
     appState,

+ 1 - 0
src/actions/actionSelectAll.ts

@@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
 
 export const actionSelectAll = register({
   name: "selectAll",
+  trackEvent: { category: "canvas" },
   perform: (elements, appState) => {
     if (appState.editingLinearElement) {
       return false;

+ 2 - 0
src/actions/actionStyles.ts

@@ -19,6 +19,7 @@ export let copiedStyles: string = "{}";
 
 export const actionCopyStyles = register({
   name: "copyStyles",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     const element = elements.find((el) => appState.selectedElementIds[el.id]);
     if (element) {
@@ -39,6 +40,7 @@ export const actionCopyStyles = register({
 
 export const actionPasteStyles = register({
   name: "pasteStyles",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     const pastedElement = JSON.parse(copiedStyles);
     if (!isExcalidrawElement(pastedElement)) {

+ 4 - 2
src/actions/actionToggleGridMode.tsx

@@ -2,12 +2,14 @@ import { CODES, KEYS } from "../keys";
 import { register } from "./register";
 import { GRID_SIZE } from "../constants";
 import { AppState } from "../types";
-import { trackEvent } from "../analytics";
 
 export const actionToggleGridMode = register({
   name: "gridMode",
+  trackEvent: {
+    category: "canvas",
+    predicate: (appState) => !appState.gridSize,
+  },
   perform(elements, appState) {
-    trackEvent("view", "mode", "grid");
     return {
       appState: {
         ...appState,

+ 1 - 0
src/actions/actionToggleStats.tsx

@@ -3,6 +3,7 @@ import { CODES, KEYS } from "../keys";
 
 export const actionToggleStats = register({
   name: "stats",
+  trackEvent: { category: "menu" },
   perform(elements, appState) {
     return {
       appState: {

+ 4 - 2
src/actions/actionToggleViewMode.tsx

@@ -1,11 +1,13 @@
 import { CODES, KEYS } from "../keys";
 import { register } from "./register";
-import { trackEvent } from "../analytics";
 
 export const actionToggleViewMode = register({
   name: "viewMode",
+  trackEvent: {
+    category: "canvas",
+    predicate: (appState) => !appState.viewModeEnabled,
+  },
   perform(elements, appState) {
-    trackEvent("view", "mode", "view");
     return {
       appState: {
         ...appState,

+ 4 - 3
src/actions/actionToggleZenMode.tsx

@@ -1,12 +1,13 @@
 import { CODES, KEYS } from "../keys";
 import { register } from "./register";
-import { trackEvent } from "../analytics";
 
 export const actionToggleZenMode = register({
   name: "zenMode",
+  trackEvent: {
+    category: "canvas",
+    predicate: (appState) => !appState.zenModeEnabled,
+  },
   perform(elements, appState) {
-    trackEvent("view", "mode", "zen");
-
     return {
       appState: {
         ...appState,

+ 5 - 0
src/actions/actionZindex.tsx

@@ -18,6 +18,7 @@ import {
 
 export const actionSendBackward = register({
   name: "sendBackward",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       elements: moveOneLeft(elements, appState),
@@ -45,6 +46,7 @@ export const actionSendBackward = register({
 
 export const actionBringForward = register({
   name: "bringForward",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       elements: moveOneRight(elements, appState),
@@ -72,6 +74,7 @@ export const actionBringForward = register({
 
 export const actionSendToBack = register({
   name: "sendToBack",
+  trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
       elements: moveAllLeft(elements, appState),
@@ -106,6 +109,8 @@ export const actionSendToBack = register({
 
 export const actionBringToFront = register({
   name: "bringToFront",
+  trackEvent: { category: "element" },
+
   perform: (elements, appState) => {
     return {
       elements: moveAllRight(elements, appState),

+ 37 - 36
src/actions/manager.tsx

@@ -1,11 +1,11 @@
 import React from "react";
 import {
   Action,
-  ActionsManagerInterface,
   UpdaterFn,
   ActionName,
   ActionResult,
   PanelComponentProps,
+  ActionSource,
 } from "./types";
 import { ExcalidrawElement } from "../element/types";
 import { AppClassProperties, AppState } from "../types";
@@ -14,21 +14,25 @@ import { trackEvent } from "../analytics";
 
 const trackAction = (
   action: Action,
-  source: "ui" | "keyboard" | "api",
+  source: ActionSource,
+  appState: Readonly<AppState>,
+  elements: readonly ExcalidrawElement[],
+  app: AppClassProperties,
   value: any,
 ) => {
-  if (action.trackEvent !== false) {
+  if (action.trackEvent) {
     try {
-      if (action.trackEvent === true) {
-        trackEvent(
-          action.name,
-          source,
-          typeof value === "number" || typeof value === "string"
-            ? String(value)
-            : undefined,
-        );
-      } else {
-        action.trackEvent?.(action, source, value);
+      if (typeof action.trackEvent === "object") {
+        const shouldTrack = action.trackEvent.predicate
+          ? action.trackEvent.predicate(appState, elements, value)
+          : true;
+        if (shouldTrack) {
+          trackEvent(
+            action.trackEvent.category,
+            action.trackEvent.action || action.name,
+            `${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
+          );
+        }
       }
     } catch (error) {
       console.error("error while logging action:", error);
@@ -36,8 +40,8 @@ const trackAction = (
   }
 };
 
-export class ActionManager implements ActionsManagerInterface {
-  actions = {} as ActionsManagerInterface["actions"];
+export class ActionManager {
+  actions = {} as Record<ActionName, Action>;
 
   updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
 
@@ -106,30 +110,25 @@ export class ActionManager implements ActionsManagerInterface {
       }
     }
 
-    trackAction(action, "keyboard", null);
+    const elements = this.getElementsIncludingDeleted();
+    const appState = this.getAppState();
+    const value = null;
+
+    trackAction(action, "keyboard", appState, elements, this.app, null);
 
     event.preventDefault();
-    this.updater(
-      data[0].perform(
-        this.getElementsIncludingDeleted(),
-        this.getAppState(),
-        null,
-        this.app,
-      ),
-    );
+    this.updater(data[0].perform(elements, appState, value, this.app));
     return true;
   }
 
-  executeAction(action: Action) {
-    this.updater(
-      action.perform(
-        this.getElementsIncludingDeleted(),
-        this.getAppState(),
-        null,
-        this.app,
-      ),
-    );
-    trackAction(action, "api", null);
+  executeAction(action: Action, source: ActionSource = "api") {
+    const elements = this.getElementsIncludingDeleted();
+    const appState = this.getAppState();
+    const value = null;
+
+    trackAction(action, source, appState, elements, this.app, value);
+
+    this.updater(action.perform(elements, appState, value, this.app));
   }
 
   /**
@@ -147,7 +146,11 @@ export class ActionManager implements ActionsManagerInterface {
     ) {
       const action = this.actions[name];
       const PanelComponent = action.PanelComponent!;
+      const elements = this.getElementsIncludingDeleted();
+      const appState = this.getAppState();
       const updateData = (formState?: any) => {
+        trackAction(action, "ui", appState, elements, this.app, formState);
+
         this.updater(
           action.perform(
             this.getElementsIncludingDeleted(),
@@ -156,8 +159,6 @@ export class ActionManager implements ActionsManagerInterface {
             this.app,
           ),
         );
-
-        trackAction(action, "ui", formState);
       };
 
       return (

+ 21 - 11
src/actions/types.ts

@@ -8,6 +8,8 @@ import {
 } from "../types";
 import { ToolButtonSize } from "../components/ToolButton";
 
+export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
+
 /** if false, the action should be prevented */
 export type ActionResult =
   | {
@@ -139,15 +141,23 @@ export interface Action {
     appState: AppState,
   ) => boolean;
   checked?: (appState: Readonly<AppState>) => boolean;
-  trackEvent?:
-    | boolean
-    | ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
-}
-
-export interface ActionsManagerInterface {
-  actions: Record<ActionName, Action>;
-  registerAction: (action: Action) => void;
-  handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
-  renderAction: (name: ActionName) => React.ReactElement | null;
-  executeAction: (action: Action) => void;
+  trackEvent:
+    | false
+    | {
+        category:
+          | "toolbar"
+          | "element"
+          | "canvas"
+          | "export"
+          | "history"
+          | "menu"
+          | "collab"
+          | "hyperlink";
+        action?: string;
+        predicate?: (
+          appState: Readonly<AppState>,
+          elements: readonly ExcalidrawElement[],
+          value: any,
+        ) => boolean;
+      };
 }

+ 10 - 6
src/analytics.ts

@@ -4,15 +4,19 @@ export const trackEvent =
   typeof window !== "undefined" &&
   window.gtag
     ? (category: string, action: string, label?: string, value?: number) => {
-        window.gtag("event", action, {
-          event_category: category,
-          event_label: label,
-          value,
-        });
+        try {
+          window.gtag("event", action, {
+            event_category: category,
+            event_label: label,
+            value,
+          });
+        } catch (error) {
+          console.error("error logging to ga", error);
+        }
       }
     : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
     ? (category: string, action: string, label?: string, value?: number) => {}
     : (category: string, action: string, label?: string, value?: number) => {
         // Uncomment the next line to track locally
-        // console.info("Track Event", category, action, label, value);
+        // console.log("Track Event", { category, action, label, value });
       };

+ 4 - 0
src/components/Actions.tsx

@@ -24,6 +24,7 @@ import {
 import Stack from "./Stack";
 import { ToolButton } from "./ToolButton";
 import { hasStrokeColor } from "../scene/comparisons";
+import { trackEvent } from "../analytics";
 import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
 
 export const SelectedShapeActions = ({
@@ -209,6 +210,9 @@ export const ShapesSwitcher = ({
       activeToolType: typeof SHAPES[number]["value"];
       pointerType: PointerType | null;
     }) => {
+      if (appState.activeTool.type !== activeToolType) {
+        trackEvent("toolbar", activeToolType, "ui");
+      }
       if (!appState.penDetected && pointerType === "pen") {
         setAppState({
           penDetected: true,

+ 20 - 9
src/components/App.tsx

@@ -38,7 +38,6 @@ import { ActionResult } from "../actions/types";
 import { trackEvent } from "../analytics";
 import { getDefaultAppState, isEraserActive } from "../appState";
 import {
-  copyToClipboard,
   parseClipboard,
   probablySupportsClipboardBlob,
   probablySupportsClipboardWriteText,
@@ -1291,12 +1290,11 @@ class App extends React.Component<AppProps, AppState> {
   });
 
   private cutAll = () => {
-    this.copyAll();
-    this.actionManager.executeAction(actionDeleteSelected);
+    this.actionManager.executeAction(actionCut, "keyboard");
   };
 
   private copyAll = () => {
-    copyToClipboard(this.scene.getElements(), this.state, this.files);
+    this.actionManager.executeAction(actionCopy, "keyboard");
   };
 
   private static resetTapTwice() {
@@ -1570,7 +1568,14 @@ class App extends React.Component<AppProps, AppState> {
     gesture.pointers.delete(event.pointerId);
   };
 
-  toggleLock = () => {
+  toggleLock = (source: "keyboard" | "ui" = "ui") => {
+    if (!this.state.elementLocked) {
+      trackEvent(
+        "toolbar",
+        "toggleLock",
+        `${source} (${this.deviceType.isMobile ? "mobile" : "desktop"})`,
+      );
+    }
     this.setState((prevState) => {
       return {
         elementLocked: !prevState.elementLocked,
@@ -1594,9 +1599,6 @@ class App extends React.Component<AppProps, AppState> {
   };
 
   toggleStats = () => {
-    if (!this.state.showStats) {
-      trackEvent("dialog", "stats");
-    }
     this.actionManager.executeAction(actionToggleStats);
   };
 
@@ -1851,9 +1853,16 @@ class App extends React.Component<AppProps, AppState> {
       ) {
         const shape = findShapeByKey(event.key);
         if (shape) {
+          if (this.state.activeTool.type !== shape) {
+            trackEvent(
+              "toolbar",
+              shape,
+              `keyboard (${this.deviceType.isMobile ? "mobile" : "desktop"})`,
+            );
+          }
           this.setActiveTool({ type: shape });
         } else if (event.key === KEYS.Q) {
-          this.toggleLock();
+          this.toggleLock("keyboard");
         }
       }
       if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
@@ -5493,6 +5502,7 @@ class App extends React.Component<AppProps, AppState> {
           options: [
             this.deviceType.isMobile &&
               navigator.clipboard && {
+                trackEvent: false,
                 name: "paste",
                 perform: (elements, appStates) => {
                   this.pasteFromClipboard(null);
@@ -5549,6 +5559,7 @@ class App extends React.Component<AppProps, AppState> {
             this.deviceType.isMobile &&
               navigator.clipboard && {
                 name: "paste",
+                trackEvent: false,
                 perform: (elements, appStates) => {
                   this.pasteFromClipboard(null);
                   return {

+ 3 - 1
src/components/ContextMenu.tsx

@@ -70,7 +70,9 @@ const ContextMenu = ({
                   dangerous: actionName === "deleteSelectedElements",
                   checkmark: option.checked?.(appState),
                 })}
-                onClick={() => actionManager.executeAction(option)}
+                onClick={() =>
+                  actionManager.executeAction(option, "contextMenu")
+                }
               >
                 <div className="context-menu-option__label">{label}</div>
                 <kbd className="context-menu-option__shortcut">

+ 3 - 3
src/components/ImageExportDialog.tsx

@@ -1,6 +1,5 @@
 import React, { useEffect, useRef, useState } from "react";
 import { render, unmountComponentAtNode } from "react-dom";
-import { ActionsManagerInterface } from "../actions/types";
 import { probablySupportsClipboardBlob } from "../clipboard";
 import { canvasToBlob } from "../data/blob";
 import { NonDeletedExcalidrawElement } from "../element/types";
@@ -19,6 +18,7 @@ import OpenColor from "open-color";
 import { CheckboxItem } from "./CheckboxItem";
 import { DEFAULT_EXPORT_PADDING } from "../constants";
 import { nativeFileSystemSupported } from "../data/filesystem";
+import { ActionManager } from "../actions/manager";
 
 const supportsContextFilters =
   "filter" in document.createElement("canvas").getContext("2d")!;
@@ -90,7 +90,7 @@ const ImageExportModal = ({
   elements: readonly NonDeletedExcalidrawElement[];
   files: BinaryFiles;
   exportPadding?: number;
-  actionManager: ActionsManagerInterface;
+  actionManager: ActionManager;
   onExportToPng: ExportCB;
   onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;
@@ -229,7 +229,7 @@ export const ImageExportDialog = ({
   elements: readonly NonDeletedExcalidrawElement[];
   files: BinaryFiles;
   exportPadding?: number;
-  actionManager: ActionsManagerInterface;
+  actionManager: ActionManager;
   onExportToPng: ExportCB;
   onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;

+ 10 - 7
src/components/JSONExportDialog.tsx

@@ -1,5 +1,4 @@
 import React, { useState } from "react";
-import { ActionsManagerInterface } from "../actions/types";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { useDeviceType } from "./App";
@@ -12,6 +11,9 @@ import { Card } from "./Card";
 
 import "./ExportDialog.scss";
 import { nativeFileSystemSupported } from "../data/filesystem";
+import { trackEvent } from "../analytics";
+import { ActionManager } from "../actions/manager";
+import { getFrame } from "../utils";
 
 export type ExportCB = (
   elements: readonly NonDeletedExcalidrawElement[],
@@ -29,7 +31,7 @@ const JSONExportModal = ({
   appState: AppState;
   files: BinaryFiles;
   elements: readonly NonDeletedExcalidrawElement[];
-  actionManager: ActionsManagerInterface;
+  actionManager: ActionManager;
   onCloseRequest: () => void;
   exportOpts: ExportOpts;
   canvas: HTMLCanvasElement | null;
@@ -54,7 +56,7 @@ const JSONExportModal = ({
               aria-label={t("exportDialog.disk_button")}
               showAriaLabel={true}
               onClick={() => {
-                actionManager.executeAction(actionSaveFileToDisk);
+                actionManager.executeAction(actionSaveFileToDisk, "ui");
               }}
             />
           </Card>
@@ -70,9 +72,10 @@ const JSONExportModal = ({
               title={t("exportDialog.link_button")}
               aria-label={t("exportDialog.link_button")}
               showAriaLabel={true}
-              onClick={() =>
-                onExportToBackend(elements, appState, files, canvas)
-              }
+              onClick={() => {
+                onExportToBackend(elements, appState, files, canvas);
+                trackEvent("export", "link", `ui (${getFrame()})`);
+              }}
             />
           </Card>
         )}
@@ -94,7 +97,7 @@ export const JSONExportDialog = ({
   elements: readonly NonDeletedExcalidrawElement[];
   appState: AppState;
   files: BinaryFiles;
-  actionManager: ActionsManagerInterface;
+  actionManager: ActionManager;
   exportOpts: ExportOpts;
   canvas: HTMLCanvasElement | null;
 }) => {

+ 4 - 2
src/components/LayerUI.tsx

@@ -36,6 +36,7 @@ import { LibraryMenu } from "./LibraryMenu";
 import "./LayerUI.scss";
 import "./Toolbar.scss";
 import { PenModeButton } from "./PenModeButton";
+import { trackEvent } from "../analytics";
 import { useDeviceType } from "../components/App";
 
 interface LayerUIProps {
@@ -122,6 +123,7 @@ const LayerUI = ({
     const createExporter =
       (type: ExportType): ExportCB =>
       async (exportedElements) => {
+        trackEvent("export", type, "ui");
         const fileHandle = await exportCanvas(
           type,
           exportedElements,
@@ -326,7 +328,7 @@ const LayerUI = ({
                     <LockButton
                       zenModeEnabled={zenModeEnabled}
                       checked={appState.elementLocked}
-                      onChange={onLockToggle}
+                      onChange={() => onLockToggle()}
                       title={t("toolBar.lock")}
                     />
                     <Island
@@ -531,7 +533,7 @@ const LayerUI = ({
         renderImageExportDialog={renderImageExportDialog}
         setAppState={setAppState}
         onCollabButtonClick={onCollabButtonClick}
-        onLockToggle={onLockToggle}
+        onLockToggle={() => onLockToggle()}
         onPenModeToggle={onPenModeToggle}
         canvas={canvas}
         isCollaborating={isCollaborating}

+ 2 - 0
src/components/LibraryMenu.tsx

@@ -19,6 +19,7 @@ import LibraryMenuItems from "./LibraryMenuItems";
 import { EVENT } from "../constants";
 import { KEYS } from "../keys";
 import { arrayToMap } from "../utils";
+import { trackEvent } from "../analytics";
 
 const useOnClickOutside = (
   ref: RefObject<HTMLElement>,
@@ -157,6 +158,7 @@ export const LibraryMenu = ({
 
   const addToLibrary = useCallback(
     async (elements: LibraryItem["elements"]) => {
+      trackEvent("element", "addToLibrary", "ui");
       if (elements.some((element) => element.type === "image")) {
         return setAppState({
           errorMessage: "Support for adding images to the library coming soon!",

+ 1 - 3
src/element/Hyperlink.tsx

@@ -262,9 +262,7 @@ export const actionLink = register({
       commitToHistory: true,
     };
   },
-  trackEvent: (action, source) => {
-    trackEvent("hyperlink", "edit", source);
-  },
+  trackEvent: { category: "hyperlink", action: "click" },
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
   contextItemLabel: (elements, appState) =>
     getContextMenuLabel(elements, appState),

+ 1 - 5
src/element/newElement.test.ts

@@ -2,11 +2,7 @@ import { duplicateElement } from "./newElement";
 import { mutateElement } from "./mutateElement";
 import { API } from "../tests/helpers/api";
 import { FONT_FAMILY } from "../constants";
-
-const isPrimitive = (val: any) => {
-  const type = typeof val;
-  return val == null || (type !== "object" && type !== "function");
-};
+import { isPrimitive } from "../utils";
 
 const assertCloneObjects = (source: any, clone: any) => {
   for (const key in clone) {

+ 2 - 1
src/excalidraw-app/collab/CollabWrapper.tsx

@@ -11,6 +11,7 @@ import {
 import { getSceneVersion } from "../../packages/excalidraw/index";
 import { Collaborator, Gesture } from "../../types";
 import {
+  getFrame,
   preventUnload,
   resolvablePromise,
   withBatchedUpdates,
@@ -239,7 +240,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
   };
 
   openPortal = async () => {
-    trackEvent("share", "room creation");
+    trackEvent("share", "room creation", `ui (${getFrame()})`);
     return this.initializeSocketClient(null);
   };
 

+ 3 - 0
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx

@@ -13,6 +13,8 @@ import { isInitializedImageElement } from "../../element/typeChecks";
 import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
 import { encodeFilesForUpload } from "../data/FileManager";
 import { MIME_TYPES } from "../../constants";
+import { trackEvent } from "../../analytics";
+import { getFrame } from "../../utils";
 
 const exportToExcalidrawPlus = async (
   elements: readonly NonDeletedExcalidrawElement[],
@@ -92,6 +94,7 @@ export const ExportToExcalidrawPlus: React.FC<{
         showAriaLabel={true}
         onClick={async () => {
           try {
+            trackEvent("export", "eplus", `ui (${getFrame()})`);
             await exportToExcalidrawPlus(elements, appState, files);
           } catch (error: any) {
             console.error(error);

+ 2 - 0
src/excalidraw-app/index.tsx

@@ -34,6 +34,7 @@ import {
 import {
   debounce,
   getVersion,
+  getFrame,
   isTestEnv,
   preventUnload,
   ResolvablePromise,
@@ -302,6 +303,7 @@ const ExcalidrawWrapper = () => {
   }
 
   useEffect(() => {
+    trackEvent("load", "frame", getFrame());
     // Delayed so that the app has a time to load the latest SW
     setTimeout(() => {
       trackEvent("load", "version", getVersion());

+ 1 - 0
src/types.ts

@@ -323,6 +323,7 @@ export type AppClassProperties = {
     }
   >;
   files: BinaryFiles;
+  deviceType: App["deviceType"];
 };
 
 export type PointerDownState = Readonly<{

+ 13 - 0
src/utils.ts

@@ -612,3 +612,16 @@ export const updateObject = <T extends Record<string, any>>(
     ...updates,
   };
 };
+
+export const isPrimitive = (val: any) => {
+  const type = typeof val;
+  return val == null || (type !== "object" && type !== "function");
+};
+
+export const getFrame = () => {
+  try {
+    return window.self === window.top ? "top" : "iframe";
+  } catch (error) {
+    return "iframe";
+  }
+};