@@ -583,15 +591,15 @@ export default function App() {
const collaborators = new Map();
collaborators.set("id1", {
username: "Doremon",
- avatarUrl: "doremon.png",
+ avatarUrl: "images/doremon.png",
});
collaborators.set("id2", {
username: "Excalibot",
- avatarUrl: "excalibot.png",
+ avatarUrl: "images/excalibot.png",
});
collaborators.set("id3", {
username: "Pika",
- avatarUrl: "pika.jpeg",
+ avatarUrl: "images/pika.jpeg",
});
collaborators.set("id4", {
username: "fallback",
diff --git a/src/packages/excalidraw/example/index.tsx b/src/packages/excalidraw/example/index.tsx
index 825a1016e..0f3bad30f 100644
--- a/src/packages/excalidraw/example/index.tsx
+++ b/src/packages/excalidraw/example/index.tsx
@@ -8,6 +8,9 @@ const root = createRoot(rootElement);
root.render(
-
+ {}}
+ />
,
);
diff --git a/src/packages/excalidraw/example/public/doremon.png b/src/packages/excalidraw/example/public/images/doremon.png
similarity index 100%
rename from src/packages/excalidraw/example/public/doremon.png
rename to src/packages/excalidraw/example/public/images/doremon.png
diff --git a/src/packages/excalidraw/example/public/excalibot.png b/src/packages/excalidraw/example/public/images/excalibot.png
similarity index 100%
rename from src/packages/excalidraw/example/public/excalibot.png
rename to src/packages/excalidraw/example/public/images/excalibot.png
diff --git a/src/packages/excalidraw/example/public/pika.jpeg b/src/packages/excalidraw/example/public/images/pika.jpeg
similarity index 100%
rename from src/packages/excalidraw/example/public/pika.jpeg
rename to src/packages/excalidraw/example/public/images/pika.jpeg
diff --git a/src/packages/excalidraw/example/public/rocket.jpeg b/src/packages/excalidraw/example/public/images/rocket.jpeg
similarity index 100%
rename from src/packages/excalidraw/example/public/rocket.jpeg
rename to src/packages/excalidraw/example/public/images/rocket.jpeg
diff --git a/src/packages/excalidraw/example/sidebar/ExampleSidebar.tsx b/src/packages/excalidraw/example/sidebar/ExampleSidebar.tsx
index 0fa5bf4e0..793d17b05 100644
--- a/src/packages/excalidraw/example/sidebar/ExampleSidebar.tsx
+++ b/src/packages/excalidraw/example/sidebar/ExampleSidebar.tsx
@@ -10,8 +10,8 @@ export default function Sidebar({ children }: { children: React.ReactNode }) {
x
-
- {" "}
+
+ {" "}
diff --git a/src/packages/excalidraw/publicPath.js b/src/packages/excalidraw/publicPath.js
index 0e1f8c3db..8eceb854d 100644
--- a/src/packages/excalidraw/publicPath.js
+++ b/src/packages/excalidraw/publicPath.js
@@ -2,6 +2,12 @@ import { ENV } from "../../constants";
if (process.env.NODE_ENV !== ENV.TEST) {
/* eslint-disable */
/* global __webpack_public_path__:writable */
+ if (process.env.NODE_ENV === ENV.DEVELOPMENT && (
+ window.EXCALIDRAW_ASSET_PATH === undefined ||
+ window.EXCALIDRAW_ASSET_PATH === ""
+ )) {
+ window.EXCALIDRAW_ASSET_PATH = "/";
+ }
__webpack_public_path__ =
window.EXCALIDRAW_ASSET_PATH ||
`https://unpkg.com/${process.env.PKG_NAME}@${process.env.PKG_VERSION}/dist/`;
diff --git a/src/packages/extensions/.gitignore b/src/packages/extensions/.gitignore
new file mode 100644
index 000000000..f06235c46
--- /dev/null
+++ b/src/packages/extensions/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/src/packages/extensions/CHANGELOG.md b/src/packages/extensions/CHANGELOG.md
new file mode 100644
index 000000000..28a78cde2
--- /dev/null
+++ b/src/packages/extensions/CHANGELOG.md
@@ -0,0 +1,24 @@
+# Changelog
+
+
+
+## Unreleased
+
+### Excalidraw Extensions
+
+#### Features
+
+- Render math notation using the MathJax library. Both standard Latex input and simplified AsciiMath input are supported. MathJax support is implemented as a `math` subtype of `ExcalidrawTextElement`.
+
+ Also added plugin-like subtypes for `ExcalidrawElement`. These allow easily supporting custom extensions of `ExcalidrawElement`s such as for MathJax, Markdown, or inline code. [#5311](https://github.com/excalidraw/excalidraw/pull/5311).
+
+- Provided a stub example extension (`./empty/index.ts`).
diff --git a/src/packages/extensions/README.md b/src/packages/extensions/README.md
new file mode 100644
index 000000000..e0fc7f36c
--- /dev/null
+++ b/src/packages/extensions/README.md
@@ -0,0 +1,45 @@
+#### Note
+
+⚠️ ⚠️ ⚠️ You are viewing the docs for the **next** release, in case you want to check the docs for the stable release, you can view it [here](https://www.npmjs.com/package/@excalidraw/extensions).
+
+### Extensions
+
+Excalidraw extensions to be used in Excalidraw.
+
+### Installation
+
+You can use npm
+
+```
+npm install react react-dom @excalidraw/extensions
+```
+
+or via yarn
+
+```
+yarn add react react-dom @excalidraw/extensions
+```
+
+After installation you will see a folder `excalidraw-extensions-assets` and `excalidraw-extensions-assets-dev` in `dist` directory which contains the assets needed for this app in prod and dev mode respectively.
+
+Move the folder `excalidraw-extensions-assets` and `excalidraw-extensions-assets-dev` to the path where your assets are served.
+
+By default it will try to load the files from `https://unpkg.com/@excalidraw/extensions/dist/`
+
+If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_EXTENSIONS_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
+
+#### Note
+
+**If you don't want to wait for the next stable release and try out the unreleased changes you can use `@excalidraw/extensions@next`.**
+
+### Need help?
+
+Check out the existing [Q&A](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aextensions). If you have any queries or need help, ask us [here](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aextensions).
+
+### Development
+
+#### Install the dependencies
+
+```bash
+yarn
+```
diff --git a/src/packages/extensions/env.js b/src/packages/extensions/env.js
new file mode 100644
index 000000000..c4a84acd7
--- /dev/null
+++ b/src/packages/extensions/env.js
@@ -0,0 +1,18 @@
+const dotenv = require("dotenv");
+const { readFileSync } = require("fs");
+const pkg = require("./package.json");
+const parseEnvVariables = (filepath) => {
+ const envVars = Object.entries(dotenv.parse(readFileSync(filepath))).reduce(
+ (env, [key, value]) => {
+ env[key] = JSON.stringify(value);
+ return env;
+ },
+ {},
+ );
+ envVars.PKG_NAME = JSON.stringify(pkg.name);
+ envVars.PKG_VERSION = JSON.stringify(pkg.version);
+ envVars.IS_EXCALIDRAW_EXTENSIONS_NPM_PACKAGE = JSON.stringify(true);
+ return envVars;
+};
+
+module.exports = { parseEnvVariables };
diff --git a/src/packages/extensions/example/index.tsx b/src/packages/extensions/example/index.tsx
new file mode 100644
index 000000000..9fb60715e
--- /dev/null
+++ b/src/packages/extensions/example/index.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import ReactDOM from "react-dom";
+
+import App from "../../excalidraw/example/App";
+
+declare global {
+ interface Window {
+ ExcalidrawExtensionsLib: any;
+ }
+}
+const { useExtensions } = window.ExcalidrawExtensionsLib;
+
+const rootElement = document.getElementById("root");
+ReactDOM.render(
+
+
+ ,
+ rootElement,
+);
diff --git a/src/packages/extensions/example/public/excalidraw-assets-dev b/src/packages/extensions/example/public/excalidraw-assets-dev
new file mode 120000
index 000000000..925b03c6b
--- /dev/null
+++ b/src/packages/extensions/example/public/excalidraw-assets-dev
@@ -0,0 +1 @@
+../../../excalidraw/dist/excalidraw-assets-dev/
\ No newline at end of file
diff --git a/src/packages/extensions/example/public/excalidraw.development.js b/src/packages/extensions/example/public/excalidraw.development.js
new file mode 120000
index 000000000..20b05edfe
--- /dev/null
+++ b/src/packages/extensions/example/public/excalidraw.development.js
@@ -0,0 +1 @@
+../../../excalidraw/dist/excalidraw.development.js
\ No newline at end of file
diff --git a/src/packages/extensions/example/public/images b/src/packages/extensions/example/public/images
new file mode 120000
index 000000000..c2539a4af
--- /dev/null
+++ b/src/packages/extensions/example/public/images
@@ -0,0 +1 @@
+../../../excalidraw/example/public/images/
\ No newline at end of file
diff --git a/src/packages/extensions/example/public/index.html b/src/packages/extensions/example/public/index.html
new file mode 100644
index 000000000..b455b65bf
--- /dev/null
+++ b/src/packages/extensions/example/public/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
React App
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/packages/extensions/index.ts b/src/packages/extensions/index.ts
new file mode 100644
index 000000000..cfffae3ad
--- /dev/null
+++ b/src/packages/extensions/index.ts
@@ -0,0 +1,3 @@
+import "./publicPath";
+
+export * from "./ts/node-main";
diff --git a/src/packages/extensions/package.json b/src/packages/extensions/package.json
new file mode 100644
index 000000000..e65450e1d
--- /dev/null
+++ b/src/packages/extensions/package.json
@@ -0,0 +1,86 @@
+{
+ "name": "@excalidraw/extensions",
+ "version": "0.12.0",
+ "main": "index.ts",
+ "files": [
+ "dist/*"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "description": "Excalidraw extensions",
+ "repository": "https://github.com/excalidraw/excalidraw",
+ "license": "MIT",
+ "keywords": [
+ "excalidraw",
+ "excalidraw-embed",
+ "react",
+ "npm",
+ "npm excalidraw"
+ ],
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all",
+ "not safari < 12",
+ "not kaios <= 2.5",
+ "not edge < 79",
+ "not chrome < 70",
+ "not and_uc < 13",
+ "not samsung < 10"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "peerDependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@babel/core": "7.18.9",
+ "@babel/plugin-transform-arrow-functions": "7.18.6",
+ "@babel/plugin-transform-async-to-generator": "7.18.6",
+ "@babel/plugin-transform-runtime": "7.18.9",
+ "@babel/plugin-transform-typescript": "7.18.8",
+ "@babel/preset-env": "7.18.6",
+ "@babel/preset-react": "7.18.6",
+ "@babel/preset-typescript": "7.18.6",
+ "autoprefixer": "10.4.7",
+ "babel-loader": "8.2.5",
+ "babel-plugin-transform-class-properties": "6.24.1",
+ "cross-env": "7.0.3",
+ "css-loader": "6.7.1",
+ "dotenv": "16.0.1",
+ "mini-css-extract-plugin": "2.6.1",
+ "postcss-loader": "7.0.1",
+ "sass-loader": "13.0.2",
+ "terser-webpack-plugin": "5.3.3",
+ "ts-loader": "9.3.1",
+ "typescript": "4.7.4",
+ "webpack": "5.73.0",
+ "webpack-bundle-analyzer": "4.5.0",
+ "webpack-cli": "4.10.0",
+ "webpack-dev-server": "4.9.3",
+ "webpack-merge": "5.8.0"
+ },
+ "bugs": "https://github.com/excalidraw/excalidraw/issues",
+ "homepage": "https://github.com/excalidraw/excalidraw/tree/master/src/packages/extensions",
+ "scripts": {
+ "gen:types": "tsc --project ../../../tsconfig-types.json",
+ "build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && yarn gen:types",
+ "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
+ "pack": "yarn build:umd && yarn pack",
+ "start": "webpack serve --config webpack.dev-server.config.js",
+ "install:deps": "yarn install --frozen-lockfile && yarn --cwd ../../../",
+ "build:deps": "yarn --cwd ../excalidraw cross-env NODE_ENV=development webpack --config webpack.dev.config.js",
+ "build:example": "EXAMPLE=true webpack --config webpack.dev-server.config.js && yarn gen:types"
+ },
+ "dependencies": {
+ "mathjax-full": "3.2.2"
+ }
+}
diff --git a/src/packages/extensions/publicPath.js b/src/packages/extensions/publicPath.js
new file mode 100644
index 000000000..0368ae686
--- /dev/null
+++ b/src/packages/extensions/publicPath.js
@@ -0,0 +1,14 @@
+import { ENV } from "../../constants";
+if (process.env.NODE_ENV !== ENV.TEST) {
+ /* eslint-disable */
+ /* global __webpack_public_path__:writable */
+ if (process.env.NODE_ENV === ENV.DEVELOPMENT && (
+ window.EXCALIDRAW_EXTENSIONS_ASSET_PATH === undefined ||
+ window.EXCALIDRAW_EXTENSIONS_ASSET_PATH === ""
+ )) {
+ window.EXCALIDRAW_EXTENSIONS_ASSET_PATH = "/";
+ }
+ __webpack_public_path__ =
+ window.EXCALIDRAW_EXTENSIONS_ASSET_PATH ||
+ `https://unpkg.com/${process.env.PKG_NAME}@${process.env.PKG_VERSION}/dist/`;
+}
diff --git a/src/packages/extensions/ts/empty/index.ts b/src/packages/extensions/ts/empty/index.ts
new file mode 100644
index 000000000..bc69a90ce
--- /dev/null
+++ b/src/packages/extensions/ts/empty/index.ts
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+import { ExcalidrawImperativeAPI } from "../../../../types";
+
+// Extension authors: provide a extension name here like "myextension"
+export const EmptyExtension = "empty";
+
+// Extension authors: provide a hook like `useMyExtension` in `myextension/index`
+export const useEmptyExtension = (api: ExcalidrawImperativeAPI | null) => {
+ const enabled = emptyExtensionLoadable;
+ useEffect(() => {
+ if (enabled) {
+ }
+ }, [enabled, api]);
+};
+
+// Extension authors: Use a variable like `myExtensionLoadable` to determine
+// whether or not to do anything in each of `useMyExtension` and `testMyExtension`.
+let emptyExtensionLoadable = false;
+
+export const getEmptyExtensionLoadable = () => {
+ return emptyExtensionLoadable;
+};
+
+export const setEmptyExtensionLoadable = (loadable: boolean) => {
+ emptyExtensionLoadable = loadable;
+};
diff --git a/src/packages/extensions/ts/global.d.ts b/src/packages/extensions/ts/global.d.ts
new file mode 100644
index 000000000..c1808376d
--- /dev/null
+++ b/src/packages/extensions/ts/global.d.ts
@@ -0,0 +1,4 @@
+declare module SREfeature {
+ function custom(locale: string): Promise
;
+ export = custom;
+}
diff --git a/src/packages/extensions/ts/mathjax/icon.tsx b/src/packages/extensions/ts/mathjax/icon.tsx
new file mode 100644
index 000000000..e45550d22
--- /dev/null
+++ b/src/packages/extensions/ts/mathjax/icon.tsx
@@ -0,0 +1,13 @@
+import { Theme } from "../../../../element/types";
+import { createIcon, iconFillColor } from "../../../../components/icons";
+
+// We inline font-awesome icons in order to save on js size rather than including the font awesome react library
+export const mathSubtypeIcon = ({ theme }: { theme: Theme }) =>
+ createIcon(
+ ,
+ { width: 576, height: 512, mirror: true, strokeWidth: 1.25 },
+ );
diff --git a/src/packages/extensions/ts/mathjax/implementation.tsx b/src/packages/extensions/ts/mathjax/implementation.tsx
new file mode 100644
index 000000000..e188ed835
--- /dev/null
+++ b/src/packages/extensions/ts/mathjax/implementation.tsx
@@ -0,0 +1,1507 @@
+// Some imports
+import fallbackMathJaxLangData from "./locales/en.json";
+import { FONT_FAMILY, SVG_NS } from "../../../../constants";
+import { getFontString, getFontFamilyString, isRTL } from "../../../../utils";
+import {
+ getApproxLineHeight,
+ getBoundTextElement,
+ getContainerElement,
+ getTextWidth,
+ measureText,
+ wrapText,
+} from "../../../../element/textElement";
+import {
+ hasBoundTextElement,
+ isTextElement,
+} from "../../../../element/typeChecks";
+import {
+ ExcalidrawElement,
+ ExcalidrawTextElement,
+ NonDeleted,
+} from "../../../../element/types";
+import { newElementWith } from "../../../../element/mutateElement";
+import { getElementAbsoluteCoords } from "../../../../element/bounds";
+
+// Imports for actions
+import { t, registerAuxLangData } from "../../../../i18n";
+import { Action } from "../../../../actions/types";
+import { AppState } from "../../../../types";
+import {
+ changeProperty,
+ getFormValue,
+} from "../../../../actions/actionProperties";
+import { getSelectedElements } from "../../../../scene";
+import {
+ getNonDeletedElements,
+ redrawTextBoundingBox,
+} from "../../../../element";
+import { ButtonSelect } from "../../../../components/ButtonSelect";
+
+// Subtype imports
+import {
+ SubtypeLoadedCb,
+ SubtypeMethods,
+ SubtypePrepFn,
+} from "../../../../subtypes";
+import { mathSubtypeIcon } from "./icon";
+import { getMathSubtypeRecord } from "./types";
+import { SubtypeButton } from "../../../../components/SubtypeButton";
+import { getMaxContainerWidth } from "../../../../element/newElement";
+
+const mathSubtype = getMathSubtypeRecord().subtype;
+const FONT_FAMILY_MATH = FONT_FAMILY.Helvetica;
+type MathProps = Record<"useTex" | "mathOnly", boolean>;
+
+type ExcalidrawMathElement = ExcalidrawTextElement &
+ Readonly<{
+ subtype: typeof mathSubtype;
+ }>;
+
+const isMathElement = (
+ element: ExcalidrawElement | null,
+): element is ExcalidrawMathElement => {
+ return (
+ isTextElement(element) &&
+ "subtype" in element &&
+ element.subtype === mathSubtype
+ );
+};
+
+class GetMathProps {
+ private useTex: boolean = true;
+ private mathOnly: boolean = false;
+ getUseTex = (appState?: AppState): boolean => {
+ const mathProps =
+ appState?.customData && appState.customData[`${mathSubtype}`];
+ if (mathProps !== undefined) {
+ this.useTex = mathProps.useTex !== undefined ? mathProps.useTex : true;
+ }
+ return this.useTex;
+ };
+
+ getMathOnly = (appState?: AppState): boolean => {
+ const mathProps =
+ appState?.customData && appState.customData[`${mathSubtype}`];
+ if (mathProps !== undefined) {
+ this.mathOnly =
+ mathProps.mathOnly !== undefined ? mathProps.mathOnly : false;
+ }
+ return this.mathOnly;
+ };
+
+ ensureMathProps = (props: ExcalidrawElement["customData"]): MathProps => {
+ const mathProps: MathProps = {
+ useTex:
+ props !== undefined && props.useTex !== undefined
+ ? props.useTex
+ : this.useTex,
+ mathOnly:
+ props !== undefined && props.mathOnly !== undefined
+ ? props.mathOnly
+ : this.mathOnly,
+ };
+ return mathProps;
+ };
+}
+const getMathProps = new GetMathProps();
+
+const getStartDelimiter = (useTex: boolean): string => {
+ return useTex ? "\\(" : "`";
+};
+
+const getEndDelimiter = (useTex: boolean): string => {
+ return useTex ? "\\)" : "`";
+};
+
+const mathJax = {} as {
+ adaptor: any;
+ amHtml: any;
+ texHtml: any;
+ visitor: any;
+ mmlSvg: any;
+ mmlSre: any;
+};
+
+let stopLoadingMathJax = false;
+let mathJaxLoaded = false;
+let mathJaxLoading = false;
+let mathJaxLoadedCallback: SubtypeLoadedCb | undefined;
+
+// Configure use or non-use of speech-rule-engine
+const useSRE = false;
+
+let errorSvg: string;
+let errorAria: string;
+
+const loadMathJax = async () => {
+ const shouldLoad =
+ !mathJaxLoaded &&
+ !mathJaxLoading &&
+ (mathJax.adaptor === undefined ||
+ mathJax.amHtml === undefined ||
+ mathJax.texHtml === undefined ||
+ mathJax.visitor === undefined ||
+ mathJax.mmlSvg === undefined ||
+ (useSRE && mathJax.mmlSre === undefined));
+ if (!shouldLoad && !mathJaxLoaded) {
+ stopLoadingMathJax = true;
+ }
+ if (!mathJaxLoaded) {
+ mathJaxLoading = true;
+
+ // MathJax components we use
+ const AsciiMath = (await import("mathjax-full/js/input/asciimath"))
+ .AsciiMath;
+ const TeX = (await import("mathjax-full/js/input/tex")).TeX;
+ const SVG = (await import("mathjax-full/js/output/svg")).SVG;
+ const liteAdaptor = (await import("mathjax-full/js/adaptors/liteAdaptor"))
+ .liteAdaptor;
+ const RegisterHTMLHandler = (await import("mathjax-full/js/handlers/html"))
+ .RegisterHTMLHandler;
+
+ // Components for MathJax accessibility
+ const MathML = (await import("mathjax-full/js/input/mathml")).MathML;
+ const SerializedMmlVisitor = (
+ await import("mathjax-full/js/core/MmlTree/SerializedMmlVisitor")
+ ).SerializedMmlVisitor;
+ const Sre = useSRE
+ ? (await import("mathjax-full/js/a11y/sre")).Sre
+ : undefined;
+
+ // Import some TeX packages
+ await import("mathjax-full/js/input/tex/ams/AmsConfiguration");
+ await import(
+ "mathjax-full/js/input/tex/boldsymbol/BoldsymbolConfiguration"
+ );
+
+ // Set the following to "true" to import the "mhchem" and "physics" packages.
+ const includeMhchemPhysics = false;
+ if (includeMhchemPhysics) {
+ await import("mathjax-full/js/input/tex/mhchem/MhchemConfiguration");
+ await import("mathjax-full/js/input/tex/physics/PhysicsConfiguration");
+ }
+ const texPackages = includeMhchemPhysics
+ ? ["base", "ams", "boldsymbol", "mhchem", "physics"]
+ : ["base", "ams", "boldsymbol"];
+
+ // Types needed to lazy-load MathJax
+ const LiteElement = (await import("mathjax-full/js/adaptors/lite/Element"))
+ .LiteElement;
+ const LiteText = (await import("mathjax-full/js/adaptors/lite/Text"))
+ .LiteText;
+ const LiteDocument = (
+ await import("mathjax-full/js/adaptors/lite/Document")
+ ).LiteDocument;
+
+ // Configure AsciiMath to use the "display" option. See
+ // https://github.com/mathjax/MathJax/issues/2520#issuecomment-1128831182.
+ const MathJax = (
+ await require("mathjax-full/js/input/asciimath/mathjax2/legacy/MathJax")
+ ).MathJax;
+ MathJax.InputJax.AsciiMath.AM.Augment({ displaystyle: false });
+
+ // Load the document creator last
+ const mathjax = (await import("mathjax-full/js/mathjax")).mathjax;
+
+ type E = typeof LiteElement;
+ type T = typeof LiteText;
+ type D = typeof LiteDocument;
+ if (stopLoadingMathJax) {
+ stopLoadingMathJax = false;
+ return;
+ }
+
+ // Set up shared components
+ mathJax.adaptor = liteAdaptor();
+ mathJax.visitor = new SerializedMmlVisitor();
+ RegisterHTMLHandler(mathJax.adaptor);
+
+ // Set up input components
+ const asciimath = new AsciiMath({});
+ const tex = new TeX({ packages: texPackages });
+
+ // Set up output components
+ const mml = new MathML();
+ const svg = new SVG({ fontCache: "local" });
+
+ // AsciiMath input
+ mathJax.amHtml = mathjax.document("", { InputJax: asciimath });
+
+ // LaTeX input
+ mathJax.texHtml = mathjax.document("", { InputJax: tex });
+
+ // Capture the MathML for accessibility purposes
+ mathJax.mmlSvg = mathjax.document("", {
+ InputJax: mml,
+ OutputJax: svg,
+ });
+
+ const mathJaxReady = function () {
+ mathJax.mmlSre = Sre;
+
+ // Error indicator
+ const errorMML = mathJax.visitor.visitTree(
+ mathJax.texHtml.convert("ERR", { display: false }),
+ );
+ errorSvg = mathJax.adaptor.outerHTML(mathJax.mmlSvg.convert(errorMML));
+ errorAria = useSRE ? mathJax.mmlSre.toSpeech(errorMML) : "ERR";
+
+ // Finalize loading MathJax
+ mathJaxLoaded = true;
+ if (mathJaxLoadedCallback !== undefined) {
+ mathJaxLoadedCallback(isMathElement);
+ }
+ };
+
+ if (useSRE) {
+ // Set up a custom loader to use our local mathmaps
+ const custom = (locale: string) => {
+ return new Promise((resolve, reject) => {
+ try {
+ const mathmap = JSON.stringify(
+ require(`mathjax-full/es5/sre/mathmaps/${locale}.json`),
+ );
+ resolve(mathmap);
+ } catch (e) {
+ reject("");
+ }
+ });
+ };
+ global.SREfeature = { custom };
+
+ // Configure MathJax for accessibility
+ Sre?.setupEngine({ speech: "shallow", custom: true }).then(() => {
+ mathJaxReady();
+ });
+ } else {
+ mathJaxReady();
+ }
+ }
+};
+
+// Round `x` to `n` decimal places
+const roundDec = (x: number, n: number) => {
+ const powOfTen = Math.pow(10, n);
+ return Math.round(x * powOfTen) / powOfTen;
+};
+
+const splitMath = (text: string, mathProps: MathProps) => {
+ const startDelimiter = getStartDelimiter(mathProps.useTex);
+ const endDelimiter = getEndDelimiter(mathProps.useTex);
+ let curIndex = 0;
+ let oldIndex = 0;
+ const array = [];
+
+ const mathFirst = text.indexOf(startDelimiter, 0) === 0;
+ let inText = !mathFirst;
+ if (!inText) {
+ array.push("");
+ }
+ while (oldIndex >= 0 && curIndex >= 0) {
+ oldIndex =
+ curIndex +
+ (inText
+ ? curIndex > 0
+ ? endDelimiter.length
+ : 0
+ : startDelimiter.length);
+ curIndex = text.indexOf(inText ? startDelimiter : endDelimiter, oldIndex);
+ if (curIndex >= oldIndex || (curIndex < 0 && oldIndex > 0)) {
+ inText = !inText;
+ array.push(
+ text.substring(oldIndex, curIndex >= 0 ? curIndex : text.length),
+ );
+ }
+ }
+ if (array.length === 0 && !mathFirst) {
+ array[0] = text;
+ }
+
+ return array;
+};
+
+const joinMath = (
+ text: string[],
+ mathProps: MathProps,
+ isMathJaxLoaded: boolean,
+) => {
+ const startDelimiter = getStartDelimiter(mathProps.useTex);
+ const endDelimiter = getEndDelimiter(mathProps.useTex);
+ let inText = true;
+ let joined = "";
+ for (let index = 0; index < text.length; index++) {
+ const space = index > 0 ? " " : "";
+ joined +=
+ mathProps.mathOnly && isMathJaxLoaded
+ ? `${space}${text[index]}`
+ : inText
+ ? text[index]
+ : startDelimiter + text[index] + endDelimiter;
+ inText = !inText;
+ }
+ return joined;
+};
+
+const getMathNewline = (mathProps: MathProps) => {
+ return mathProps.useTex && mathProps.mathOnly ? "\\\\" : "\n";
+};
+
+// This lets math input run across multiple newlines.
+// Basically, replace with a space each newline between the delimiters.
+// Do so unless it's AsciiMath in math-only mode.
+const consumeMathNewlines = (
+ text: string,
+ mathProps: MathProps,
+ isMathJaxLoaded: boolean,
+) => {
+ const tempText = splitMath(text.replace(/\r\n?/g, "\n"), mathProps);
+ if (mathProps.useTex || !mathProps.mathOnly) {
+ for (let i = 0; i < tempText.length; i++) {
+ if (i % 2 === 1 || mathProps.mathOnly) {
+ tempText[i] = tempText[i].replace(/\n/g, " ");
+ }
+ }
+ }
+ return joinMath(tempText, mathProps, isMathJaxLoaded);
+};
+
+// Cache the SVGs from MathJax
+const mathJaxSvgCacheAM = {} as {
+ [key: string]: { svg: string; aria: string };
+};
+const mathJaxSvgCacheTex = {} as {
+ [key: string]: { svg: string; aria: string };
+};
+// Cache the results of getMetrics()
+const metricsCache = {} as {
+ [key: string]: {
+ markupMetrics: Array<{
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ }>[];
+ lineMetrics: Array<{ width: number; height: number; baseline: number }>;
+ imageMetrics: { width: number; height: number; baseline: number };
+ };
+};
+// Cache the SVGs for renderSvgMathElement()
+const svgCache = {} as { [key: string]: string };
+// Cache the rendered MathJax images for renderMathElement()
+const imageCache = {} as { [key: string]: HTMLImageElement };
+
+const textAsMjxContainer = (
+ text: string,
+ isMathJaxLoaded: boolean,
+): Element | null => {
+ // Put the content in a div to check whether it is SVG or Text
+ const container = document.createElement("div");
+ container.innerHTML = text;
+ // The mjx-container has child nodes, while Text nodes do not.
+ // Check for the "viewBox" attribute to determine if it's SVG.
+ const childIsSvg =
+ isMathJaxLoaded &&
+ container.childNodes &&
+ container.childNodes.length > 0 &&
+ container.childNodes[0].hasChildNodes() &&
+ container.childNodes[0].childNodes[0].nodeName === "svg";
+ // Conditionally return the mjx-container
+ return childIsSvg ? (container.children[0] as Element) : null;
+};
+
+const math2Svg = (
+ text: string,
+ mathProps: MathProps,
+ isMathJaxLoaded: boolean,
+): { svg: string; aria: string } => {
+ const useTex = mathProps.useTex;
+ if (
+ isMathJaxLoaded &&
+ (useTex ? mathJaxSvgCacheTex[text] : mathJaxSvgCacheAM[text])
+ ) {
+ return useTex ? mathJaxSvgCacheTex[text] : mathJaxSvgCacheAM[text];
+ }
+ loadMathJax();
+ try {
+ const userOptions = { display: mathProps.mathOnly };
+ // Intermediate MathML
+ const mmlString = isMathJaxLoaded
+ ? mathJax.visitor.visitTree(
+ useTex
+ ? mathJax.texHtml.convert(text, userOptions)
+ : mathJax.amHtml.convert(text, userOptions),
+ )
+ : text;
+ // For rendering
+ const htmlString = isMathJaxLoaded
+ ? mathJax.adaptor.outerHTML(mathJax.mmlSvg.convert(mmlString))
+ : text;
+ // For accessibility
+ const ariaString = isMathJaxLoaded
+ ? useSRE
+ ? mathJax.mmlSre.toSpeech(mmlString)
+ : text
+ : mmlString;
+ if (isMathJaxLoaded) {
+ if (useTex) {
+ mathJaxSvgCacheTex[text] = { svg: htmlString, aria: ariaString };
+ } else {
+ mathJaxSvgCacheAM[text] = { svg: htmlString, aria: ariaString };
+ }
+ }
+ return { svg: htmlString, aria: ariaString };
+ } catch {
+ if (isMathJaxLoaded) {
+ return { svg: errorSvg, aria: errorAria };
+ }
+ return { svg: text, aria: text };
+ }
+};
+
+const markupText = (
+ text: string,
+ mathProps: MathProps,
+ isMathJaxLoaded: boolean,
+) => {
+ const lines = consumeMathNewlines(text, mathProps, isMathJaxLoaded).split(
+ getMathNewline(mathProps),
+ );
+ const markup = [] as Array[];
+ const aria = [] as Array[];
+ for (let index = 0; index < lines.length; index++) {
+ markup.push([]);
+ aria.push([]);
+ if (!isMathJaxLoaded) {
+ // Run lines[index] through math2Svg so loadMathJax() gets called
+ const math = math2Svg(lines[index], mathProps, isMathJaxLoaded);
+ markup[index].push(math.svg);
+ aria[index].push(math.aria);
+ continue;
+ }
+ // Don't split by the delimiter in math-only mode
+ const lineArray =
+ mathProps.mathOnly || !isMathJaxLoaded
+ ? [lines[index]]
+ : splitMath(lines[index], mathProps);
+ for (let i = 0; i < lineArray.length; i++) {
+ // Don't guard the following as "isMathJaxLoaded && i % 2 === 1"
+ // in order to ensure math2Svg() actually gets called, and thus
+ // loadMathJax().
+ if (i % 2 === 1 || mathProps.mathOnly) {
+ const math = math2Svg(lineArray[i], mathProps, isMathJaxLoaded);
+ markup[index].push(math.svg);
+ aria[index].push(math.aria);
+ } else {
+ markup[index].push(lineArray[i]);
+ aria[index].push(lineArray[i]);
+ }
+ }
+ if (lineArray.length === 0) {
+ markup[index].push("");
+ aria[index].push("");
+ }
+ }
+ return { markup, aria };
+};
+
+const getCacheKey = (
+ text: string,
+ fontSize: number,
+ strokeColor: String,
+ textAlign: string,
+ opacity: Number,
+ mathProps: MathProps,
+) => {
+ const key = `${text}, ${fontSize}, ${strokeColor}, ${textAlign}, ${opacity}, ${mathProps.useTex}, ${mathProps.mathOnly}`;
+ return key;
+};
+
+const measureMarkup = (
+ markup: Array,
+ fontSize: number,
+ mathProps: MathProps,
+ isMathJaxLoaded: boolean,
+ maxWidth?: number | null,
+) => {
+ const font = getFontString({ fontSize, fontFamily: FONT_FAMILY_MATH });
+ const container = document.createElement("div");
+ container.style.position = "absolute";
+ container.style.whiteSpace = "pre";
+ container.style.font = font;
+ container.style.minHeight = "1em";
+
+ if (maxWidth) {
+ const lineHeight = getApproxLineHeight(font);
+ container.style.maxWidth = `${String(maxWidth)}px`;
+ container.style.overflow = "hidden";
+ container.style.wordBreak = "break-word";
+ container.style.lineHeight = `${String(lineHeight)}px`;
+ container.style.whiteSpace = "pre-wrap";
+ }
+ document.body.appendChild(container);
+
+ // Sanitize possible HTML entered
+ for (let i = 0; i < markup.length; i++) {
+ // This should be an mjx-container
+ if ((markup[i] as Element).hasChildNodes) {
+ // Append as HTML
+ container.appendChild(markup[i] as Element);
+ } else {
+ // Append as text
+ container.append(markup[i]);
+ }
+ }
+
+ // Now creating 1px sized item that will be aligned to baseline
+ // to calculate baseline shift
+ const span = document.createElement("span");
+ span.style.display = "inline-block";
+ span.style.overflow = "hidden";
+ span.style.width = "1px";
+ span.style.height = "1px";
+ container.appendChild(span);
+ // Baseline is important for positioning text on canvas
+ const baseline = span.offsetTop + span.offsetHeight;
+ const width = container.offsetWidth + 1;
+ const height = container.offsetHeight;
+
+ const containerRect = container.getBoundingClientRect();
+ // Compute for each SVG or Text child node of line (the last
+ // child is the span element for the baseline).
+ const childMetrics = [];
+ let nextX = 0;
+ for (let i = 0; i < container.childNodes.length - 1; i++) {
+ // The mjx-container has child nodes, while Text nodes do not
+ const childIsSvg =
+ isMathJaxLoaded &&
+ ((container.childNodes[i].hasChildNodes() &&
+ container.childNodes[i].childNodes[0].nodeName === "svg") ||
+ mathProps.mathOnly);
+ // The mjx-container element or the Text node
+ const child = container.childNodes[i] as HTMLElement | Text;
+ if (isMathJaxLoaded && (mathProps.mathOnly || childIsSvg)) {
+ // The svg element
+ const grandchild = (child as HTMLElement).firstChild as HTMLElement;
+ const grandchildRect = grandchild.getBoundingClientRect();
+ childMetrics.push({
+ x: grandchildRect.x - containerRect.x,
+ y: grandchildRect.y - containerRect.y,
+ width: grandchildRect.width,
+ height: grandchildRect.height,
+ });
+ // Set the x value for the next Text node
+ nextX = grandchildRect.x + grandchildRect.width - containerRect.x;
+ } else {
+ // The Text node
+ const grandchild = child as Text;
+ const text = grandchild.textContent ?? "";
+ if (text !== "") {
+ const textMetrics = measureText(text, font, maxWidth);
+ childMetrics.push({
+ x: nextX,
+ y: baseline,
+ width: textMetrics.width,
+ height: textMetrics.height,
+ });
+ }
+ }
+ }
+ if (childMetrics.length === 0) {
+ // Avoid crashes in getMetrics()
+ childMetrics.push({ x: 0, y: 0, width: 0, height: 0 });
+ }
+ document.body.removeChild(container);
+ return { width, height, baseline, childMetrics };
+};
+
+const getMetrics = (
+ markup: string[][],
+ fontSize: number,
+ mathProps: MathProps,
+ isMathJaxLoaded: boolean,
+ maxWidth?: number | null,
+) => {
+ let key = `${fontSize} ${mathProps.useTex} ${mathProps.mathOnly} ${isMathJaxLoaded} ${maxWidth}`;
+ for (let index = 0; index < markup.length; index++) {
+ for (let i = 0; i < markup[index].length; i++) {
+ key += markup[index][i];
+ }
+ if (index < markup.length - 1) {
+ key += "\n";
+ }
+ }
+ const cKey = key;
+ if (isMathJaxLoaded && metricsCache[cKey]) {
+ return metricsCache[cKey];
+ }
+ const markupMetrics = [] as Array<{
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ }>[];
+ const lineMetrics = [] as Array<{
+ width: number;
+ height: number;
+ baseline: number;
+ }>;
+ let imageWidth = 0;
+ let imageHeight = 0;
+ for (let index = 0; index < markup.length; index++) {
+ // We pass an array of mjx-containers and strings
+ const lineMarkup = [] as Array;
+ for (let i = 0; i < markup[index].length; i++) {
+ const mjx = textAsMjxContainer(markup[index][i], isMathJaxLoaded);
+ lineMarkup.push(mjx !== null ? mjx : markup[index][i]);
+ }
+
+ // Use the browser's measurements by temporarily attaching
+ // the rendered line to the document.body.
+ const { width, height, baseline, childMetrics } = measureMarkup(
+ lineMarkup,
+ fontSize,
+ mathProps,
+ isMathJaxLoaded,
+ maxWidth,
+ );
+
+ markupMetrics.push(childMetrics);
+ lineMetrics.push({ width, height, baseline });
+ imageWidth = Math.max(imageWidth, width);
+ imageHeight += height;
+ }
+ const lastLineMetrics = lineMetrics[lineMetrics.length - 1];
+ const imageMetrics = {
+ width: imageWidth,
+ height: imageHeight,
+ baseline: imageHeight - lastLineMetrics.height + lastLineMetrics.baseline,
+ };
+ const metrics = { markupMetrics, lineMetrics, imageMetrics };
+ if (isMathJaxLoaded) {
+ metricsCache[cKey] = metrics;
+ }
+ return metrics;
+};
+
+const renderMath = (
+ text: string,
+ fontSize: number,
+ textAlign: string,
+ mathProps: MathProps,
+ isMathJaxLoaded: boolean,
+ doSetupChild: (
+ childIsSvg: boolean,
+ svg: SVGSVGElement | null,
+ text: string,
+ rtl: boolean,
+ childRtl: boolean,
+ lineHeight: number,
+ ) => void,
+ doRenderChild: (x: number, y: number, width: number, height: number) => void,
+ parentWidth?: number,
+): string => {
+ const mathLines = consumeMathNewlines(text, mathProps, isMathJaxLoaded).split(
+ getMathNewline(mathProps),
+ );
+ const { markup, aria } = markupText(text, mathProps, isMathJaxLoaded);
+ const metrics = getMetrics(markup, fontSize, mathProps, isMathJaxLoaded);
+ const width = parentWidth ?? metrics.imageMetrics.width;
+
+ let y = 0;
+ for (let index = 0; index < markup.length; index++) {
+ const lineMetrics = metrics.lineMetrics[index];
+ const lineMarkupMetrics = metrics.markupMetrics[index];
+ const rtl = isRTL(mathLines[index]);
+ const x =
+ textAlign === "right"
+ ? width - lineMetrics.width
+ : textAlign === "left"
+ ? 0
+ : (width - lineMetrics.width) / 2;
+ // Drop any empty strings from this line to match childMetrics
+ const content = markup[index].filter((value) => value !== "");
+ for (let i = 0; i < content.length; i += 1) {
+ const mjx = textAsMjxContainer(
+ content[mathProps.mathOnly ? 0 : i],
+ isMathJaxLoaded,
+ );
+ // If we got an mjx-container, then assume it contains an SVG child
+ const childIsSvg = mjx !== null;
+ const svg = childIsSvg ? (mjx.children[0] as SVGSVGElement) : null;
+ const childRtl = childIsSvg
+ ? false
+ : isRTL(content[mathProps.mathOnly ? 0 : i]);
+ // Set up the child for rendering
+ const height = lineMetrics.height;
+ doSetupChild(childIsSvg, svg, content[i], rtl, childRtl, height);
+ // Don't offset when we have an empty string.
+ const nullContent = content.length > 0 && content[i] === "";
+ const childX = nullContent ? 0 : lineMarkupMetrics[i].x;
+ const childY = nullContent ? 0 : lineMarkupMetrics[i].y;
+ const childWidth = nullContent ? 0 : lineMarkupMetrics[i].width;
+ const childHeight = nullContent ? 0 : lineMarkupMetrics[i].height;
+ // Now render the child
+ doRenderChild(x + childX, y + childY, childWidth, childHeight);
+ }
+ y += lineMetrics.height;
+ }
+ let ariaText = "";
+ for (let i = 0; i < aria.length; i++) {
+ if (i > 0) {
+ ariaText = `${ariaText} `;
+ }
+ for (let j = 0; j < aria[i].length; j++) {
+ ariaText = `${ariaText}${aria[i][j]}`;
+ }
+ }
+ return ariaText;
+};
+
+const getImageMetrics = (
+ text: string,
+ fontSize: number,
+ mathProps: MathProps,
+ isMathJaxLoaded: boolean,
+ maxWidth?: number | null,
+) => {
+ const markup = markupText(text, mathProps, isMathJaxLoaded).markup;
+ return getMetrics(markup, fontSize, mathProps, isMathJaxLoaded, maxWidth)
+ .imageMetrics;
+};
+
+const getSelectedMathElements = (
+ elements: readonly ExcalidrawElement[],
+ appState: Readonly,
+): NonDeleted[] => {
+ const selectedElements = getSelectedElements(
+ getNonDeletedElements(elements),
+ appState,
+ );
+ if (appState.editingElement) {
+ selectedElements.push(appState.editingElement);
+ }
+ const eligibleElements = selectedElements.filter(
+ (element, index, eligibleElements) =>
+ isMathElement(element) ||
+ (hasBoundTextElement(element) &&
+ isMathElement(getBoundTextElement(element))),
+ ) as NonDeleted[];
+ return eligibleElements;
+};
+
+// Be sure customData is defined with proper values for ExcalidrawMathElements
+const ensureMathElement = (element: Partial) => {
+ if (!isMathElement(element as Required)) {
+ return;
+ }
+ const mathProps = getMathProps.ensureMathProps(element.customData);
+ (element as any).customData = {
+ ...element.customData,
+ useTex: mathProps.useTex,
+ mathOnly: mathProps.mathOnly,
+ } as ExcalidrawElement["customData"];
+};
+
+const cleanMathElementUpdate = function (updates) {
+ const oldUpdates = {};
+ for (const key in updates) {
+ if (key !== "fontFamily") {
+ (oldUpdates as any)[key] = (updates as any)[key];
+ }
+ if (key === "customData") {
+ const mathProps = getMathProps.ensureMathProps((updates as any)[key]);
+ (updates as any)[key] = {
+ ...(updates as any)[key],
+ useTex: mathProps.useTex,
+ mathOnly: mathProps.mathOnly,
+ };
+ } else {
+ (updates as any)[key] = (updates as any)[key];
+ }
+ }
+ (updates as any).fontFamily = FONT_FAMILY_MATH;
+ return oldUpdates;
+} as SubtypeMethods["clean"];
+
+const measureMathElement = function (element, next, maxWidth) {
+ ensureMathElement(element);
+ const isMathJaxLoaded = mathJaxLoaded;
+ const fontSize = next?.fontSize ?? element.fontSize;
+ const text = next?.text ?? element.text;
+ const customData = next?.customData ?? element.customData;
+ const mathProps = getMathProps.ensureMathProps(customData);
+ const noMaxWidth = mathProps.mathOnly;
+ const cWidth = noMaxWidth ? undefined : maxWidth;
+ const metrics = getImageMetrics(
+ text,
+ fontSize,
+ mathProps,
+ isMathJaxLoaded,
+ cWidth,
+ );
+ const { height, baseline } = metrics;
+ const width = noMaxWidth ? maxWidth ?? metrics.width : metrics.width;
+ return { width, height, baseline };
+} as SubtypeMethods["measureText"];
+
+const renderMathElement = function (element, context, renderCb) {
+ ensureMathElement(element);
+ const isMathJaxLoaded = mathJaxLoaded;
+ const _element = element as NonDeleted;
+ const text = _element.text;
+ const fontSize = _element.fontSize;
+ const strokeColor = _element.strokeColor;
+ const textAlign = _element.textAlign;
+ const opacity = _element.opacity / 100;
+ const mathProps = getMathProps.ensureMathProps(_element.customData);
+
+ let _childIsSvg: boolean;
+ let _text: string;
+ let _svg: SVGSVGElement;
+
+ const doSetupChild: (
+ childIsSvg: boolean,
+ svg: SVGSVGElement | null,
+ text: string,
+ rtl: boolean,
+ childRtl: boolean,
+ ) => void = function (childIsSvg, svg, text, rtl, childRtl) {
+ _childIsSvg = childIsSvg;
+ _text = text;
+
+ if (_childIsSvg) {
+ _svg = svg!;
+ } else {
+ context.save();
+ context.canvas.setAttribute("dir", childRtl ? "rtl" : "ltr");
+ context.font = getFontString({ fontSize, fontFamily: FONT_FAMILY_MATH });
+ context.fillStyle = _element.strokeColor;
+ context.textAlign = _element.textAlign as CanvasTextAlign;
+ }
+ };
+
+ const doRenderChild: (
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ ) => void = function (x, y, width, height) {
+ if (_childIsSvg) {
+ const key = getCacheKey(
+ _text,
+ fontSize,
+ strokeColor,
+ "left",
+ 1,
+ mathProps,
+ );
+
+ const _x = Math.round(x);
+ const _y = Math.round(y);
+ const imgKey = `${key}, ${width}, ${height}`;
+ if (
+ isMathJaxLoaded &&
+ imageCache[imgKey] &&
+ imageCache[imgKey] !== undefined
+ ) {
+ const img = imageCache[imgKey];
+ const [width, height] = [img.naturalWidth, img.naturalHeight];
+ context.drawImage(img, _x, _y, width, height);
+ } else {
+ const img = new Image();
+ _svg.setAttribute("width", `${width}`);
+ _svg.setAttribute("height", `${height}`);
+ _svg.setAttribute("color", `${strokeColor}`);
+ const svgString = _svg.outerHTML;
+ const svg = new Blob([svgString], {
+ type: "image/svg+xml;charset=utf-8",
+ });
+ const transformMatrix = context.getTransform();
+ const reader = new FileReader();
+ reader.addEventListener(
+ "load",
+ () => {
+ img.onload = function () {
+ const [width, height] = [img.naturalWidth, img.naturalHeight];
+ context.save();
+ context.setTransform(transformMatrix);
+ context.globalAlpha = opacity;
+ context.drawImage(img, _x, _y, width, height);
+ context.restore();
+ if (isMathJaxLoaded) {
+ imageCache[imgKey] = img;
+ }
+ if (renderCb) {
+ renderCb();
+ }
+ };
+ img.src = reader.result as string;
+ },
+ false,
+ );
+ reader.readAsDataURL(svg);
+ }
+ } else {
+ const childOffset =
+ textAlign === "center"
+ ? (width - 1) / 2
+ : textAlign === "right"
+ ? width - 1
+ : 0;
+ context.fillText(_text, x + childOffset, y);
+ context.restore();
+ }
+ };
+ const container = getContainerElement(_element);
+ const parentWidth = container ? getMaxContainerWidth(container) : undefined;
+
+ const offsetX =
+ (_element.width - (container ? parentWidth! : _element.width)) *
+ (textAlign === "right" ? 1 : textAlign === "center" ? 1 / 2 : 0);
+
+ context.save();
+ context.translate(offsetX, 0);
+ element.customData!.ariaLabel = renderMath(
+ text,
+ fontSize,
+ textAlign,
+ mathProps,
+ isMathJaxLoaded,
+ doSetupChild,
+ doRenderChild,
+ parentWidth,
+ );
+ context.restore();
+} as SubtypeMethods["render"];
+
+const renderSvgMathElement = function (svgRoot, root, element, opt) {
+ ensureMathElement(element);
+ const isMathJaxLoaded = mathJaxLoaded;
+
+ const _element = element as NonDeleted;
+ const mathProps = getMathProps.ensureMathProps(_element.customData);
+ const text = _element.text;
+ const fontSize = _element.fontSize;
+ const strokeColor = _element.strokeColor;
+ const textAlign = _element.textAlign;
+ const opacity = _element.opacity / 100;
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ const cx = (x2 - x1) / 2 - (element.x - x1);
+ const cy = (y2 - y1) / 2 - (element.y - y1);
+ const degree = (180 * element.angle) / Math.PI;
+
+ const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
+ node.setAttribute(
+ "transform",
+ `translate(${opt?.offsetX || 0} ${
+ opt?.offsetY || 0
+ }) rotate(${degree} ${cx} ${cy})`,
+ );
+
+ const key = getCacheKey(
+ text,
+ fontSize,
+ strokeColor,
+ textAlign,
+ opacity,
+ mathProps,
+ );
+ if (isMathJaxLoaded && svgCache[key]) {
+ const cachedDiv = svgRoot.ownerDocument!.createElement("div");
+ cachedDiv.innerHTML = svgCache[key];
+ node.appendChild(cachedDiv.firstElementChild!);
+ root.appendChild(node);
+ return;
+ }
+
+ const tempSvg = svgRoot.ownerDocument!.createElementNS(SVG_NS, "svg");
+ const groupNode = tempSvg.ownerDocument.createElementNS(SVG_NS, "g");
+
+ const font = getFontFamilyString({ fontFamily: FONT_FAMILY_MATH });
+ groupNode.setAttribute("font-family", `${font}`);
+ groupNode.setAttribute("font-size", `${fontSize}px`);
+ groupNode.setAttribute("color", `${strokeColor}`);
+ if (opacity !== 1) {
+ groupNode.setAttribute("stroke-opacity", `${opacity}`);
+ groupNode.setAttribute("fill-opacity", `${opacity}`);
+ }
+ tempSvg.appendChild(groupNode);
+
+ const container = getContainerElement(_element);
+ const parentWidth = container ? getMaxContainerWidth(container) : undefined;
+
+ const offsetX =
+ (_element.width - (container ? parentWidth! : _element.width)) *
+ (textAlign === "right" ? 1 : textAlign === "center" ? 1 / 2 : 0);
+
+ const { width, height } = getImageMetrics(
+ text,
+ fontSize,
+ mathProps,
+ isMathJaxLoaded,
+ parentWidth,
+ );
+
+ let childNode = {} as SVGSVGElement | SVGTextElement;
+ const doSetupChild: (
+ childIsSvg: boolean,
+ svg: SVGSVGElement | null,
+ text: string,
+ rtl: boolean,
+ childRtl: boolean,
+ lineHeight: number,
+ ) => void = function (childIsSvg, svg, text, rtl, childRtl, lineHeight) {
+ if (childIsSvg && text !== "") {
+ childNode = svg!;
+
+ // Scale the viewBox to have a centered height of 1.2 * lineHeight
+ const rect = childNode.viewBox.baseVal;
+ const scale = (1.2 * lineHeight) / rect.height;
+ const goffset = roundDec(0.12 * lineHeight, 3);
+ const gx = roundDec(rect.x * scale, 3) + goffset;
+ const gy = roundDec(rect.y * scale, 3) + goffset;
+ const gwidth = roundDec(rect.width * scale, 3);
+ const gheight = roundDec(rect.height * scale, 3);
+ childNode.setAttribute(
+ "viewBox",
+ `${-goffset} ${-goffset} ${gwidth} ${gheight}`,
+ );
+
+ // Set the transform on the svg's group node(s)
+ for (let i = 0; i < childNode.childNodes.length; i++) {
+ if (childNode.childNodes[i].nodeName === "g") {
+ const group = childNode.childNodes[i] as SVGGElement;
+ const transform = group.getAttribute("transform");
+ group.setAttribute(
+ "transform",
+ `translate(${-gx} ${-gy}) scale(${scale}) ${transform}`,
+ );
+ }
+ }
+ } else {
+ const textNode = tempSvg.ownerDocument.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "text",
+ );
+ textNode.setAttribute("style", "white-space: pre;");
+ textNode.setAttribute("fill", `${strokeColor}`);
+ textNode.setAttribute("direction", `${childRtl ? "rtl" : "ltr"}`);
+ textNode.setAttribute("text-anchor", `${childRtl ? "end" : "start"}`);
+ textNode.textContent = text;
+ childNode = textNode;
+ }
+ };
+ const doRenderChild: (x: number, y: number) => void = function (x, y) {
+ childNode.setAttribute("x", `${x + offsetX}`);
+ childNode.setAttribute("y", `${y}`);
+ groupNode.appendChild(childNode);
+ };
+ element.customData!.ariaLabel = renderMath(
+ text,
+ fontSize,
+ textAlign,
+ mathProps,
+ isMathJaxLoaded,
+ doSetupChild,
+ doRenderChild,
+ parentWidth,
+ );
+
+ tempSvg.setAttribute("version", "1.1");
+ tempSvg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ tempSvg.setAttribute("width", `${width}`);
+ tempSvg.setAttribute("height", `${height}`);
+ if (isMathJaxLoaded) {
+ svgCache[key] = tempSvg.outerHTML;
+ }
+ node.appendChild(tempSvg);
+ root.appendChild(node);
+} as SubtypeMethods["renderSvg"];
+
+const wrappedMathCache: { [key: string]: string } = {};
+
+const wrapMathElement = function (element, containerWidth, next) {
+ ensureMathElement(element);
+ const isMathJaxLoaded = mathJaxLoaded;
+ const fontSize =
+ next?.fontSize !== undefined ? next.fontSize : element.fontSize;
+ const text = next?.text !== undefined ? next.text : element.originalText;
+ const customData = next?.customData ?? element.customData;
+ const mathProps = getMathProps.ensureMathProps(customData);
+
+ const font = getFontString({ fontSize, fontFamily: FONT_FAMILY_MATH });
+
+ // Use regular text-wrapping for math-only mode
+ if (mathProps.mathOnly) {
+ return wrapText(text, font, containerWidth);
+ }
+
+ const maxWidth = containerWidth;
+
+ const markup = markupText(text, mathProps, isMathJaxLoaded).markup;
+ const metrics = getMetrics(markup, fontSize, mathProps, isMathJaxLoaded);
+
+ const lines = consumeMathNewlines(text, mathProps, isMathJaxLoaded).split(
+ "\n",
+ );
+ const wrappedLines: string[] = [];
+ const spaceWidth = getTextWidth(" ", font);
+ for (let index = 0; index < lines.length; index++) {
+ const mathLineKey = `${containerWidth} ${fontSize} ${mathProps.useTex} ${mathProps.mathOnly} ${lines[index]}`;
+ if (wrappedMathCache[mathLineKey] !== undefined) {
+ wrappedLines.push(...wrappedMathCache[mathLineKey].split("\n"));
+ continue;
+ }
+ const currLineNum = wrappedLines.length;
+ const lineArray = splitMath(lines[index], mathProps).filter(
+ (value) => value !== "",
+ );
+ const markupArray = markup[index].filter((value) => value !== "");
+ let curWidth = 0;
+ wrappedLines.push("");
+ // The following two boolean variables are to handle edge cases
+ let nlByText = false;
+ let nlByMath = false;
+ for (let i = 0; i < lineArray.length; i++) {
+ const isSvg =
+ textAsMjxContainer(markupArray[i], isMathJaxLoaded) !== null;
+ if (isSvg) {
+ const lineItem =
+ getStartDelimiter(mathProps.useTex) +
+ lineArray[i] +
+ getEndDelimiter(mathProps.useTex);
+ const itemWidth = metrics.markupMetrics[index][i].width;
+ if (itemWidth > maxWidth) {
+ // If the math svg is greater than maxWidth, make its source
+ // text be a new line and start on the next line. Don't try
+ // to split the math rendering into multiple lines.
+ if (nlByText) {
+ wrappedLines.pop();
+ nlByText = false;
+ }
+ wrappedLines.push(lineItem);
+ wrappedLines.push("");
+ curWidth = 0;
+ nlByMath = true;
+ } else if (curWidth <= maxWidth && curWidth + itemWidth > maxWidth) {
+ // If the math svg would push us past maxWidth, start a
+ // new line and continue on that new line. Store the math
+ // svg's width in curWidth.
+ wrappedLines.push(lineItem);
+ curWidth = itemWidth;
+ nlByMath = false;
+ } else {
+ // If the math svg would not push us past maxWidth, then
+ // just append its source text to the current line. Add
+ // the math svg's width to curWidth.
+ wrappedLines[wrappedLines.length - 1] += lineItem;
+ curWidth += itemWidth;
+ nlByMath = false;
+ }
+ } else {
+ // Don't have spaces at the start of a wrapped line. But
+ // allow them at the start of new lines from the originalText.
+ const lineItem =
+ curWidth > 0 || i === 0 ? lineArray[i] : lineArray[i].trimStart();
+ // Append words one-by-one until we would be over maxWidth;
+ // then let wrapText() take effect.
+ const words = lineItem.split(" ");
+ let wordsIndex = 0;
+ while (curWidth <= maxWidth && wordsIndex < words.length) {
+ const wordWidth = getTextWidth(words[wordsIndex], font);
+ if (nlByMath && wordWidth + spaceWidth > maxWidth) {
+ wrappedLines.pop();
+ nlByMath = false;
+ }
+ if (curWidth + wordWidth + spaceWidth <= maxWidth) {
+ wrappedLines[wrappedLines.length - 1] += words[wordsIndex];
+ curWidth += wordWidth;
+ wordsIndex++;
+ // Only append a space if we wouldn't go over maxWidth
+ // and we haven't appended the last word yet.
+ if (
+ curWidth + spaceWidth <= maxWidth &&
+ wordsIndex < words.length
+ ) {
+ wrappedLines[wrappedLines.length - 1] += " ";
+ curWidth += spaceWidth;
+ } else {
+ break;
+ }
+ } else {
+ break;
+ }
+ }
+ const toWrap = words.slice(wordsIndex).join(" ").trimStart();
+ if (toWrap !== "") {
+ wrappedLines.push(
+ ...wrapText(toWrap, font, containerWidth).split("\n"),
+ );
+ // Set curWidth to the width of the last wrapped line
+ curWidth = getTextWidth(wrappedLines[wrappedLines.length - 1], font);
+ // This means wrapText() caused us to start a new line
+ if (curWidth === 0) {
+ nlByText = true;
+ }
+ }
+ }
+ }
+ wrappedMathCache[mathLineKey] = wrappedLines.slice(currLineNum).join("\n");
+ }
+
+ if (wrappedLines[0] === "") {
+ wrappedLines.splice(0, 1);
+ }
+ return wrappedLines.join("\n");
+} as SubtypeMethods["wrapText"];
+
+const ensureMathJaxLoaded = async function (callback) {
+ await loadMathJax();
+ if (callback) {
+ callback();
+ }
+} as SubtypeMethods["ensureLoaded"];
+
+const enableActionChangeMathProps = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+) => {
+ const eligibleElements = getSelectedMathElements(elements, appState);
+
+ let enabled = false;
+ eligibleElements.forEach((element) => {
+ if (
+ isMathElement(element) ||
+ (hasBoundTextElement(element) &&
+ isMathElement(getBoundTextElement(element)))
+ ) {
+ enabled = true;
+ }
+ });
+
+ if (
+ appState.activeTool.type === "text" &&
+ appState.activeSubtypes &&
+ appState.activeSubtypes.includes(mathSubtype)
+ ) {
+ enabled = true;
+ }
+ return enabled;
+};
+
+const createMathActions = () => {
+ const mathActions: Action[] = [];
+ const actionUseTexTrue: Action = {
+ name: "useTexTrue",
+ perform: (elements, appState, useTex: boolean | null) => {
+ const mathOnly = getMathProps.getMathOnly(appState);
+ const customData = appState.customData ?? {};
+ customData[`${mathSubtype}`] = { useTex: true, mathOnly };
+ return {
+ elements,
+ appState: { ...appState, customData },
+ commitToHistory: true,
+ };
+ },
+ contextItemLabel: (elements, appState) =>
+ getMathProps.getUseTex(appState)
+ ? "labels.useTexTrueActive"
+ : "labels.useTexTrueInactive",
+ shapeConfigPredicate: (elements, appState, data) =>
+ data?.source === mathSubtype,
+ trackEvent: false,
+ };
+ const actionUseTexFalse: Action = {
+ name: "useTexTrue",
+ perform: (elements, appState, useTex: boolean | null) => {
+ const mathOnly = getMathProps.getMathOnly(appState);
+ const customData = appState.customData ?? {};
+ customData[`${mathSubtype}`] = { useTex: false, mathOnly };
+ return {
+ elements,
+ appState: { ...appState, customData },
+ commitToHistory: true,
+ };
+ },
+ contextItemLabel: (elements, appState) =>
+ !getMathProps.getUseTex(appState)
+ ? "labels.useTexFalseActive"
+ : "labels.useTexFalseInactive",
+ shapeConfigPredicate: (elements, appState, data) =>
+ data?.source === mathSubtype,
+ trackEvent: false,
+ };
+ const actionResetUseTex: Action = {
+ name: "resetUseTex",
+ perform: (elements, appState) => {
+ const useTex = getMathProps.getUseTex(appState);
+ const modElements = changeProperty(
+ elements,
+ appState,
+ (oldElement) => {
+ if (
+ isMathElement(oldElement) &&
+ (oldElement.customData === undefined ||
+ oldElement.customData.useTex !== useTex)
+ ) {
+ const newElement: ExcalidrawTextElement = newElementWith(
+ oldElement,
+ {
+ customData: getMathProps.ensureMathProps({
+ useTex: useTex as boolean,
+ mathOnly: oldElement.customData?.mathOnly,
+ }),
+ },
+ );
+ redrawTextBoundingBox(newElement, getContainerElement(oldElement));
+ return newElement;
+ }
+
+ return oldElement;
+ },
+ true,
+ );
+
+ return {
+ elements: modElements,
+ commitToHistory: true,
+ };
+ },
+ keyTest: (event) => event.shiftKey && event.code === "KeyR",
+ contextItemLabel: "labels.resetUseTex",
+ contextItemPredicate: (elements, appState) => {
+ const useTex = getMathProps.getUseTex(appState);
+ const mathElements = getSelectedMathElements(elements, appState);
+ return mathElements.some((el) => {
+ const e = isMathElement(el) ? el : getBoundTextElement(el)!;
+ return e.customData === undefined || e.customData.useTex !== useTex;
+ });
+ },
+ trackEvent: false,
+ };
+ const actionChangeMathOnly: Action = {
+ name: "changeMathOnly",
+ perform: (elements, appState, mathOnly: boolean | null) => {
+ if (mathOnly === null) {
+ mathOnly = getFormValue(elements, appState, (element) => {
+ const el = hasBoundTextElement(element)
+ ? getBoundTextElement(element)
+ : element;
+ return isMathElement(el) && el.customData?.mathOnly;
+ });
+ if (mathOnly === null) {
+ mathOnly = getMathProps.getMathOnly(appState);
+ }
+ }
+ const modElements = changeProperty(
+ elements,
+ appState,
+ (oldElement) => {
+ if (isMathElement(oldElement)) {
+ const customData = getMathProps.ensureMathProps({
+ useTex: oldElement.customData?.useTex,
+ mathOnly: mathOnly as boolean,
+ });
+ const newElement: ExcalidrawTextElement = newElementWith(
+ oldElement,
+ { customData },
+ );
+ redrawTextBoundingBox(newElement, getContainerElement(oldElement));
+ return newElement;
+ }
+
+ return oldElement;
+ },
+ true,
+ );
+
+ const useTex = getMathProps.getUseTex(appState);
+ const customData = appState.customData ?? {};
+ customData[`${mathSubtype}`] = { useTex, mathOnly };
+ return {
+ elements: modElements,
+ appState: { ...appState, customData },
+ commitToHistory: true,
+ };
+ },
+ PanelComponent: ({ elements, appState, updateData }) => (
+
+ ),
+ panelComponentPredicate: (elements, appState) =>
+ enableActionChangeMathProps(elements, appState),
+ trackEvent: false,
+ };
+ const actionMath = SubtypeButton(mathSubtype, "text", mathSubtypeIcon, "M");
+ mathActions.push(actionUseTexTrue);
+ mathActions.push(actionUseTexFalse);
+ mathActions.push(actionResetUseTex);
+ mathActions.push(actionChangeMathOnly);
+ mathActions.push(actionMath);
+ return mathActions;
+};
+
+export const prepareMathSubtype = function (
+ addSubtypeAction,
+ addLangData,
+ onSubtypeLoaded,
+) {
+ // Set the callback first just in case anything in this method
+ // calls loadMathJax().
+ mathJaxLoadedCallback = onSubtypeLoaded;
+
+ const methods = {} as SubtypeMethods;
+ methods.clean = cleanMathElementUpdate;
+ methods.ensureLoaded = ensureMathJaxLoaded;
+ methods.measureText = measureMathElement;
+ methods.render = renderMathElement;
+ methods.renderSvg = renderSvgMathElement;
+ methods.wrapText = wrapMathElement;
+ const getLangData = async (langCode: string): Promise