diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 94b36e87..856ac23e 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -10,6 +10,31 @@ import { import { ExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { MODES } from "../constants"; +import { trackEvent } from "../analytics"; + +const trackAction = ( + action: Action, + source: "ui" | "keyboard" | "api", + value: any, +) => { + if (action.trackEvent !== false) { + try { + if (action.trackEvent === true) { + trackEvent( + action.name, + source, + typeof value === "number" || typeof value === "string" + ? String(value) + : undefined, + ); + } else { + action.trackEvent?.(action, source, value); + } + } catch (error) { + console.error("error while logging action:", error); + } + } +}; export class ActionManager implements ActionsManagerInterface { actions = {} as ActionsManagerInterface["actions"]; @@ -65,9 +90,12 @@ export class ActionManager implements ActionsManagerInterface { ), ); - if (data.length === 0) { + if (data.length !== 1) { return false; } + + const action = data[0]; + const { viewModeEnabled } = this.getAppState(); if (viewModeEnabled) { if (!Object.values(MODES).includes(data[0].name)) { @@ -75,6 +103,8 @@ export class ActionManager implements ActionsManagerInterface { } } + trackAction(action, "keyboard", null); + event.preventDefault(); this.updater( data[0].perform( @@ -96,6 +126,7 @@ export class ActionManager implements ActionsManagerInterface { this.app, ), ); + trackAction(action, "api", null); } /** @@ -122,6 +153,8 @@ export class ActionManager implements ActionsManagerInterface { this.app, ), ); + + trackAction(action, "ui", formState); }; return ( diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index d018b731..cca518da 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -1,8 +1,10 @@ import { t } from "../i18n"; import { isDarwin } from "../keys"; import { getShortcutKey } from "../utils"; +import { ActionName } from "./types"; -export type ShortcutName = +export type ShortcutName = SubtypeOf< + ActionName, | "cut" | "copy" | "paste" @@ -26,7 +28,8 @@ export type ShortcutName = | "viewMode" | "flipHorizontal" | "flipVertical" - | "link"; + | "hyperlink" +>; const shortcutMap: Record = { cut: [getShortcutKey("CtrlOrCmd+X")], @@ -63,7 +66,7 @@ const shortcutMap: Record = { flipHorizontal: [getShortcutKey("Shift+H")], flipVertical: [getShortcutKey("Shift+V")], viewMode: [getShortcutKey("Alt+R")], - link: [getShortcutKey("CtrlOrCmd+K")], + hyperlink: [getShortcutKey("CtrlOrCmd+K")], }; export const getShortcutFromShortcutName = (name: ShortcutName) => { diff --git a/src/actions/types.ts b/src/actions/types.ts index 19c0700a..b7bb560d 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -105,7 +105,7 @@ export type ActionName = | "increaseFontSize" | "decreaseFontSize" | "unbindText" - | "link"; + | "hyperlink"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; @@ -136,6 +136,9 @@ export interface Action { appState: AppState, ) => boolean; checked?: (appState: Readonly) => boolean; + trackEvent?: + | boolean + | ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void); } export interface ActionsManagerInterface { diff --git a/src/analytics.ts b/src/analytics.ts index a48a0a1f..c4a2c4e3 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -3,16 +3,16 @@ export const trackEvent = process.env?.REACT_APP_GOOGLE_ANALYTICS_ID && typeof window !== "undefined" && window.gtag - ? (category: string, name: string, label?: string, value?: number) => { - window.gtag("event", name, { + ? (category: string, action: string, label?: string, value?: number) => { + window.gtag("event", action, { event_category: category, event_label: label, value, }); } : typeof process !== "undefined" && process.env?.JEST_WORKER_ID - ? (category: string, name: string, label?: string, value?: number) => {} - : (category: string, name: string, label?: string, value?: number) => { + ? (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, name, label, value); + // console.info("Track Event", category, action, label, value); }; diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 21e15773..65c10a6a 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -158,7 +158,7 @@ export const SelectedShapeActions = ({ {!isMobile && renderAction("deleteSelectedElements")} {renderAction("group")} {renderAction("ungroup")} - {targetElements.length === 1 && renderAction("link")} + {targetElements.length === 1 && renderAction("hyperlink")} )} diff --git a/src/element/Hyperlink.tsx b/src/element/Hyperlink.tsx index b07ac745..7c2d5fbe 100644 --- a/src/element/Hyperlink.tsx +++ b/src/element/Hyperlink.tsx @@ -31,6 +31,7 @@ import { isPointHittingElementBoundingBox } from "./collision"; import { getElementAbsoluteCoords } from "./"; import "./Hyperlink.scss"; +import { trackEvent } from "../analytics"; const CONTAINER_WIDTH = 320; const SPACE_BOTTOM = 85; @@ -69,6 +70,10 @@ export const Hyperlink = ({ const link = normalizeLink(inputRef.current.value); + if (!element.link && link) { + trackEvent("hyperlink", "create"); + } + mutateElement(element, { link }); setAppState({ showHyperlinkPopup: "info" }); }, [element, setAppState]); @@ -108,6 +113,7 @@ export const Hyperlink = ({ }, [appState, element, isEditing, setAppState]); const handleRemove = useCallback(() => { + trackEvent("hyperlink", "delete"); mutateElement(element, { link: null }); if (isEditing) { inputRef.current!.value = ""; @@ -116,6 +122,7 @@ export const Hyperlink = ({ }, [setAppState, element, isEditing]); const onEdit = () => { + trackEvent("hyperlink", "edit", "popup-ui"); setAppState({ showHyperlinkPopup: "editor" }); }; const { x, y } = getCoordsForPopover(element, appState); @@ -239,11 +246,12 @@ export const isLocalLink = (link: string | null) => { }; export const actionLink = register({ - name: "link", + name: "hyperlink", perform: (elements, appState) => { if (appState.showHyperlinkPopup === "editor") { return false; } + return { elements, appState: { @@ -254,6 +262,9 @@ export const actionLink = register({ commitToHistory: true, }; }, + trackEvent: (action, source) => { + trackEvent("hyperlink", "edit", source); + }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, contextItemLabel: (elements, appState) => getContextMenuLabel(elements, appState), @@ -400,6 +411,7 @@ const renderTooltip = ( }, "top", ); + trackEvent("hyperlink", "tooltip", "link-icon"); IS_HYPERLINK_TOOLTIP_VISIBLE = true; }; diff --git a/src/global.d.ts b/src/global.d.ts index 85e8d4ce..ef24cb5a 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -34,6 +34,10 @@ type Mutable = { -readonly [P in keyof T]: T[P]; }; +/** utility type to assert that the second type is a subtype of the first type. + * Returns the subtype. */ +type SubtypeOf = Subtype; + type ResolutionType any> = T extends ( ...args: any ) => Promise diff --git a/src/tests/contextmenu.test.tsx b/src/tests/contextmenu.test.tsx index 663282ed..97a240e7 100644 --- a/src/tests/contextmenu.test.tsx +++ b/src/tests/contextmenu.test.tsx @@ -136,7 +136,7 @@ describe("contextMenu element", () => { "sendToBack", "bringToFront", "duplicateSelection", - "link", + "hyperlink", ]; expect(contextMenu).not.toBeNull();