From 2939b03e78f0bbc7c454d9b9ea897f5c8115fd34 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Tue, 6 Feb 2024 18:14:57 +0100 Subject: [PATCH] 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 --- packages/excalidraw/data/restore.ts | 9 +- packages/excalidraw/element/resizeElements.ts | 35 +++-- packages/excalidraw/element/textElement.ts | 135 ++++++++++++------ 3 files changed, 122 insertions(+), 57 deletions(-) diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 12e7f1af1..7e4c16940 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -207,11 +207,16 @@ const restoreElement = ( : // no element height likely means programmatic use, so default // to a fixed line height getDefaultLineHeight(element.fontFamily)); + const baseline = measureBaseline( element.text, - getFontString(element), - lineHeight, + getFontString({ + fontSize: element.fontSize, + fontFamily: element.fontFamily, + }), + String(lineHeight), ); + element = restoreElementWithProperties(element, { fontSize, fontFamily, diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index deb5fead3..da38a4ce5 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -54,6 +54,8 @@ import { getApproxMinLineHeight, measureText, getBoundTextMaxHeight, + measureBaselines, + BaselineInput, } from "./textElement"; 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) { // bounded text elements are updated along with their container elements if (isTextElement(orig) && isBoundToContainer(orig)) { @@ -838,17 +859,13 @@ export const resizeMultipleElements = ( } if (isTextElement(orig)) { - const metrics = measureFontSizeFromWidth( - orig, - elementsMap, - width, - height, - ); - if (!metrics) { + const nextFontSize = orig.fontSize * scale; + if (nextFontSize < MIN_FONT_SIZE) { return; } - update.fontSize = metrics.size; - update.baseline = metrics.baseline; + + update.fontSize = nextFontSize; + update.baseline = precomputedBaselines.get(orig.id); } const boundTextElement = originalElements.get( diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index fc4c15f2d..cef19611f 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -294,59 +294,102 @@ export const measureText = ( const fontSize = parseFloat(font); const height = getTextHeight(text, fontSize, lineHeight); const width = getTextWidth(text, font); - const baseline = measureBaseline(text, font, lineHeight); + const baseline = measureBaseline(text, font, String(lineHeight)); return { width, height, baseline }; }; +export type BaselineInput = { + id: string; + font: FontString; + lineHeight: string; + 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(); + const containers = new Map(); + + // Batch DOM writes (and reads below) to avoid layout trashing + for (const input of inputs) { + const container = document.createElement("div"); + const span = document.createElement("span"); + + Object.assign(span.style, { + display: "inline-block", + // overflow: "hidden", + }); + + Object.assign(container.style, { + font: input.font, + lineHeight: input.lineHeight, + minHeight: "1em", + visibility: "hidden", + // whitespace: "pre", + // overflow: "hidden", + // wordBreak: "break-word", + // whiteSpace: "pre-wrap", + }); + + container.innerText = input.text; + + container.appendChild(span); + document.body.appendChild(container); + + containers.set(input.id, [container, input]); + } + + for (const [id, [container, input]] of containers.entries()) { + const span = container.lastChild as HTMLSpanElement; + let baseline = + span.getBoundingClientRect().y - container.getBoundingClientRect().y; + + if (isSafari) { + const height = container.offsetHeight; + 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 + // from the actual canvas height + const domHeight = getTextHeight( + input.text, + Math.round(fontSize), + Number(input.lineHeight) as any, + ); + if (canvasHeight > height) { + baseline += canvasHeight - domHeight; + } + + if (height > canvasHeight) { + baseline -= domHeight - canvasHeight; + } + } + + baselines.set(id, baseline); + } + + for (const [container] of containers.values()) { + document.body.removeChild(container); + } + + return baselines; +}; + export const measureBaseline = ( text: string, font: FontString, - lineHeight: ExcalidrawTextElement["lineHeight"], - wrapInContainer?: boolean, + lineHeight: string, ) => { - 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; + // In single measurement the element id is irrelevant + const fakeId = "fake-id"; + const baselines = measureBaselines([{ id: fakeId, text, font, lineHeight }]); + return baselines.get(fakeId)!; }; /**