diff --git a/.env.production b/.env.production new file mode 100644 index 000000000..0a6355edd --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +REACT_APP_INCLUDE_GTAG=true diff --git a/analytics.md b/analytics.md new file mode 100644 index 000000000..264364dc3 --- /dev/null +++ b/analytics.md @@ -0,0 +1,31 @@ +| Excalidraw | Name | Category | Label | Value | +| ------------------ | ------ | ---------------------------------- | ----------------------- | --------- | +| Shape / Selection | shape | selection, rectangle, diamond, etc | `toolbar` or `shortcut` | +| Lock selection | shape | lock | `on` or `off` | +| Load file | action | load | `MIME type` | +| Import from URL | action | import | +| Save | action | save | +| Save as | action | save as | +| Clear canvas | action | clear canvas | +| Zoom in | action | zoom | in | `zoom` | +| Zoom out | action | zoom | out | `zoom` | +| Zoom fit | action | zoom | fit | `zoom` | +| Zoom reset | action | zoom | reset | `zoom` | +| Open shortcut menu | action | keyboard shortcuts | +| Canvas color | change | canvas color | `color` | +| Background color | change | background color | `color` | +| Stroke color | change | stroke color | `color` | +| Stroke width | change | stroke | width | `width` | +| Stroke sloppiness | change | stroke | sloppiness | `value` | +| Fill | change | fill | `value` | +| Edge | change | edge | `value` | +| Opacity | change | opacity | value | `opacity` | +| Project name | change | title | +| Theme | change | theme | `light` or `dark` | +| Change language | change | language | `language` | +| Language on load | change | language on load | `language` | +| E2EE shield | exit | e2ee shield | +| GitHub corner | exit | github | +| Excalidraw blog | exit | blog | +| Excalidraw guides | exit | guides | +| File issues | exit | issues | diff --git a/package-lock.json b/package-lock.json index daa47d0b7..5959fa86b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15933,9 +15933,9 @@ } }, "npm": { - "version": "6.14.8", - "resolved": "https://registry.npmjs.org/npm/-/npm-6.14.8.tgz", - "integrity": "sha512-HBZVBMYs5blsj94GTeQZel7s9odVuuSUHy1+AlZh7rPVux1os2ashvEGLy/STNK7vUjbrCg5Kq9/GXisJgdf6A==", + "version": "6.14.9", + "resolved": "https://registry.npmjs.org/npm/-/npm-6.14.9.tgz", + "integrity": "sha512-yHi1+i9LyAZF1gAmgyYtVk+HdABlLy94PMIDoK1TRKWvmFQAt5z3bodqVwKvzY0s6dLqQPVsRLiwhJfNtiHeCg==", "requires": { "JSONStream": "^1.3.5", "abbrev": "~1.1.1", @@ -16017,7 +16017,7 @@ "npm-pick-manifest": "^3.0.2", "npm-profile": "^4.0.4", "npm-registry-fetch": "^4.0.7", - "npm-user-validate": "~1.0.0", + "npm-user-validate": "^1.0.1", "npmlog": "~4.1.2", "once": "~1.4.0", "opener": "^1.5.1", @@ -16088,16 +16088,6 @@ "humanize-ms": "^1.2.1" } }, - "ajv": { - "version": "5.5.2", - "bundled": true, - "requires": { - "co": "^4.6.0", - "fast-deep-equal": "^1.0.0", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.3.0" - } - }, "ansi-align": { "version": "2.0.0", "bundled": true, @@ -16383,10 +16373,6 @@ "mkdirp": "~0.5.0" } }, - "co": { - "version": "4.6.0", - "bundled": true - }, "code-point-at": { "version": "1.1.0", "bundled": true @@ -16775,10 +16761,6 @@ "version": "1.3.0", "bundled": true }, - "fast-deep-equal": { - "version": "1.1.0", - "bundled": true - }, "fast-json-stable-stringify": { "version": "2.0.0", "bundled": true @@ -17063,11 +17045,31 @@ "bundled": true }, "har-validator": { - "version": "5.1.0", + "version": "5.1.5", "bundled": true, "requires": { - "ajv": "^5.3.0", + "ajv": "^6.12.3", "har-schema": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "bundled": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "bundled": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "bundled": true + } } }, "has": { @@ -17310,10 +17312,6 @@ "version": "0.2.3", "bundled": true }, - "json-schema-traverse": { - "version": "0.3.1", - "bundled": true - }, "json-stringify-safe": { "version": "5.0.1", "bundled": true @@ -17884,7 +17882,7 @@ } }, "npm-user-validate": { - "version": "1.0.0", + "version": "1.0.1", "bundled": true }, "npmlog": { @@ -18759,6 +18757,19 @@ "xdg-basedir": "^3.0.0" } }, + "uri-js": { + "version": "4.4.0", + "bundled": true, + "requires": { + "punycode": "^2.1.0" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "bundled": true + } + } + }, "url-parse-lax": { "version": "1.0.0", "bundled": true, diff --git a/public/index.html b/public/index.html index fea1633ac..2d923da67 100644 --- a/public/index.html +++ b/public/index.html @@ -87,7 +87,7 @@ <% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %> <% } %> diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index d7c7f6fcf..e321c8d43 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -14,10 +14,14 @@ import { AppState, NormalizedZoomValue } from "../types"; import { getCommonBounds } from "../element"; import { getNewZoom } from "../scene/zoom"; import { centerScrollOn } from "../scene/scroll"; +import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", perform: (_, appState, value) => { + if (value !== appState.viewBackgroundColor) { + trackEvent(EVENT_CHANGE, "canvas color", value); + } return { appState: { ...appState, viewBackgroundColor: value }, commitToHistory: true, @@ -40,6 +44,7 @@ export const actionChangeViewBackgroundColor = register({ export const actionClearCanvas = register({ name: "clearCanvas", perform: (elements, appState: AppState) => { + trackEvent(EVENT_ACTION, "clear canvas"); return { elements: elements.map((element) => newElementWith(element, { isDeleted: true }), @@ -78,14 +83,16 @@ const ZOOM_STEP = 0.1; export const actionZoomIn = register({ name: "zoomIn", perform: (_elements, appState) => { + const zoom = getNewZoom( + getNormalizedZoom(appState.zoom.value + ZOOM_STEP), + appState.zoom, + { x: appState.width / 2, y: appState.height / 2 }, + ); + trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100); return { appState: { ...appState, - zoom: getNewZoom( - getNormalizedZoom(appState.zoom.value + ZOOM_STEP), - appState.zoom, - { x: appState.width / 2, y: appState.height / 2 }, - ), + zoom, }, commitToHistory: false, }; @@ -109,14 +116,17 @@ export const actionZoomIn = register({ export const actionZoomOut = register({ name: "zoomOut", perform: (_elements, appState) => { + const zoom = getNewZoom( + getNormalizedZoom(appState.zoom.value - ZOOM_STEP), + appState.zoom, + { x: appState.width / 2, y: appState.height / 2 }, + ); + + trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100); return { appState: { ...appState, - zoom: getNewZoom( - getNormalizedZoom(appState.zoom.value - ZOOM_STEP), - appState.zoom, - { x: appState.width / 2, y: appState.height / 2 }, - ), + zoom, }, commitToHistory: false, }; @@ -140,6 +150,7 @@ export const actionZoomOut = register({ export const actionResetZoom = register({ name: "resetZoom", perform: (_elements, appState) => { + trackEvent(EVENT_ACTION, "zoom", "reset", 100); return { appState: { ...appState, @@ -201,7 +212,7 @@ export const actionZoomToFit = register({ const [x1, y1, x2, y2] = commonBounds; const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; - + trackEvent(EVENT_ACTION, "zoom", "fit", newZoom.value * 100); return { appState: { ...appState, diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 35ca92997..bb92ee626 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -8,10 +8,12 @@ import useIsMobile from "../is-mobile"; import { register } from "./register"; import { KEYS } from "../keys"; import { muteFSAbortError } from "../utils"; +import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics"; export const actionChangeProjectName = register({ name: "changeProjectName", perform: (_elements, appState, value) => { + trackEvent(EVENT_CHANGE, "title"); return { appState: { ...appState, name: value }, commitToHistory: false }; }, PanelComponent: ({ appState, updateData }) => ( @@ -88,6 +90,7 @@ export const actionSaveScene = register({ perform: async (elements, appState, value) => { try { const { fileHandle } = await saveAsJSON(elements, appState); + trackEvent(EVENT_ACTION, "save"); return { commitToHistory: false, appState: { ...appState, fileHandle } }; } catch (error) { if (error?.name !== "AbortError") { @@ -118,6 +121,7 @@ export const actionSaveAsScene = register({ ...appState, fileHandle: null, }); + trackEvent(EVENT_ACTION, "save as"); return { commitToHistory: false, appState: { ...appState, fileHandle } }; } catch (error) { if (error?.name !== "AbortError") { @@ -149,16 +153,14 @@ export const actionLoadScene = register({ elements, appState, { elements: loadedElements, appState: loadedAppState, error }, - ) => { - return { - elements: loadedElements, - appState: { - ...loadedAppState, - errorMessage: error, - }, - commitToHistory: true, - }; - }, + ) => ({ + elements: loadedElements, + appState: { + ...loadedAppState, + errorMessage: error, + }, + commitToHistory: true, + }), PanelComponent: ({ updateData, appState }) => ( { + trackEvent(EVENT_ACTION, "keyboard shortcuts"); return { appState: { ...appState, diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index fcce6d14e..0eebdb0c6 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -40,6 +40,7 @@ import { SloppinessArtistIcon, SloppinessCartoonistIcon, } from "../components/icons"; +import { EVENT_CHANGE, trackEvent } from "../analytics"; const changeProperty = ( elements: readonly ExcalidrawElement[], @@ -81,6 +82,9 @@ const getFormValue = function ( export const actionChangeStrokeColor = register({ name: "changeStrokeColor", perform: (elements, appState, value) => { + if (value !== appState.currentItemStrokeColor) { + trackEvent(EVENT_CHANGE, "stroke color", value); + } return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { @@ -112,6 +116,10 @@ export const actionChangeStrokeColor = register({ export const actionChangeBackgroundColor = register({ name: "changeBackgroundColor", perform: (elements, appState, value) => { + if (value !== appState.currentItemBackgroundColor) { + trackEvent(EVENT_CHANGE, "background color", value); + } + return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { @@ -143,6 +151,7 @@ export const actionChangeBackgroundColor = register({ export const actionChangeFillStyle = register({ name: "changeFillStyle", perform: (elements, appState, value) => { + trackEvent(EVENT_CHANGE, "fill", value); return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { @@ -192,6 +201,7 @@ export const actionChangeFillStyle = register({ export const actionChangeStrokeWidth = register({ name: "changeStrokeWidth", perform: (elements, appState, value) => { + trackEvent(EVENT_CHANGE, "stroke", "width", value); return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { @@ -254,6 +264,7 @@ export const actionChangeStrokeWidth = register({ export const actionChangeSloppiness = register({ name: "changeSloppiness", perform: (elements, appState, value) => { + trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value); return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { @@ -349,6 +360,7 @@ export const actionChangeStrokeStyle = register({ export const actionChangeOpacity = register({ name: "changeOpacity", perform: (elements, appState, value) => { + trackEvent(EVENT_CHANGE, "opacity", "value", value); return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { @@ -545,6 +557,7 @@ export const actionChangeSharpness = register({ const shouldUpdateForLinearElements = targetElements.length ? targetElements.every(isLinearElement) : isLinearElementType(appState.elementType); + trackEvent(EVENT_CHANGE, "edge", value); return { elements: changeProperty(elements, appState, (el) => newElementWith(el, { diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 000000000..7372c3126 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,16 @@ +export const EVENT_ACTION = "action"; +export const EVENT_EXIT = "exit"; +export const EVENT_CHANGE = "change"; +export const EVENT_SHAPE = "shape"; + +export const trackEvent = window.gtag + ? (name: string, category: string, label?: string, value?: number) => { + window.gtag("event", name, { + event_category: category, + event_label: label, + value, + }); + } + : (name: string, category: string, label?: string, value?: number) => { + console.info("Track Event", name, category, label, value); + }; diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 9a188875e..36fa19ef3 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -16,6 +16,7 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils"; import Stack from "./Stack"; import useIsMobile from "../is-mobile"; import { getNonDeletedElements } from "../element"; +import { trackEvent, EVENT_SHAPE } from "../analytics"; export const SelectedShapeActions = ({ appState, @@ -173,6 +174,7 @@ export const ShapesSwitcher = ({ aria-keyshortcuts={shortcut} data-testid={value} onChange={() => { + trackEvent(EVENT_SHAPE, value, "toolbar"); setAppState({ elementType: value, multiElement: null, diff --git a/src/components/App.tsx b/src/components/App.tsx index 3736d6c0b..2c6c7731f 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -181,6 +181,7 @@ import { isSavedToFirebase, } from "../data/firebase"; import { getNewZoom } from "../scene/zoom"; +import { EVENT_SHAPE, trackEvent } from "../analytics"; /** * @param func handler taking at most single parameter (event). @@ -1270,12 +1271,15 @@ class App extends React.Component { }; toggleLock = () => { - this.setState((prevState) => ({ - elementLocked: !prevState.elementLocked, - elementType: prevState.elementLocked - ? "selection" - : prevState.elementType, - })); + this.setState((prevState) => { + trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off"); + return { + elementLocked: !prevState.elementLocked, + elementType: prevState.elementLocked + ? "selection" + : prevState.elementType, + }; + }); }; toggleZenMode = () => { @@ -1655,6 +1659,7 @@ class App extends React.Component { ) { const shape = findShapeByKey(event.key); if (shape) { + trackEvent(EVENT_SHAPE, shape, "shortcut"); this.selectShapeTool(shape); } else if (event.key === KEYS.Q) { this.toggleLock(); diff --git a/src/components/BackgroundPickerAndDarkModeToggle.tsx b/src/components/BackgroundPickerAndDarkModeToggle.tsx index f2eba1315..e87ff08a9 100644 --- a/src/components/BackgroundPickerAndDarkModeToggle.tsx +++ b/src/components/BackgroundPickerAndDarkModeToggle.tsx @@ -1,5 +1,6 @@ import React from "react"; import { ActionManager } from "../actions/manager"; +import { EVENT_CHANGE, trackEvent } from "../analytics"; import { AppState } from "../types"; import { DarkModeToggle } from "./DarkModeToggle"; @@ -18,6 +19,8 @@ export const BackgroundPickerAndDarkModeToggle = ({ { + // TODO: track the theme on the first load too + trackEvent(EVENT_CHANGE, "theme", appearance); setAppState({ appearance }); }} /> diff --git a/src/components/GitHubCorner.tsx b/src/components/GitHubCorner.tsx index f773c11fe..a61a0b733 100644 --- a/src/components/GitHubCorner.tsx +++ b/src/components/GitHubCorner.tsx @@ -1,5 +1,6 @@ import React from "react"; import oc from "open-color"; +import { EVENT_EXIT, trackEvent } from "../analytics"; // https://github.com/tholman/github-corners export const GitHubCorner = React.memo( @@ -16,6 +17,9 @@ export const GitHubCorner = React.memo( target="_blank" rel="noopener noreferrer" aria-label="GitHub repository" + onClick={() => { + trackEvent(EVENT_EXIT, "github"); + }} > { + trackEvent(EVENT_EXIT, "e2ee shield"); + }} > {t("encrypted.tooltip")} diff --git a/src/components/ShortcutsDialog.tsx b/src/components/ShortcutsDialog.tsx index c9b2e1ca6..9a7ffa2db 100644 --- a/src/components/ShortcutsDialog.tsx +++ b/src/components/ShortcutsDialog.tsx @@ -4,6 +4,7 @@ import { isDarwin } from "../keys"; import { Dialog } from "./Dialog"; import { getShortcutKey } from "../utils"; import "./ShortcutsDialog.scss"; +import { EVENT_EXIT, trackEvent } from "../analytics"; const Columns = (props: { children: React.ReactNode }) => (
( href="https://blog.excalidraw.com" target="_blank" rel="noopener noreferrer" + onClick={() => { + trackEvent(EVENT_EXIT, "blog"); + }} > {t("shortcutsDialog.blog")} @@ -98,6 +102,9 @@ const Footer = () => ( href="https://howto.excalidraw.com" target="_blank" rel="noopener noreferrer" + onClick={() => { + trackEvent(EVENT_EXIT, "guides"); + }} > {t("shortcutsDialog.howto")} @@ -105,6 +112,9 @@ const Footer = () => ( href="https://github.com/excalidraw/excalidraw/issues" target="_blank" rel="noopener noreferrer" + onClick={() => { + trackEvent(EVENT_EXIT, "issues"); + }} > {t("shortcutsDialog.github")} diff --git a/src/data/blob.ts b/src/data/blob.ts index 012090940..224dc216d 100644 --- a/src/data/blob.ts +++ b/src/data/blob.ts @@ -7,6 +7,7 @@ import { calculateScrollCenter } from "../scene"; import { MIME_TYPES } from "../constants"; import { CanvasError } from "../errors"; import { clearElementsForExport } from "../element"; +import { EVENT_ACTION, trackEvent } from "../analytics"; export const parseFileContents = async (blob: Blob | File) => { let contents: string; @@ -89,7 +90,7 @@ export const loadFromBlob = async ( if (data.type !== "excalidraw") { throw new Error(t("alerts.couldNotLoadInvalidFile")); } - return restore( + const result = restore( { elements: clearElementsForExport(data.elements || []), appState: { @@ -109,6 +110,9 @@ export const loadFromBlob = async ( }, localAppState, ); + + trackEvent(EVENT_ACTION, "load", getMimeType(blob)); + return result; } catch (error) { console.error(error.message); throw new Error(t("alerts.couldNotLoadInvalidFile")); diff --git a/src/data/index.ts b/src/data/index.ts index 171df12c7..a89579368 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -20,6 +20,7 @@ import { ExportType } from "../scene/types"; import { restore } from "./restore"; import { ImportedDataState } from "./types"; import { canvasToBlob } from "./blob"; +import { EVENT_ACTION, trackEvent } from "../analytics"; export { loadFromBlob } from "./blob"; export { saveAsJSON, loadFromJSON } from "./json"; @@ -263,6 +264,7 @@ const importFromBackend = async ( data = await response.json(); } + trackEvent(EVENT_ACTION, "import"); return { elements: data.elements || null, appState: data.appState || null, diff --git a/src/data/json.ts b/src/data/json.ts index 37aec4133..2eb4f9502 100644 --- a/src/data/json.ts +++ b/src/data/json.ts @@ -42,7 +42,6 @@ export const saveAsJSON = async ( }, appState.fileHandle, ); - return { fileHandle }; }; diff --git a/src/global.d.ts b/src/global.d.ts index c72b46e46..25a7d1fc8 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -12,6 +12,7 @@ interface Document { interface Window { ClipboardItem: any; __EXCALIDRAW_SHA__: string | undefined; + gtag: Function; } // https://github.com/facebook/create-react-app/blob/ddcb7d5/packages/react-scripts/lib/react-app.d.ts diff --git a/src/i18n.ts b/src/i18n.ts index 9742e4f8b..279a08ccb 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,4 +1,5 @@ import LanguageDetector from "i18next-browser-languagedetector"; +import { EVENT_CHANGE, trackEvent } from "./analytics"; import fallbackLanguageData from "./locales/en.json"; import percentages from "./locales/percentages.json"; @@ -67,8 +68,8 @@ export const setLanguage = async (newLng: string | undefined) => { currentLanguageData = await import( /* webpackChunkName: "i18n-[request]" */ `./locales/${currentLanguage.lng}.json` ); - languageDetector.cacheUserLanguage(currentLanguage.lng); + trackEvent(EVENT_CHANGE, "language", currentLanguage.lng); }; export const setLanguageFirstTime = async () => { @@ -84,6 +85,7 @@ export const setLanguageFirstTime = async () => { ); languageDetector.cacheUserLanguage(currentLanguage.lng); + trackEvent(EVENT_CHANGE, "language on load", currentLanguage.lng); }; export const getLanguage = () => currentLanguage;