Batching read/write baseline DOM measurements to avoid layour thrashing on resize, changed baseline calc. to not rely on absolute positioning, other related perf. improvements

This commit is contained in:
Marcel Mraz 2024-02-06 18:14:57 +01:00
parent 0513b647ec
commit 2939b03e78
3 changed files with 122 additions and 57 deletions

View File

@ -207,11 +207,16 @@ const restoreElement = (
: // no element height likely means programmatic use, so default : // no element height likely means programmatic use, so default
// to a fixed line height // to a fixed line height
getDefaultLineHeight(element.fontFamily)); getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline( const baseline = measureBaseline(
element.text, element.text,
getFontString(element), getFontString({
lineHeight, fontSize: element.fontSize,
fontFamily: element.fontFamily,
}),
String(lineHeight),
); );
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,

View File

@ -54,6 +54,8 @@ import {
getApproxMinLineHeight, getApproxMinLineHeight,
measureText, measureText,
getBoundTextMaxHeight, getBoundTextMaxHeight,
measureBaselines,
BaselineInput,
} from "./textElement"; } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
@ -769,6 +771,25 @@ export const resizeMultipleElements = (
}; };
}[] = []; }[] = [];
const precomputedBaselines = measureBaselines(
targetElements.reduce((inputs, { latest: element }) => {
if (!isTextElement(element)) {
return inputs;
}
inputs.push({
id: element.id,
text: element.text,
font: getFontString({
fontSize: element.fontSize,
fontFamily: element.fontFamily,
}),
lineHeight: String(element.lineHeight),
});
return inputs;
}, [] as BaselineInput[]),
);
for (const { orig, latest } of targetElements) { for (const { orig, latest } of targetElements) {
// bounded text elements are updated along with their container elements // bounded text elements are updated along with their container elements
if (isTextElement(orig) && isBoundToContainer(orig)) { if (isTextElement(orig) && isBoundToContainer(orig)) {
@ -838,17 +859,13 @@ export const resizeMultipleElements = (
} }
if (isTextElement(orig)) { if (isTextElement(orig)) {
const metrics = measureFontSizeFromWidth( const nextFontSize = orig.fontSize * scale;
orig, if (nextFontSize < MIN_FONT_SIZE) {
elementsMap,
width,
height,
);
if (!metrics) {
return; return;
} }
update.fontSize = metrics.size;
update.baseline = metrics.baseline; update.fontSize = nextFontSize;
update.baseline = precomputedBaselines.get(orig.id);
} }
const boundTextElement = originalElements.get( const boundTextElement = originalElements.get(

View File

@ -294,49 +294,74 @@ 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); const baseline = measureBaseline(text, font, String(lineHeight));
return { width, height, baseline }; return { width, height, baseline };
}; };
export const measureBaseline = ( export type BaselineInput = {
text: string, id: string;
font: FontString, font: FontString;
lineHeight: ExcalidrawTextElement["lineHeight"], lineHeight: string;
wrapInContainer?: boolean, text: string;
) => { };
/**
* Baseline calculation is based on expensive DOM operations, resulting in forced reflow.
* Therefore whenever we can, we should always batch the calculation of the baselines upfront for all the elements.
*/
export const measureBaselines = (inputs: BaselineInput[]) => {
const baselines = new Map<string, number>();
const containers = new Map<string, [HTMLDivElement, BaselineInput]>();
// Batch DOM writes (and reads below) to avoid layout trashing
for (const input of inputs) {
const container = document.createElement("div"); const container = document.createElement("div");
container.style.position = "absolute"; const span = document.createElement("span");
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); Object.assign(span.style, {
display: "inline-block",
// overflow: "hidden",
});
container.innerText = text; Object.assign(container.style, {
font: input.font,
lineHeight: input.lineHeight,
minHeight: "1em",
visibility: "hidden",
// whitespace: "pre",
// overflow: "hidden",
// wordBreak: "break-word",
// whiteSpace: "pre-wrap",
});
// Baseline is important for positioning text on canvas container.innerText = input.text;
container.appendChild(span);
document.body.appendChild(container); document.body.appendChild(container);
const span = document.createElement("span"); containers.set(input.id, [container, input]);
span.style.display = "inline-block"; }
span.style.overflow = "hidden";
span.style.width = "1px"; for (const [id, [container, input]] of containers.entries()) {
span.style.height = "1px"; const span = container.lastChild as HTMLSpanElement;
container.appendChild(span); let baseline =
let baseline = span.offsetTop + span.offsetHeight; span.getBoundingClientRect().y - container.getBoundingClientRect().y;
const height = container.offsetHeight;
if (isSafari) { if (isSafari) {
const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight); const height = container.offsetHeight;
const fontSize = parseFloat(font); const fontSize = parseFloat(input.font);
const canvasHeight = getTextHeight(
input.text,
fontSize,
Number(input.lineHeight) as any,
);
// In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs // 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 // from the actual canvas height
const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight); const domHeight = getTextHeight(
input.text,
Math.round(fontSize),
Number(input.lineHeight) as any,
);
if (canvasHeight > height) { if (canvasHeight > height) {
baseline += canvasHeight - domHeight; baseline += canvasHeight - domHeight;
} }
@ -345,8 +370,26 @@ export const measureBaseline = (
baseline -= domHeight - canvasHeight; baseline -= domHeight - canvasHeight;
} }
} }
baselines.set(id, baseline);
}
for (const [container] of containers.values()) {
document.body.removeChild(container); document.body.removeChild(container);
return baseline; }
return baselines;
};
export const measureBaseline = (
text: string,
font: FontString,
lineHeight: string,
) => {
// In single measurement the element id is irrelevant
const fakeId = "fake-id";
const baselines = measureBaselines([{ id: fakeId, text, font, lineHeight }]);
return baselines.get(fakeId)!;
}; };
/** /**