feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com

This commit is contained in:
Daniel J. Geiger 2022-12-27 15:11:52 -06:00
parent c8370b394c
commit 86f5c2ebcf
84 changed files with 8331 additions and 289 deletions

View file

@ -5,12 +5,23 @@ import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import { maybeGetSubtypeProps } from "./newElement";
import { getSubtypeMethods } from "../subtypes";
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "version" | "versionNonce"
>;
const cleanUpdates = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
): ElementUpdate<TElement> => {
const subtype = maybeGetSubtypeProps(element, element.type).subtype;
const map = getSubtypeMethods(subtype);
return map?.clean ? (map.clean(updates) as typeof updates) : updates;
};
// This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in
// the same drawing. Note: this will trigger the component to update. Make sure you
@ -21,6 +32,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
informMutation = true,
): TElement => {
let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
@ -76,6 +89,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
(element as any)[key] = value;
didChange = true;
key in oldUpdates && (increment = true);
}
}
if (!didChange) {
@ -91,9 +105,11 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
invalidateShapeForElement(element);
}
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
if (increment) {
element.version++;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
}
if (informMutation) {
Scene.getScene(element)?.informMutation();
@ -107,6 +123,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
updates: ElementUpdate<TElement>,
): TElement => {
let didChange = false;
let increment = false;
const oldUpdates = cleanUpdates(element, updates);
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
@ -118,6 +136,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
continue;
}
didChange = true;
key in oldUpdates && (increment = true);
}
}
@ -125,6 +144,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element;
}
if (!increment) {
return { ...element, ...updates };
}
return {
...element,
...updates,

View file

@ -13,7 +13,7 @@ import {
FontFamilyValues,
ExcalidrawTextContainer,
} from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
import { getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
import { mutateElement, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
@ -26,12 +26,36 @@ import {
getBoundTextElementOffset,
getContainerDims,
getContainerElement,
measureText,
measureTextElement,
normalizeText,
wrapText,
wrapTextElement,
} from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks";
import { getSubtypeMethods, isValidSubtype } from "../subtypes";
export const maybeGetSubtypeProps = (
obj: {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
},
type: ExcalidrawElement["type"],
) => {
const data: typeof obj = {};
if ("subtype" in obj) {
data.subtype = obj.subtype;
}
if ("customData" in obj) {
data.customData = obj.customData;
}
if ("subtype" in data && !isValidSubtype(data.subtype, type)) {
delete data.subtype;
}
if (!("subtype" in data) && "customData" in data) {
delete data.customData;
}
return data as typeof obj;
};
type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@ -44,6 +68,8 @@ type ElementConstructorOpts = MarkOptional<
| "version"
| "versionNonce"
| "link"
| "subtype"
| "customData"
>;
const _newElementBase = <T extends ExcalidrawElement>(
@ -69,8 +95,10 @@ const _newElementBase = <T extends ExcalidrawElement>(
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
const { subtype, customData } = rest;
// assign type to guard against excess properties
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
...maybeGetSubtypeProps({ subtype, customData }, type),
id: rest.id || randomId(),
type,
x,
@ -103,8 +131,11 @@ export const newElement = (
opts: {
type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
): NonDeleted<ExcalidrawGenericElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
};
/** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = (
@ -138,8 +169,13 @@ export const newTextElement = (
containerId?: ExcalidrawTextContainer["id"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
const text = normalizeText(opts.text);
const metrics = measureText(text, getFontString(opts));
const metrics = measureTextElement(opts, {
text,
customData: opts.customData,
});
const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith(
{
@ -181,7 +217,7 @@ const getAdjustedDimensions = (
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureText(nextText, getFontString(element), maxWidth);
} = measureTextElement(element, { text: nextText }, maxWidth);
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@ -190,9 +226,9 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId
) {
const prevMetrics = measureText(
element.text,
getFontString(element),
const prevMetrics = measureTextElement(
element,
{ fontSize: element.fontSize },
maxWidth,
);
const offsets = getTextElementPositionOffsets(element, {
@ -268,11 +304,9 @@ export const refreshTextDimensions = (
) => {
const container = getContainerElement(textElement);
if (container) {
text = wrapText(
text = wrapTextElement(textElement, getMaxContainerWidth(container), {
text,
getFontString(textElement),
getMaxContainerWidth(container),
);
});
}
const dimensions = getAdjustedDimensions(textElement, text);
return { text, ...dimensions };
@ -336,6 +370,8 @@ export const newFreeDrawElement = (
simulatePressure: boolean;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [],
@ -353,6 +389,8 @@ export const newLinearElement = (
points?: ExcalidrawLinearElement["points"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [],
@ -372,6 +410,8 @@ export const newImageElement = (
scale?: ExcalidrawImageElement["scale"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => {
const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts);
return {
..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements,

View file

@ -46,7 +46,7 @@ import {
getBoundTextElementOffset,
getContainerElement,
handleBindTextResize,
measureText,
measureTextElement,
} from "./textElement";
import { getMaxContainerWidth } from "./newElement";
@ -211,9 +211,9 @@ const measureFontSizeFromWH = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
const metrics = measureTextElement(
element,
{ fontSize: nextFontSize },
element.containerId ? width : null,
);
return {

View file

@ -1,3 +1,4 @@
import { getSubtypeMethods, SubtypeMethods } from "../subtypes";
import { getFontString, arrayToMap, isTestEnv } from "../utils";
import {
ExcalidrawElement,
@ -29,6 +30,30 @@ import {
updateOriginalContainerCache,
} from "./textWysiwyg";
export const measureTextElement = function (element, next, maxWidth) {
const map = getSubtypeMethods(element.subtype);
if (map?.measureText) {
return map.measureText(element, next, maxWidth);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.text;
return measureText(text, font, maxWidth);
} as SubtypeMethods["measureText"];
export const wrapTextElement = function (element, containerWidth, next) {
const map = getSubtypeMethods(element.subtype);
if (map?.wrapText) {
return map.wrapText(element, containerWidth, next);
}
const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.originalText;
return wrapText(text, font, containerWidth);
} as SubtypeMethods["wrapText"];
export const normalizeText = (text: string) => {
return (
text
@ -47,13 +72,15 @@ export const redrawTextBoundingBox = (
let text = textElement.text;
if (container) {
maxWidth = getMaxContainerWidth(container);
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
text = wrapTextElement(textElement, maxWidth);
}
const metrics = measureText(text, getFontString(textElement), maxWidth);
const width = measureTextElement(
textElement,
{ text: textElement.originalText },
maxWidth,
).width;
const { height, baseline } = measureTextElement(textElement, { text });
const metrics = { width, height, baseline };
let coordY = textElement.y;
let coordX = textElement.x;
// Resize container and vertically center align the text
@ -175,16 +202,12 @@ export const handleBindTextResize = (
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
text = wrapTextElement(textElement, maxWidth);
}
const dimensions = measureText(
text,
getFontString(textElement),
maxWidth,
const dimensions = measureTextElement(
textElement,
{ text },
container.width,
);
nextHeight = dimensions.height;
nextWidth = dimensions.width;

View file

@ -10,8 +10,10 @@ import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import {
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontString,
} from "./types";
import * as textElementUtils from "./textElement";
import { getFontString } from "../utils";
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils";
@ -675,39 +677,53 @@ describe("textWysiwyg", () => {
});
it("should wrap text and vertcially center align once text submitted", async () => {
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
if (text === "Hello \nWorld!") {
height = APPROX_LINE_HEIGHT * 2;
}
if (maxWidth) {
width = maxWidth;
// To capture cases where maxWidth passed is initial width
// due to which the text is not wrapped correctly
if (maxWidth === INITIAL_WIDTH) {
height = DUMMY_HEIGHT;
}
}
const mockMeasureText = (
text: string,
font: FontString,
maxWidth?: number | null,
) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
});
}
baseline = 30;
width = DUMMY_WIDTH;
if (text === "Hello \nWorld!") {
height = APPROX_LINE_HEIGHT * 2;
}
if (maxWidth) {
width = maxWidth;
// To capture cases where maxWidth passed is initial width
// due to which the text is not wrapped correctly
if (maxWidth === INITIAL_WIDTH) {
height = DUMMY_HEIGHT;
}
}
return {
width,
height,
baseline,
};
};
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation(mockMeasureText);
jest
.spyOn(textElementUtils, "measureTextElement")
.mockImplementation((element, next, maxWidth) => {
return mockMeasureText(
next?.text ?? element.text,
getFontString(element),
maxWidth,
);
});
expect(h.elements.length).toBe(1);
Keyboard.keyDown(KEYS.ENTER);
@ -1057,28 +1073,42 @@ describe("textWysiwyg", () => {
});
it("should restore original container height and clear cache once text is unbind", async () => {
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation((text, font, maxWidth) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
height = APPROX_LINE_HEIGHT * 5;
const mockMeasureText = (
text: string,
font: FontString,
maxWidth?: number | null,
) => {
let width = INITIAL_WIDTH;
let height = APPROX_LINE_HEIGHT;
let baseline = 10;
if (!text) {
return {
width,
height,
baseline,
};
}
baseline = 30;
width = DUMMY_WIDTH;
height = APPROX_LINE_HEIGHT * 5;
return {
width,
height,
baseline,
};
};
jest
.spyOn(textElementUtils, "measureText")
.mockImplementation(mockMeasureText);
jest
.spyOn(textElementUtils, "measureTextElement")
.mockImplementation((element, next, maxWidth) => {
return mockMeasureText(
next?.text ?? element.text,
getFontString(element),
maxWidth,
);
});
const originalRectHeight = rectangle.height;
expect(rectangle.height).toBe(originalRectHeight);

View file

@ -28,6 +28,7 @@ import {
getContainerDims,
getContainerElement,
getTextElementAngle,
measureText,
getTextWidth,
normalizeText,
wrapText,
@ -43,6 +44,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
const getTransform = (
offsetX: number,
width: number,
height: number,
angle: number,
@ -60,7 +62,7 @@ const getTransform = (
if (height > maxHeight && zoom.value !== 1) {
translateY = (maxHeight * (zoom.value - 1)) / 2;
}
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg) translate(${offsetX}px, 0px)`;
};
const originalContainerCache: {
@ -153,11 +155,19 @@ export const textWysiwyg = ({
if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y;
let eCoordY = coordY;
const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height;
const width = updatedTextElement.width;
// Editing metrics
const eMetrics = measureText(
updatedTextElement.originalText,
getFontString(updatedTextElement),
container ? getContainerDims(container).width : null,
);
let maxWidth = eMetrics.width;
let maxHeight = eMetrics.height;
const width = eMetrics.width;
// Set to element height by default since that's
// what is going to be used for unbounded text
let textElementHeight = updatedTextElement.height;
@ -170,6 +180,7 @@ export const textWysiwyg = ({
);
coordX = boundTextCoords.x;
coordY = boundTextCoords.y;
eCoordY = coordY;
}
const propertiesUpdated = textPropertiesUpdated(
updatedTextElement,
@ -183,7 +194,14 @@ export const textWysiwyg = ({
}
if (propertiesUpdated) {
// update height of the editor after properties updated
textElementHeight = updatedTextElement.height;
const font = getFontString(updatedTextElement);
textElementHeight =
getApproxLineHeight(font) *
updatedTextElement.text.split("\n").length;
textElementHeight = Math.max(
textElementHeight,
updatedTextElement.height,
);
}
let originalContainerData;
@ -235,6 +253,7 @@ export const textWysiwyg = ({
if (!isArrowElement(container)) {
coordY =
container.y + containerDims.height / 2 - textElementHeight / 2;
eCoordY = coordY + textElementHeight / 2 - eMetrics.height / 2;
}
}
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
@ -243,10 +262,11 @@ export const textWysiwyg = ({
containerDims.height -
textElementHeight -
getBoundTextElementOffset(updatedTextElement);
eCoordY = coordY + textElementHeight - eMetrics.height;
}
}
}
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
const [viewportX, viewportY] = getViewportCoords(coordX, eCoordY);
const initialSelectionStart = editable.selectionStart;
const initialSelectionEnd = editable.selectionEnd;
const initialLength = editable.value.length;
@ -268,10 +288,24 @@ export const textWysiwyg = ({
const lines = updatedTextElement.originalText.split("\n");
const lineHeight = updatedTextElement.containerId
? approxLineHeight
: updatedTextElement.height / lines.length;
: eMetrics.height / lines.length;
if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
}
// Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
const offWidth = container
? Math.min(
0,
updatedTextElement.width - Math.min(maxWidth, eMetrics.width),
)
: Math.min(maxWidth, updatedTextElement.width) -
Math.min(maxWidth, eMetrics.width);
const offsetX =
textAlign === "right"
? offWidth
: textAlign === "center"
? offWidth / 2
: 0;
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
@ -285,9 +319,12 @@ export const textWysiwyg = ({
height: `${textElementHeight}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
transformOrigin: `${updatedTextElement.width / 2}px
${updatedTextElement.height / 2}px`,
transform: getTransform(
width,
textElementHeight,
offsetX,
updatedTextElement.width,
updatedTextElement.height,
getTextElementAngle(updatedTextElement),
appState,
maxWidth,

View file

@ -1,3 +1,4 @@
import { Subtype } from "../subtypes";
import { Point } from "../types";
import {
FONT_FAMILY,
@ -63,6 +64,7 @@ type _ExcalidrawElementBase = Readonly<{
updated: number;
link: string | null;
locked: boolean;
subtype?: Subtype;
customData?: Record<string, any>;
}>;