Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax

This commit is contained in:
Daniel J. Geiger 2023-04-10 20:24:03 -05:00
commit fb24221587
15 changed files with 287 additions and 115 deletions

View File

@ -42,9 +42,12 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const { width, height } = measureTextElement(boundTextElement, { const { width, height, baseline } = measureTextElement(
text: boundTextElement.originalText, boundTextElement,
}); {
text: boundTextElement.originalText,
},
);
const originalContainerHeight = getOriginalContainerHeightFromCache( const originalContainerHeight = getOriginalContainerHeightFromCache(
element.id, element.id,
); );
@ -54,6 +57,7 @@ export const actionUnbindText = register({
containerId: null, containerId: null,
width, width,
height, height,
baseline,
text: boundTextElement.originalText, text: boundTextElement.originalText,
}); });
mutateElement(element, { mutateElement(element, {

View File

@ -1,4 +1,5 @@
import { AppState } from "../../src/types"; import { AppState } from "../../src/types";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
@ -37,6 +38,7 @@ import {
TextAlignLeftIcon, TextAlignLeftIcon,
TextAlignCenterIcon, TextAlignCenterIcon,
TextAlignRightIcon, TextAlignRightIcon,
FillZigZagIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
@ -294,7 +296,12 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({ export const actionChangeFillStyle = register({
name: "changeFillStyle", name: "changeFillStyle",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
trackEvent(
"element",
"changeFillStyle",
`${value} (${app.device.isMobile ? "mobile" : "desktop"})`,
);
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
@ -305,40 +312,55 @@ export const actionChangeFillStyle = register({
commitToHistory: true, commitToHistory: true,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => {
<fieldset> const selectedElements = getSelectedElements(elements, appState);
<legend>{t("labels.fill")}</legend> const allElementsZigZag = selectedElements.every(
<ButtonIconSelect (el) => el.fillStyle === "zigzag",
options={[ );
{
value: "hachure", return (
text: t("labels.hachure"), <fieldset>
icon: FillHachureIcon, <legend>{t("labels.fill")}</legend>
}, <ButtonIconSelect
{ type="button"
value: "cross-hatch", options={[
text: t("labels.crossHatch"), {
icon: FillCrossHatchIcon, value: "hachure",
}, text: t("labels.hachure"),
{ icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
value: "solid", active: allElementsZigZag ? true : undefined,
text: t("labels.solid"), },
icon: FillSolidIcon, {
}, value: "cross-hatch",
]} text: t("labels.crossHatch"),
group="fill" icon: FillCrossHatchIcon,
value={getFormValue( },
elements, {
appState, value: "solid",
(element) => element.fillStyle, text: t("labels.solid"),
appState.currentItemFillStyle, icon: FillSolidIcon,
)} },
onChange={(value) => { ]}
updateData(value); value={getFormValue(
}} elements,
/> appState,
</fieldset> (element) => element.fillStyle,
), appState.currentItemFillStyle,
)}
onClick={(value, event) => {
const nextValue =
event.altKey &&
value === "hachure" &&
selectedElements.every((el) => el.fillStyle === "hachure")
? "zigzag"
: value;
updateData(nextValue);
}}
/>
</fieldset>
);
},
}); });
export const actionChangeStrokeWidth = register({ export const actionChangeStrokeWidth = register({

View File

@ -1,33 +1,59 @@
import clsx from "clsx"; import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect /> // TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>({ export const ButtonIconSelect = <T extends Object>(
options, props: {
value, options: {
onChange, value: T;
group, text: string;
}: { icon: JSX.Element;
options: { value: T; text: string; icon: JSX.Element; testId?: string }[]; testId?: string;
value: T | null; /** if not supplied, defaults to value identity check */
onChange: (value: T) => void; active?: boolean;
group: string; }[];
}) => ( value: T | null;
type?: "radio" | "button";
} & (
| { type?: "radio"; group: string; onChange: (value: T) => void }
| {
type: "button";
onClick: (
value: T,
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => void;
}
),
) => (
<div className="buttonList buttonListIcon"> <div className="buttonList buttonListIcon">
{options.map((option) => ( {props.options.map((option) =>
<label props.type === "button" ? (
key={option.text} <button
className={clsx({ active: value === option.value })} key={option.text}
title={option.text} onClick={(event) => props.onClick(option.value, event)}
> className={clsx({
<input active: option.active ?? props.value === option.value,
type="radio" })}
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
data-testid={option.testId} data-testid={option.testId}
/> title={option.text}
{option.icon} >
</label> {option.icon}
))} </button>
) : (
<label
key={option.text}
className={clsx({ active: props.value === option.value })}
title={option.text}
>
<input
type="radio"
name={props.group}
onChange={() => props.onChange(option.value)}
checked={props.value === option.value}
data-testid={option.testId}
/>
{option.icon}
</label>
),
)}
</div> </div>
); );

View File

@ -1008,6 +1008,13 @@ export const UngroupIcon = React.memo(({ theme }: { theme: Theme }) =>
), ),
); );
export const FillZigZagIcon = createIcon(
<g strokeWidth={1.25}>
<path d="M5.879 2.625h8.242a3.27 3.27 0 0 1 3.254 3.254v8.242a3.27 3.27 0 0 1-3.254 3.254H5.88a3.27 3.27 0 0 1-3.254-3.254V5.88A3.27 3.27 0 0 1 5.88 2.626l-.001-.001ZM4.518 16.118l7.608-12.83m.198 13.934 5.051-9.897M2.778 9.675l9.348-6.387m-7.608 12.83 12.857-8.793" />
</g>,
modifiedTablerIconProps,
);
export const FillHachureIcon = createIcon( export const FillHachureIcon = createIcon(
<> <>
<path <path

View File

@ -155,6 +155,9 @@
margin: 1px; margin: 1px;
} }
.welcome-screen-menu-item:focus-visible,
.dropdown-menu-item:focus-visible,
button:focus-visible,
.buttonList label:focus-within, .buttonList label:focus-within,
input:focus-visible { input:focus-visible {
outline: transparent; outline: transparent;

View File

@ -36,7 +36,11 @@ import { arrayToMap } from "../utils";
import { isValidSubtype } from "../subtypes"; import { isValidSubtype } from "../subtypes";
import oc from "open-color"; import oc from "open-color";
import { MarkOptional, Mutable } from "../utility-types"; import { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getDefaultLineHeight } from "../element/textElement"; import {
detectLineHeight,
getDefaultLineHeight,
measureTextElement,
} from "../element/textElement";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@ -176,6 +180,20 @@ const restoreElement = (
} }
const text = element.text ?? ""; const text = element.text ?? "";
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
// For the latter we want to detect the original line height which
// will likely differ from our per-font fixed line height we now use,
// to maintain backward compatibility.
const lineHeight =
element.lineHeight ||
(element.height
? // detect line-height from current element height and font-size
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureTextElement(element, { text }).baseline;
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,
@ -184,19 +202,9 @@ const restoreElement = (
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,
originalText: element.originalText || text, originalText: element.originalText || text,
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams. lineHeight,
// For the latter we want to detect the original line height which baseline,
// will likely differ from our per-font fixed line height we now use,
// to maintain backward compatibility.
lineHeight:
element.lineHeight ||
(element.height
? // detect line-height from current element height and font-size
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily)),
}); });
if (refreshDimensions) { if (refreshDimensions) {

View File

@ -184,6 +184,7 @@ export const newTextElement = (
}, },
); );
const offsets = getTextElementPositionOffsets(opts, metrics); const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith( const textElement = newElementWith(
{ {
..._newElementBase<ExcalidrawTextElement>("text", opts), ..._newElementBase<ExcalidrawTextElement>("text", opts),
@ -196,6 +197,7 @@ export const newTextElement = (
y: opts.y - offsets.y, y: opts.y - offsets.y,
width: metrics.width, width: metrics.width,
height: metrics.height, height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: text, originalText: text,
lineHeight, lineHeight,
@ -213,10 +215,15 @@ const getAdjustedDimensions = (
y: number; y: number;
width: number; width: number;
height: number; height: number;
baseline: number;
} => { } => {
const container = getContainerElement(element); const container = getContainerElement(element);
const { width: nextWidth, height: nextHeight } = measureTextElement(element, { const {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureTextElement(element, {
text: nextText, text: nextText,
}); });
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
@ -289,6 +296,7 @@ const getAdjustedDimensions = (
return { return {
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseline,
x: Number.isFinite(x) ? x : element.x, x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y, y: Number.isFinite(y) ? y : element.y,
}; };
@ -298,6 +306,9 @@ export const refreshTextDimensions = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
text = textElement.text, text = textElement.text,
) => { ) => {
if (textElement.isDeleted) {
return;
}
const container = getContainerElement(textElement); const container = getContainerElement(textElement);
if (container) { if (container) {
text = wrapTextElement(textElement, getMaxContainerWidth(container), { text = wrapTextElement(textElement, getMaxContainerWidth(container), {

View File

@ -46,6 +46,8 @@ import {
handleBindTextResize, handleBindTextResize,
getMaxContainerWidth, getMaxContainerWidth,
getApproxMinLineHeight, getApproxMinLineHeight,
measureTextElement,
getMaxContainerHeight,
} from "./textElement"; } from "./textElement";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
@ -193,7 +195,8 @@ const MIN_FONT_SIZE = 1;
const measureFontSizeFromWidth = ( const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
nextWidth: number, nextWidth: number,
): number | null => { nextHeight: number,
): { size: number; baseline: number } | null => {
// We only use width to scale font on resize // We only use width to scale font on resize
let width = element.width; let width = element.width;
@ -208,8 +211,11 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) { if (nextFontSize < MIN_FONT_SIZE) {
return null; return null;
} }
const metrics = measureTextElement(element, { fontSize: nextFontSize });
return nextFontSize; return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
};
}; };
const getSidesForTransformHandle = ( const getSidesForTransformHandle = (
@ -280,8 +286,8 @@ const resizeSingleTextElement = (
if (scale > 0) { if (scale > 0) {
const nextWidth = element.width * scale; const nextWidth = element.width * scale;
const nextHeight = element.height * scale; const nextHeight = element.height * scale;
const nextFontSize = measureFontSizeFromWidth(element, nextWidth); const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight);
if (nextFontSize === null) { if (metrics === null) {
return; return;
} }
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
@ -305,9 +311,10 @@ const resizeSingleTextElement = (
deltaY2, deltaY2,
); );
mutateElement(element, { mutateElement(element, {
fontSize: nextFontSize, fontSize: metrics.size,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: metrics.baseline,
x: nextElementX, x: nextElementX,
y: nextElementY, y: nextElementY,
}); });
@ -360,7 +367,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight; let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFontSize: number | null = null; let boundTextFont: { fontSize?: number; baseline?: number } = {};
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (transformHandleDirection.includes("e")) { if (transformHandleDirection.includes("e")) {
@ -410,7 +417,10 @@ export const resizeSingleElement = (
boundTextElement.id, boundTextElement.id,
) as typeof boundTextElement | undefined; ) as typeof boundTextElement | undefined;
if (stateOfBoundTextElementAtResize) { if (stateOfBoundTextElementAtResize) {
boundTextFontSize = stateOfBoundTextElementAtResize.fontSize; boundTextFont = {
fontSize: stateOfBoundTextElementAtResize.fontSize,
baseline: stateOfBoundTextElementAtResize.baseline,
};
} }
if (shouldMaintainAspectRatio) { if (shouldMaintainAspectRatio) {
const updatedElement = { const updatedElement = {
@ -419,14 +429,18 @@ export const resizeSingleElement = (
height: eleNewHeight, height: eleNewHeight,
}; };
const nextFontSize = measureFontSizeFromWidth( const nextFont = measureFontSizeFromWidth(
boundTextElement, boundTextElement,
getMaxContainerWidth(updatedElement), getMaxContainerWidth(updatedElement),
getMaxContainerHeight(updatedElement),
); );
if (nextFontSize === null) { if (nextFont === null) {
return; return;
} }
boundTextFontSize = nextFontSize; boundTextFont = {
fontSize: nextFont.size,
baseline: nextFont.baseline,
};
} else { } else {
const minWidth = getApproxMinLineWidth( const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement), getFontString(boundTextElement),
@ -568,9 +582,10 @@ export const resizeSingleElement = (
}); });
mutateElement(element, resizedElement); mutateElement(element, resizedElement);
if (boundTextElement && boundTextFontSize != null) { if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, { mutateElement(boundTextElement, {
fontSize: boundTextFontSize, fontSize: boundTextFont.fontSize,
baseline: boundTextFont.baseline,
}); });
} }
handleBindTextResize(element, transformHandleDirection); handleBindTextResize(element, transformHandleDirection);
@ -677,6 +692,7 @@ const resizeMultipleElements = (
y: number; y: number;
points?: Point[]; points?: Point[];
fontSize?: number; fontSize?: number;
baseline?: number;
} = { } = {
width, width,
height, height,
@ -685,7 +701,7 @@ const resizeMultipleElements = (
...rescaledPoints, ...rescaledPoints,
}; };
let boundTextUpdates: { fontSize: number } | null = null; let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
const boundTextElement = getBoundTextElement(element.latest); const boundTextElement = getBoundTextElement(element.latest);
@ -695,24 +711,29 @@ const resizeMultipleElements = (
width, width,
height, height,
}; };
const fontSize = measureFontSizeFromWidth( const metrics = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement), boundTextElement ?? (element.orig as ExcalidrawTextElement),
boundTextElement boundTextElement
? getMaxContainerWidth(updatedElement) ? getMaxContainerWidth(updatedElement)
: updatedElement.width, : updatedElement.width,
boundTextElement
? getMaxContainerHeight(updatedElement)
: updatedElement.height,
); );
if (!fontSize) { if (!metrics) {
return; return;
} }
if (isTextElement(element.orig)) { if (isTextElement(element.orig)) {
update.fontSize = fontSize; update.fontSize = metrics.size;
update.baseline = metrics.baseline;
} }
if (boundTextElement) { if (boundTextElement) {
boundTextUpdates = { boundTextUpdates = {
fontSize, fontSize: metrics.size,
baseline: metrics.baseline,
}; };
} }
} }

View File

@ -15,6 +15,7 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
isSafari,
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
@ -83,6 +84,7 @@ export const redrawTextBoundingBox = (
text: textElement.text, text: textElement.text,
width: textElement.width, width: textElement.width,
height: textElement.height, height: textElement.height,
baseline: textElement.baseline,
}; };
boundTextUpdates.text = textElement.text; boundTextUpdates.text = textElement.text;
@ -97,6 +99,7 @@ export const redrawTextBoundingBox = (
boundTextUpdates.width = metrics.width; boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height; boundTextUpdates.height = metrics.height;
boundTextUpdates.baseline = metrics.baseline;
// Maintain coordX for non left-aligned text in case the width has changed // Maintain coordX for non left-aligned text in case the width has changed
if (!container) { if (!container) {
@ -210,13 +213,15 @@ export const handleBindTextResize = (
const maxWidth = getMaxContainerWidth(container); const maxWidth = getMaxContainerWidth(container);
const maxHeight = getMaxContainerHeight(container); const maxHeight = getMaxContainerHeight(container);
let containerHeight = containerDims.height; let containerHeight = containerDims.height;
let nextBaseLine = textElement.baseline;
if (transformHandleType !== "n" && transformHandleType !== "s") { if (transformHandleType !== "n" && transformHandleType !== "s") {
if (text) { if (text) {
text = wrapTextElement(textElement, maxWidth); text = wrapTextElement(textElement, maxWidth);
} }
const dimensions = measureTextElement(textElement, { text }); const metrics = measureTextElement(textElement, { text });
nextHeight = dimensions.height; nextHeight = metrics.height;
nextWidth = dimensions.width; nextWidth = metrics.width;
nextBaseLine = metrics.baseline;
} }
// increase height in case text element height exceeds // increase height in case text element height exceeds
if (nextHeight > maxHeight) { if (nextHeight > maxHeight) {
@ -244,6 +249,7 @@ export const handleBindTextResize = (
text, text,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseLine,
}); });
if (!isArrowElement(container)) { if (!isArrowElement(container)) {
@ -304,8 +310,59 @@ export const measureText = (
const fontSize = parseFloat(font); const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight); const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font); const width = getTextWidth(text, font);
const baseline = measureBaseline(text, font, lineHeight);
return { width, height, baseline };
};
return { width, height }; export const measureBaseline = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
wrapInContainer?: boolean,
) => {
const container = document.createElement("div");
container.style.position = "absolute";
container.style.whiteSpace = "pre";
container.style.font = font;
container.style.minHeight = "1em";
if (wrapInContainer) {
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.whiteSpace = "pre-wrap";
}
container.style.lineHeight = String(lineHeight);
container.innerText = text;
// Baseline is important for positioning text on canvas
document.body.appendChild(container);
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);
let baseline = span.offsetTop + span.offsetHeight;
const height = container.offsetHeight;
if (isSafari) {
const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight);
const fontSize = parseFloat(font);
// In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs
// from the actual canvas height
const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight);
if (canvasHeight > height) {
baseline += canvasHeight - domHeight;
}
if (height > canvasHeight) {
baseline -= domHeight - canvasHeight;
}
}
document.body.removeChild(container);
return baseline;
}; };
/** /**

View File

@ -11,7 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { CLASSES, VERTICAL_ALIGN } from "../constants"; import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -35,6 +35,7 @@ import {
getMaxContainerHeight, getMaxContainerHeight,
getMaxContainerWidth, getMaxContainerWidth,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
detectLineHeight,
} from "./textElement"; } from "./textElement";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
@ -328,13 +329,24 @@ export const textWysiwyg = ({
? offWidth / 2 ? offWidth / 2
: 0; : 0;
const { width: w, height: h } = updatedTextElement; const { width: w, height: h } = updatedTextElement;
let lineHeight = updatedTextElement.lineHeight;
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
if (isSafari) {
lineHeight = detectLineHeight({
...updatedTextElement,
fontSize: Math.round(updatedTextElement.fontSize),
});
}
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
const editorMaxHeight = const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value; (appState.height - viewportY) / appState.zoom.value;
Object.assign(editable.style, { Object.assign(editable.style, {
font: getFontString(updatedTextElement), font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯ // must be defined *after* font ¯\_(ツ)_/¯
lineHeight: element.lineHeight, lineHeight,
width: `${Math.min(textElementWidth, maxWidth)}px`, width: `${Math.min(textElementWidth, maxWidth)}px`,
height: `${textElementHeight}px`, height: `${textElementHeight}px`,
left: `${viewportX}px`, left: `${viewportX}px`,

View File

@ -10,7 +10,7 @@ import {
import { MarkNonNullable, ValueOf } from "../utility-types"; import { MarkNonNullable, ValueOf } from "../utility-types";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY; export type FontFamilyKeys = keyof typeof FONT_FAMILY;
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys]; export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
export type Theme = typeof THEME[keyof typeof THEME]; export type Theme = typeof THEME[keyof typeof THEME];
@ -133,6 +133,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
fontSize: number; fontSize: number;
fontFamily: FontFamilyValues; fontFamily: FontFamilyValues;
text: string; text: string;
baseline: number;
textAlign: TextAlign; textAlign: TextAlign;
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null; containerId: ExcalidrawGenericElement["id"] | null;

View File

@ -5,6 +5,7 @@ import { getFontString, getFontFamilyString, isRTL } from "../../../../utils";
import { import {
getBoundTextElement, getBoundTextElement,
getContainerElement, getContainerElement,
getDefaultLineHeight,
getMaxContainerWidth, getMaxContainerWidth,
getTextWidth, getTextWidth,
measureText, measureText,
@ -859,7 +860,7 @@ const ensureMathElement = (element: Partial<ExcalidrawElement>) => {
const cleanMathElementUpdate = function (updates) { const cleanMathElementUpdate = function (updates) {
const oldUpdates = {}; const oldUpdates = {};
for (const key in updates) { for (const key in updates) {
if (key !== "fontFamily") { if (key !== "fontFamily" && key !== "lineHeight") {
(oldUpdates as any)[key] = (updates as any)[key]; (oldUpdates as any)[key] = (updates as any)[key];
} }
if (key === "customData") { if (key === "customData") {
@ -874,6 +875,7 @@ const cleanMathElementUpdate = function (updates) {
} }
} }
(updates as any).fontFamily = FONT_FAMILY_MATH; (updates as any).fontFamily = FONT_FAMILY_MATH;
(updates as any).lineHeight = getDefaultLineHeight(FONT_FAMILY_MATH);
return oldUpdates; return oldUpdates;
} as SubtypeMethods["clean"]; } as SubtypeMethods["clean"];
@ -888,8 +890,8 @@ const measureMathElement = function (element, next) {
ensureMathElement(element); ensureMathElement(element);
const isMathJaxLoaded = mathJaxLoaded; const isMathJaxLoaded = mathJaxLoaded;
if (!isMathJaxLoaded && isMathElement(element as ExcalidrawElement)) { if (!isMathJaxLoaded && isMathElement(element as ExcalidrawElement)) {
const { width, height } = element as ExcalidrawMathElement; const { width, height, baseline } = element as ExcalidrawMathElement;
return { width, height }; return { width, height, baseline };
} }
const fontSize = next?.fontSize ?? element.fontSize; const fontSize = next?.fontSize ?? element.fontSize;
const lineHeight = element.lineHeight; const lineHeight = element.lineHeight;
@ -903,8 +905,7 @@ const measureMathElement = function (element, next) {
mathProps, mathProps,
isMathJaxLoaded, isMathJaxLoaded,
); );
const { width, height } = metrics; return metrics;
return { width, height };
} as SubtypeMethods["measureText"]; } as SubtypeMethods["measureText"];
const renderMathElement = function (element, context, renderCb) { const renderMathElement = function (element, context, renderCb) {

View File

@ -246,7 +246,6 @@ const drawImagePlaceholder = (
size, size,
); );
}; };
const drawElementOnCanvas = ( const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
rc: RoughCanvas, rc: RoughCanvas,
@ -338,18 +337,16 @@ const drawElementOnCanvas = (
: element.textAlign === "right" : element.textAlign === "right"
? element.width ? element.width
: 0; : 0;
context.textBaseline = "bottom";
const lineHeightPx = getLineHeightInPx( const lineHeightPx = getLineHeightInPx(
element.fontSize, element.fontSize,
element.lineHeight, element.lineHeight,
); );
const verticalOffset = element.height - element.baseline;
for (let index = 0; index < lines.length; index++) { for (let index = 0; index < lines.length; index++) {
context.fillText( context.fillText(
lines[index], lines[index],
horizontalOffset, horizontalOffset,
(index + 1) * lineHeightPx, (index + 1) * lineHeightPx - verticalOffset,
); );
} }
context.restore(); context.restore();

View File

@ -237,7 +237,7 @@ export type SubtypeMethods = {
text?: string; text?: string;
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
}, },
) => { width: number; height: number }; ) => { width: number; height: number; baseline: number };
render: ( render: (
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,

View File

@ -282,6 +282,7 @@ exports[`restoreElements should restore text element correctly passing value for
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0,
"boundElements": Array [], "boundElements": Array [],
"containerId": null, "containerId": null,
"fillStyle": "hachure", "fillStyle": "hachure",
@ -321,6 +322,7 @@ exports[`restoreElements should restore text element correctly with unknown font
Object { Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0,
"boundElements": Array [], "boundElements": Array [],
"containerId": null, "containerId": null,
"fillStyle": "hachure", "fillStyle": "hachure",