1601 lines
50 KiB
TypeScript
1601 lines
50 KiB
TypeScript
// Some imports
|
|
import fallbackMathJaxLangData from "./locales/en.json";
|
|
import { FONT_FAMILY, SVG_NS } from "../../../../constants";
|
|
import { getFontString, getFontFamilyString, isRTL } from "../../../../utils";
|
|
import {
|
|
getBoundTextElement,
|
|
getBoundTextMaxWidth,
|
|
getContainerElement,
|
|
getDefaultLineHeight,
|
|
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";
|
|
import Scene from "../../../../scene/Scene";
|
|
|
|
// 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 { ButtonIconSelect } from "../../../../components/ButtonIconSelect";
|
|
|
|
// Subtype imports
|
|
import {
|
|
SubtypeLoadedCb,
|
|
SubtypeMethods,
|
|
SubtypePrepFn,
|
|
} from "../../../../subtypes";
|
|
import { mathSubtypeIcon } from "./icon";
|
|
import { getMathSubtypeRecord } from "./types";
|
|
import { SubtypeButton } from "../../../../components/Subtypes";
|
|
|
|
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;
|
|
amFixes: any;
|
|
};
|
|
|
|
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 continueLoading = !mathJaxLoading;
|
|
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.amFixes = MathJax.InputJax.AsciiMath.AM.Augment;
|
|
|
|
// 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 (mathJaxLoading && !continueLoading) {
|
|
return;
|
|
}
|
|
|
|
// Set up shared components
|
|
mathJax.adaptor = liteAdaptor();
|
|
mathJax.visitor = new SerializedMmlVisitor();
|
|
RegisterHTMLHandler(mathJax.adaptor);
|
|
|
|
// Set up input components
|
|
const asciimath = new AsciiMath<E | T, T, D>({});
|
|
const tex = new TeX<E | T, T, D>({ packages: texPackages });
|
|
|
|
// Set up output components
|
|
const mml = new MathML<E | T, T, D>();
|
|
const svg = new SVG<E | T, T, D>({ 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";
|
|
|
|
// Clear any caches from before loading MathJax
|
|
clearCaches();
|
|
|
|
// 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<string>((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 ? "\\newline" : "\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,
|
|
) => {
|
|
if (!isMathJaxLoaded) {
|
|
return text;
|
|
}
|
|
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 };
|
|
// Cache the MathJax-wrapped text strings for wrapMathElement()
|
|
const wrappedMathCache: { [key: string]: string } = {};
|
|
|
|
const clearCaches = () => {
|
|
for (const key in mathJaxSvgCacheAM) {
|
|
delete mathJaxSvgCacheAM[key];
|
|
}
|
|
for (const key in mathJaxSvgCacheTex) {
|
|
delete mathJaxSvgCacheTex[key];
|
|
}
|
|
for (const key in metricsCache) {
|
|
delete metricsCache[key];
|
|
}
|
|
for (const key in svgCache) {
|
|
delete svgCache[key];
|
|
}
|
|
for (const key in imageCache) {
|
|
delete imageCache[key];
|
|
}
|
|
for (const key in wrappedMathCache) {
|
|
delete wrappedMathCache[key];
|
|
}
|
|
};
|
|
|
|
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;
|
|
const key = `${mathProps.mathOnly}${text}`;
|
|
if (
|
|
isMathJaxLoaded &&
|
|
(useTex ? mathJaxSvgCacheTex[key] : mathJaxSvgCacheAM[key])
|
|
) {
|
|
return useTex ? mathJaxSvgCacheTex[key] : mathJaxSvgCacheAM[key];
|
|
}
|
|
loadMathJax();
|
|
try {
|
|
const userOptions = { display: mathProps.mathOnly };
|
|
// For some reason this needs to be set before each call to the AsciiMath
|
|
// input jax to render display/inline correctly.
|
|
if (isMathJaxLoaded && !useTex) {
|
|
mathJax.amFixes({ displaystyle: mathProps.mathOnly });
|
|
}
|
|
// Intermediate MathML
|
|
const mmlString = isMathJaxLoaded
|
|
? mathJax.visitor.visitTree(
|
|
useTex
|
|
? mathJax.texHtml.convert(text, userOptions)
|
|
: mathJax.amHtml.convert(text, userOptions),
|
|
)
|
|
: text;
|
|
let mmlError = false;
|
|
if (isMathJaxLoaded) {
|
|
const errDiv = document.createElement("div");
|
|
errDiv.innerHTML = mmlString;
|
|
mmlError =
|
|
errDiv.children[0].children.length > 0 &&
|
|
errDiv.children[0].children[0].nodeName === "merror";
|
|
}
|
|
// For rendering
|
|
const htmlString = isMathJaxLoaded
|
|
? mmlError
|
|
? errorSvg
|
|
: mathJax.adaptor.outerHTML(mathJax.mmlSvg.convert(mmlString))
|
|
: text;
|
|
// For accessibility
|
|
const ariaString = isMathJaxLoaded
|
|
? mmlError
|
|
? errorAria
|
|
: useSRE
|
|
? mathJax.mmlSre.toSpeech(mmlString)
|
|
: text
|
|
: mmlString;
|
|
if (isMathJaxLoaded) {
|
|
if (useTex) {
|
|
mathJaxSvgCacheTex[key] = { svg: htmlString, aria: ariaString };
|
|
} else {
|
|
mathJaxSvgCacheAM[key] = { 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(
|
|
isMathJaxLoaded ? getMathNewline(mathProps) : "\n",
|
|
);
|
|
const markup = [] as Array<string>[];
|
|
const aria = [] as Array<string>[];
|
|
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<string | Element>,
|
|
fontSize: number,
|
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
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) {
|
|
container.style.maxWidth = `${String(maxWidth)}px`;
|
|
container.style.overflow = "hidden";
|
|
container.style.wordBreak = "break-word";
|
|
container.style.lineHeight = `${String(lineHeight)}`;
|
|
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 constrainedText = maxWidth
|
|
? wrapText(text, font, maxWidth)
|
|
: text;
|
|
const textMetrics = measureText(constrainedText, font, lineHeight);
|
|
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,
|
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
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<string | Element>;
|
|
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,
|
|
lineHeight,
|
|
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,
|
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
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(
|
|
isMathJaxLoaded ? getMathNewline(mathProps) : "\n",
|
|
);
|
|
const { markup, aria } = markupText(text, mathProps, isMathJaxLoaded);
|
|
const metrics = getMetrics(
|
|
markup,
|
|
fontSize,
|
|
lineHeight,
|
|
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,
|
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
mathProps: MathProps,
|
|
isMathJaxLoaded: boolean,
|
|
maxWidth?: number | null,
|
|
) => {
|
|
const markup = markupText(text, mathProps, isMathJaxLoaded).markup;
|
|
return getMetrics(
|
|
markup,
|
|
fontSize,
|
|
lineHeight,
|
|
mathProps,
|
|
isMathJaxLoaded,
|
|
maxWidth,
|
|
).imageMetrics;
|
|
};
|
|
|
|
const getSelectedMathElements = (
|
|
elements: readonly ExcalidrawElement[],
|
|
appState: Readonly<AppState>,
|
|
): NonDeleted<ExcalidrawMathElement>[] => {
|
|
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<ExcalidrawMathElement>[];
|
|
return eligibleElements;
|
|
};
|
|
|
|
// Be sure customData is defined with proper values for ExcalidrawMathElements
|
|
const ensureMathElement = (element: Partial<ExcalidrawElement>) => {
|
|
if (!isMathElement(element as Required<ExcalidrawElement>)) {
|
|
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" && key !== "lineHeight") {
|
|
(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;
|
|
(updates as any).lineHeight = getDefaultLineHeight(FONT_FAMILY_MATH);
|
|
return oldUpdates;
|
|
} as SubtypeMethods["clean"];
|
|
|
|
const getMathEditorStyle = function (element) {
|
|
if (isMathElement(element)) {
|
|
return { background: undefined };
|
|
}
|
|
return {};
|
|
} as SubtypeMethods["getEditorStyle"];
|
|
|
|
const measureMathElement = function (element, next) {
|
|
ensureMathElement(element);
|
|
const isMathJaxLoaded = mathJaxLoaded;
|
|
if (!isMathJaxLoaded && isMathElement(element as ExcalidrawElement)) {
|
|
const { width, height, baseline } = element as ExcalidrawMathElement;
|
|
return { width, height, baseline };
|
|
}
|
|
const fontSize = next?.fontSize ?? element.fontSize;
|
|
const lineHeight = element.lineHeight;
|
|
const text = next?.text ?? element.text;
|
|
const customData = next?.customData ?? element.customData;
|
|
const mathProps = getMathProps.ensureMathProps(customData);
|
|
const metrics = getImageMetrics(
|
|
text,
|
|
fontSize,
|
|
lineHeight,
|
|
mathProps,
|
|
isMathJaxLoaded,
|
|
);
|
|
return metrics;
|
|
} as SubtypeMethods["measureText"];
|
|
|
|
const renderMathElement = function (element, context) {
|
|
ensureMathElement(element);
|
|
const isMathJaxLoaded = mathJaxLoaded;
|
|
const _element = element as NonDeleted<ExcalidrawMathElement>;
|
|
const text = _element.text;
|
|
const fontSize = _element.fontSize;
|
|
const lineHeight = _element.lineHeight;
|
|
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;
|
|
}
|
|
Scene.getScene(element)?.informMutation();
|
|
};
|
|
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 ? getBoundTextMaxWidth(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,
|
|
lineHeight,
|
|
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<ExcalidrawMathElement>;
|
|
const mathProps = getMathProps.ensureMathProps(_element.customData);
|
|
const text = _element.text;
|
|
const fontSize = _element.fontSize;
|
|
const lineHeight = _element.lineHeight;
|
|
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 ? getBoundTextMaxWidth(container) : undefined;
|
|
|
|
const offsetX =
|
|
(_element.width - (container ? parentWidth! : _element.width)) *
|
|
(textAlign === "right" ? 1 : textAlign === "center" ? 1 / 2 : 0);
|
|
|
|
const { width, height } = getImageMetrics(
|
|
text,
|
|
fontSize,
|
|
lineHeight,
|
|
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,
|
|
lineHeight,
|
|
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 wrapMathElement = function (element, containerWidth, next) {
|
|
ensureMathElement(element);
|
|
const isMathJaxLoaded = mathJaxLoaded;
|
|
const fontSize =
|
|
next?.fontSize !== undefined ? next.fontSize : element.fontSize;
|
|
const lineHeight = element.lineHeight;
|
|
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,
|
|
lineHeight,
|
|
mathProps,
|
|
isMathJaxLoaded,
|
|
);
|
|
|
|
const lines = consumeMathNewlines(text, mathProps, isMathJaxLoaded).split(
|
|
isMathJaxLoaded ? getMathNewline(mathProps) : "\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 (isMathJaxLoaded && 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (isMathJaxLoaded) {
|
|
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) => {
|
|
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",
|
|
predicate: (...rest) => rest.length < 5 || rest[4]?.subtype === mathSubtype,
|
|
trackEvent: false,
|
|
};
|
|
const actionUseTexFalse: Action = {
|
|
name: "useTexFalse",
|
|
perform: (elements, appState) => {
|
|
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",
|
|
predicate: (...rest) => rest.length < 5 || rest[4]?.subtype === 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",
|
|
predicate: (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 }) => {
|
|
const textIcon = (text: string, selected: boolean) => {
|
|
const color = selected
|
|
? "var(--button-color, var(--color-primary-darker))"
|
|
: "var(--button-color, var(--text-primary-color))";
|
|
return (
|
|
<div className="buttonList">
|
|
<span style={{ textAlign: "center", fontSize: "0.6rem", color }}>
|
|
{text.replace(" ", "\n")}
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
const value = getFormValue(
|
|
elements,
|
|
appState,
|
|
(element) => {
|
|
const el = hasBoundTextElement(element)
|
|
? getBoundTextElement(element)
|
|
: element;
|
|
return isMathElement(el)
|
|
? getMathProps.ensureMathProps(el.customData).mathOnly
|
|
: null;
|
|
},
|
|
getMathProps.getMathOnly(appState),
|
|
);
|
|
return (
|
|
<fieldset>
|
|
<legend>{t("labels.changeMathOnly")}</legend>
|
|
<ButtonIconSelect
|
|
group="mathOnly"
|
|
options={[
|
|
{
|
|
value: false,
|
|
text: t("labels.mathOnlyFalse"),
|
|
icon: textIcon(t("labels.mathOnlyFalse"), value === false),
|
|
},
|
|
{
|
|
value: true,
|
|
text: t("labels.mathOnlyTrue"),
|
|
icon: textIcon(t("labels.mathOnlyTrue"), value === true),
|
|
},
|
|
]}
|
|
value={value}
|
|
onChange={(value) => updateData(value)}
|
|
/>
|
|
</fieldset>
|
|
);
|
|
},
|
|
predicate: (...rest) =>
|
|
rest[4] === undefined && enableActionChangeMathProps(rest[0], rest[1]),
|
|
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.getEditorStyle = getMathEditorStyle;
|
|
methods.measureText = measureMathElement;
|
|
methods.render = renderMathElement;
|
|
methods.renderSvg = renderSvgMathElement;
|
|
methods.wrapText = wrapMathElement;
|
|
const getLangData = async (langCode: string): Promise<Object | undefined> => {
|
|
try {
|
|
const condData = await import(
|
|
/* webpackChunkName: "locales/[request]" */ `./locales/${langCode}.json`
|
|
);
|
|
if (condData) {
|
|
return condData;
|
|
}
|
|
} catch (e) {}
|
|
return undefined;
|
|
};
|
|
addLangData(fallbackMathJaxLangData, getLangData);
|
|
registerAuxLangData(fallbackMathJaxLangData, getLangData);
|
|
|
|
const actions = createMathActions();
|
|
actions.forEach((action) => addSubtypeAction(action));
|
|
// Call loadMathJax() here if we want to be sure it's loaded.
|
|
|
|
return { actions, methods };
|
|
} as SubtypePrepFn;
|