feat: improve text measurements in bound containers (#6187)
* feat: move to canvas measureText * calcualte height with better heuristic * improve heuristic more * remove vertical offset as its not needed * lint * calculate width of individual char and ceil to calculate width and remove adjustment factor * push the word if equal to max width * update height when text overflows for vertical alignment top/bottom * remove the hack of updating height when line mismatch as its not needed * remove scroll height and calculate the height instead * remove unused code * fix * remove * use math.ceil for whole width instead of individual chars * fix tests * fix * fix * redraw text bounding box instead when font loaded to fix alignment as well * fix * fix * fix * Add a 0.05px extra only for firefox * Add spec * stop taking ceil and increase firefox editor width by 0.05px * Ad 0.05px in safari too * lint * lint * remove baseline from measureFontSizeFromWH * don't redraw on font load * lint * refactor name and signature
This commit is contained in:
parent
39b96cb011
commit
9659254fd6
@ -38,7 +38,7 @@ export const actionUnbindText = register({
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
const { width, height } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
);
|
||||
@ -51,7 +51,6 @@ export const actionUnbindText = register({
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
mutateElement(element, {
|
||||
|
@ -2674,14 +2674,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
element,
|
||||
]);
|
||||
}
|
||||
|
||||
// case: creating new text not centered to parent element → offset Y
|
||||
// so that the text is centered to cursor position
|
||||
if (!parentCenterPosition) {
|
||||
mutateElement(element, {
|
||||
y: element.y - element.baseline / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
|
@ -9,6 +9,9 @@ export const isFirefox =
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
|
||||
|
@ -171,7 +171,6 @@ const restoreElement = (
|
||||
fontSize,
|
||||
fontFamily,
|
||||
text: element.text ?? "",
|
||||
baseline: element.baseline,
|
||||
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: element.containerId ?? null,
|
||||
|
@ -153,7 +153,6 @@ export const newTextElement = (
|
||||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
},
|
||||
@ -170,18 +169,13 @@ const getAdjustedDimensions = (
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
let maxWidth = null;
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
}
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseline,
|
||||
} = measureText(nextText, getFontString(element), maxWidth);
|
||||
|
||||
const { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
);
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
let y: number;
|
||||
@ -190,11 +184,7 @@ const getAdjustedDimensions = (
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId
|
||||
) {
|
||||
const prevMetrics = measureText(
|
||||
element.text,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
const prevMetrics = measureText(element.text, getFontString(element));
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
height: nextHeight - prevMetrics.height,
|
||||
@ -258,7 +248,6 @@ const getAdjustedDimensions = (
|
||||
height: nextHeight,
|
||||
x: Number.isFinite(x) ? x : element.x,
|
||||
y: Number.isFinite(y) ? y : element.y,
|
||||
baseline: nextBaseline,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -45,8 +45,6 @@ import {
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
measureText,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
} from "./textElement";
|
||||
|
||||
@ -192,11 +190,10 @@ const rescalePointsInElement = (
|
||||
|
||||
const MIN_FONT_SIZE = 1;
|
||||
|
||||
const measureFontSizeFromWH = (
|
||||
const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
): { size: number; baseline: number } | null => {
|
||||
): number | null => {
|
||||
// We only use width to scale font on resize
|
||||
let width = element.width;
|
||||
|
||||
@ -211,15 +208,8 @@ const measureFontSizeFromWH = (
|
||||
if (nextFontSize < MIN_FONT_SIZE) {
|
||||
return null;
|
||||
}
|
||||
const metrics = measureText(
|
||||
element.text,
|
||||
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
||||
element.containerId ? width : null,
|
||||
);
|
||||
return {
|
||||
size: nextFontSize,
|
||||
baseline: metrics.baseline + (nextHeight - metrics.height),
|
||||
};
|
||||
|
||||
return nextFontSize;
|
||||
};
|
||||
|
||||
const getSidesForTransformHandle = (
|
||||
@ -290,8 +280,8 @@ const resizeSingleTextElement = (
|
||||
if (scale > 0) {
|
||||
const nextWidth = element.width * scale;
|
||||
const nextHeight = element.height * scale;
|
||||
const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight);
|
||||
if (nextFont === null) {
|
||||
const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
|
||||
if (nextFontSize === null) {
|
||||
return;
|
||||
}
|
||||
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
||||
@ -315,10 +305,9 @@ const resizeSingleTextElement = (
|
||||
deltaY2,
|
||||
);
|
||||
mutateElement(element, {
|
||||
fontSize: nextFont.size,
|
||||
fontSize: nextFontSize,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextFont.baseline,
|
||||
x: nextElementX,
|
||||
y: nextElementY,
|
||||
});
|
||||
@ -371,7 +360,7 @@ export const resizeSingleElement = (
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||
|
||||
let boundTextFont: { fontSize?: number; baseline?: number } = {};
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
@ -423,7 +412,6 @@ export const resizeSingleElement = (
|
||||
if (stateOfBoundTextElementAtResize) {
|
||||
boundTextFont = {
|
||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||
baseline: stateOfBoundTextElementAtResize.baseline,
|
||||
};
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
@ -433,17 +421,15 @@ export const resizeSingleElement = (
|
||||
height: eleNewHeight,
|
||||
};
|
||||
|
||||
const nextFont = measureFontSizeFromWH(
|
||||
const nextFontSize = measureFontSizeFromWidth(
|
||||
boundTextElement,
|
||||
getMaxContainerWidth(updatedElement),
|
||||
getMaxContainerHeight(updatedElement),
|
||||
);
|
||||
if (nextFont === null) {
|
||||
if (nextFontSize === null) {
|
||||
return;
|
||||
}
|
||||
boundTextFont = {
|
||||
fontSize: nextFont.size,
|
||||
baseline: nextFont.baseline,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
@ -687,7 +673,6 @@ const resizeMultipleElements = (
|
||||
y: number;
|
||||
points?: Point[];
|
||||
fontSize?: number;
|
||||
baseline?: number;
|
||||
} = {
|
||||
width,
|
||||
height,
|
||||
@ -696,7 +681,7 @@ const resizeMultipleElements = (
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
||||
let boundTextUpdates: { fontSize: number } | null = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(element.latest);
|
||||
|
||||
@ -706,25 +691,22 @@ const resizeMultipleElements = (
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const textMeasurements = measureFontSizeFromWH(
|
||||
const fontSize = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
getMaxContainerWidth(updatedElement),
|
||||
getMaxContainerHeight(updatedElement),
|
||||
);
|
||||
|
||||
if (!textMeasurements) {
|
||||
if (!fontSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(element.orig)) {
|
||||
update.fontSize = textMeasurements.size;
|
||||
update.baseline = textMeasurements.baseline;
|
||||
update.fontSize = fontSize;
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
boundTextUpdates = {
|
||||
fontSize: textMeasurements.size,
|
||||
baseline: textMeasurements.baseline,
|
||||
fontSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import {
|
||||
getContainerCoords,
|
||||
getMaxContainerWidth,
|
||||
getMaxContainerHeight,
|
||||
measureText,
|
||||
wrapText,
|
||||
} from "./textElement";
|
||||
import { FontString } from "./types";
|
||||
@ -73,6 +72,13 @@ up`,
|
||||
width: 250,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
{
|
||||
desc: "should push the word if its equal to max width",
|
||||
width: 60,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
@ -80,6 +86,7 @@ up`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello
|
||||
whats up`;
|
||||
@ -170,38 +177,6 @@ break it now`,
|
||||
});
|
||||
|
||||
describe("Test measureText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
const text = "Hello World";
|
||||
|
||||
it("should add correct attributes when maxWidth is passed", () => {
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = measureText(text, font, maxWidth);
|
||||
|
||||
expect(res.container).toMatchInlineSnapshot(`
|
||||
<div
|
||||
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; max-width: 191px; overflow: hidden; word-break: break-word; line-height: 0px;"
|
||||
>
|
||||
<span
|
||||
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
it("should add correct attributes when maxWidth is not passed", () => {
|
||||
const res = measureText(text, font);
|
||||
|
||||
expect(res.container).toMatchInlineSnapshot(`
|
||||
<div
|
||||
style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;"
|
||||
>
|
||||
<span
|
||||
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
describe("Test getContainerCoords", () => {
|
||||
const params = { width: 200, height: 100, x: 10, y: 20 };
|
||||
|
||||
|
@ -50,7 +50,6 @@ export const redrawTextBoundingBox = (
|
||||
text: textElement.text,
|
||||
width: textElement.width,
|
||||
height: textElement.height,
|
||||
baseline: textElement.baseline,
|
||||
};
|
||||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
@ -66,12 +65,10 @@ export const redrawTextBoundingBox = (
|
||||
const metrics = measureText(
|
||||
boundTextUpdates.text,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
|
||||
boundTextUpdates.width = metrics.width;
|
||||
boundTextUpdates.height = metrics.height;
|
||||
boundTextUpdates.baseline = metrics.baseline;
|
||||
|
||||
if (container) {
|
||||
if (isArrowElement(container)) {
|
||||
@ -177,7 +174,6 @@ export const handleBindTextResize = (
|
||||
const maxWidth = getMaxContainerWidth(container);
|
||||
const maxHeight = getMaxContainerHeight(container);
|
||||
let containerHeight = containerDims.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
@ -186,14 +182,9 @@ export const handleBindTextResize = (
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
const dimensions = measureText(text, getFontString(textElement));
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
nextBaseLine = dimensions.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > maxHeight) {
|
||||
@ -221,7 +212,6 @@ export const handleBindTextResize = (
|
||||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
|
||||
if (!isArrowElement(container)) {
|
||||
@ -267,51 +257,19 @@ const computeBoundTextPosition = (
|
||||
};
|
||||
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth?: number | null,
|
||||
) => {
|
||||
|
||||
export const measureText = (text: string, font: FontString) => {
|
||||
text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const container = document.createElement("div");
|
||||
container.style.position = "absolute";
|
||||
container.style.whiteSpace = "pre";
|
||||
container.style.font = font;
|
||||
container.style.minHeight = "1em";
|
||||
|
||||
if (maxWidth) {
|
||||
const lineHeight = getApproxLineHeight(font);
|
||||
// since we are adding a span of width 1px later
|
||||
container.style.maxWidth = `${maxWidth + 1}px`;
|
||||
container.style.overflow = "hidden";
|
||||
container.style.wordBreak = "break-word";
|
||||
container.style.lineHeight = `${String(lineHeight)}px`;
|
||||
container.style.whiteSpace = "pre-wrap";
|
||||
}
|
||||
document.body.appendChild(container);
|
||||
container.innerText = text;
|
||||
const height = getTextHeight(text, font);
|
||||
const width = getTextWidth(text, font);
|
||||
|
||||
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;
|
||||
// since we are adding a span of width 1px
|
||||
const width = container.offsetWidth + 1;
|
||||
const height = container.offsetHeight;
|
||||
document.body.removeChild(container);
|
||||
if (isTestEnv()) {
|
||||
return { width, height, baseline, container };
|
||||
}
|
||||
return { width, height, baseline };
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
@ -321,40 +279,45 @@ export const getApproxLineHeight = (font: FontString) => {
|
||||
if (cacheApproxLineHeight[font]) {
|
||||
return cacheApproxLineHeight[font];
|
||||
}
|
||||
cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height;
|
||||
const fontSize = parseInt(font);
|
||||
cacheApproxLineHeight[font] = fontSize * 1.2;
|
||||
return cacheApproxLineHeight[font];
|
||||
};
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const width = canvas2dContext.measureText(text).width;
|
||||
|
||||
const metrics = canvas2dContext.measureText(text);
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return metrics.width * 10;
|
||||
return width * 10;
|
||||
}
|
||||
// Since measureText behaves differently in different browsers
|
||||
// OS so considering a adjustment factor of 0.2
|
||||
const adjustmentFactor = 0.2;
|
||||
|
||||
return metrics.width + adjustmentFactor;
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = text.split("\n");
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (text: string, font: FontString) => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = getApproxLineHeight(font);
|
||||
return lineHeight * lines.length;
|
||||
};
|
||||
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
@ -376,16 +339,23 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
||||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
push(words[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Start breaking longer words exceeding max width
|
||||
if (currentWordWidth >= maxWidth) {
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
push(currentLine);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!,
|
||||
@ -486,9 +456,9 @@ export const charWidth = (() => {
|
||||
getCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export const getApproxMinLineWidth = (font: FontString) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||
|
@ -6,12 +6,11 @@ import { CODES, KEYS } from "../keys";
|
||||
import { fireEvent } from "../tests/test-utils";
|
||||
import { queryByText } from "@testing-library/react";
|
||||
|
||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import { FONT_FAMILY } from "../constants";
|
||||
import {
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import * as textElementUtils from "./textElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { resize } from "../tests/utils";
|
||||
@ -440,17 +439,6 @@ describe("textWysiwyg", () => {
|
||||
let rectangle: any;
|
||||
const { h } = window;
|
||||
|
||||
const DUMMY_HEIGHT = 240;
|
||||
const DUMMY_WIDTH = 160;
|
||||
const APPROX_LINE_HEIGHT = 25;
|
||||
const INITIAL_WIDTH = 10;
|
||||
|
||||
beforeAll(() => {
|
||||
jest
|
||||
.spyOn(textElementUtils, "getApproxLineHeight")
|
||||
.mockReturnValue(APPROX_LINE_HEIGHT);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
h.elements = [];
|
||||
@ -732,39 +720,6 @@ 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;
|
||||
}
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
@ -773,11 +728,6 @@ describe("textWysiwyg", () => {
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
// mock scroll height
|
||||
jest
|
||||
.spyOn(editor, "scrollHeight", "get")
|
||||
.mockImplementation(() => APPROX_LINE_HEIGHT * 2);
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
@ -791,10 +741,12 @@ describe("textWysiwyg", () => {
|
||||
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.text).toBe("Hello \nWorld!");
|
||||
expect(text.originalText).toBe("Hello World!");
|
||||
expect(text.y).toBe(57.5);
|
||||
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
|
||||
expect(text.height).toBe(APPROX_LINE_HEIGHT * 2);
|
||||
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(text.y).toBe(
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
expect(text.x).toBe(25);
|
||||
expect(text.height).toBe(48);
|
||||
expect(text.width).toBe(60);
|
||||
|
||||
// Edit and text by removing second line and it should
|
||||
// still vertically align correctly
|
||||
@ -811,11 +763,6 @@ describe("textWysiwyg", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// mock scroll height
|
||||
jest
|
||||
.spyOn(editor, "scrollHeight", "get")
|
||||
.mockImplementation(() => APPROX_LINE_HEIGHT);
|
||||
editor.style.height = "25px";
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@ -825,10 +772,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
expect(text.text).toBe("Hello");
|
||||
expect(text.originalText).toBe("Hello");
|
||||
expect(text.y).toBe(57.5);
|
||||
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
|
||||
expect(text.height).toBe(APPROX_LINE_HEIGHT);
|
||||
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(text.height).toBe(24);
|
||||
expect(text.width).toBe(50);
|
||||
expect(text.y).toBe(
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
expect(text.x).toBe(30);
|
||||
});
|
||||
|
||||
it("should unbind bound text when unbind action from context menu is triggered", async () => {
|
||||
@ -915,8 +864,8 @@ describe("textWysiwyg", () => {
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
109.5,
|
||||
17,
|
||||
85,
|
||||
5,
|
||||
]
|
||||
`);
|
||||
|
||||
@ -942,7 +891,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
90,
|
||||
66,
|
||||
]
|
||||
`);
|
||||
|
||||
@ -965,7 +914,7 @@ describe("textWysiwyg", () => {
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
424,
|
||||
375,
|
||||
-539,
|
||||
]
|
||||
`);
|
||||
@ -1080,9 +1029,9 @@ describe("textWysiwyg", () => {
|
||||
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
|
||||
mouse.up(rectangle.x + 100, rectangle.y + 50);
|
||||
expect(rectangle.x).toBe(80);
|
||||
expect(rectangle.y).toBe(85);
|
||||
expect(text.x).toBe(89.5);
|
||||
expect(text.y).toBe(90);
|
||||
expect(rectangle.y).toBe(-35);
|
||||
expect(text.x).toBe(85);
|
||||
expect(text.y).toBe(-30);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.Z);
|
||||
@ -1112,29 +1061,6 @@ 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;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
});
|
||||
const originalRectHeight = rectangle.height;
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
|
||||
@ -1148,7 +1074,7 @@ describe("textWysiwyg", () => {
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(135);
|
||||
expect(rectangle.height).toBe(178);
|
||||
mouse.select(rectangle);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
@ -1174,7 +1100,7 @@ describe("textWysiwyg", () => {
|
||||
editor.blur();
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect(rectangle.height).toBe(215);
|
||||
expect(rectangle.height).toBe(156);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
|
||||
mouse.select(rectangle);
|
||||
@ -1186,13 +1112,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(215);
|
||||
expect(rectangle.height).toBe(156);
|
||||
// cache updated again
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
|
||||
});
|
||||
|
||||
//@todo fix this test later once measureText is mocked correctly
|
||||
it.skip("should reset the container height cache when font properties updated", async () => {
|
||||
it("should reset the container height cache when font properties updated", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
@ -1218,7 +1143,9 @@ describe("textWysiwyg", () => {
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
||||
).toEqual(36);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
|
||||
96.39999999999999,
|
||||
);
|
||||
});
|
||||
|
||||
describe("should align correctly", () => {
|
||||
@ -1256,7 +1183,7 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.click(screen.getByTitle("Align top"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
94.5,
|
||||
30,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
@ -1268,7 +1195,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
174,
|
||||
45,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
@ -1280,7 +1207,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
25,
|
||||
45.5,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1291,8 +1218,8 @@ describe("textWysiwyg", () => {
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
-25,
|
||||
25,
|
||||
30,
|
||||
45.5,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1303,8 +1230,8 @@ describe("textWysiwyg", () => {
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
174,
|
||||
25,
|
||||
45,
|
||||
45.5,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1316,7 +1243,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
25,
|
||||
66,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1326,8 +1253,8 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
94.5,
|
||||
25,
|
||||
30,
|
||||
66,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1337,8 +1264,8 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
174,
|
||||
25,
|
||||
45,
|
||||
66,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES, isFirefox, isSafari, VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@ -29,6 +29,7 @@ import {
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
measureText,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
@ -159,7 +160,7 @@ export const textWysiwyg = ({
|
||||
let maxWidth = updatedTextElement.width;
|
||||
|
||||
let maxHeight = updatedTextElement.height;
|
||||
const width = updatedTextElement.width;
|
||||
let textElementWidth = updatedTextElement.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let textElementHeight = updatedTextElement.height;
|
||||
@ -272,7 +273,10 @@ export const textWysiwyg = ({
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
}
|
||||
|
||||
// As firefox, Safari needs little higher dimensions on DOM
|
||||
if (isFirefox || isSafari) {
|
||||
textElementWidth += 0.5;
|
||||
}
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
@ -280,12 +284,12 @@ export const textWysiwyg = ({
|
||||
font: getFontString(updatedTextElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
width: `${Math.min(width, maxWidth)}px`,
|
||||
width: `${textElementWidth}px`,
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
transform: getTransform(
|
||||
width,
|
||||
textElementWidth,
|
||||
textElementHeight,
|
||||
getTextElementAngle(updatedTextElement),
|
||||
appState,
|
||||
@ -378,55 +382,16 @@ export const textWysiwyg = ({
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const font = getFontString(updatedTextElement);
|
||||
// using scrollHeight here since we need to calculate
|
||||
// number of lines so cannot use editable.style.height
|
||||
// as that gets updated below
|
||||
// Rounding here so that the lines calculated is more accurate in all browsers.
|
||||
// The scrollHeight and approxLineHeight differs in diff browsers
|
||||
// eg it gives 1.05 in firefox for handewritten small font due to which
|
||||
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
|
||||
// hence rounding here to avoid that
|
||||
const lines = Math.round(
|
||||
editable.scrollHeight / getApproxLineHeight(font),
|
||||
);
|
||||
// auto increase height only when lines > 1 so its
|
||||
// measured correctly and vertically aligns for
|
||||
// first line as well as setting height to "auto"
|
||||
// doubles the height as soon as user starts typing
|
||||
if (isBoundToContainer(element) && lines > 1) {
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element);
|
||||
|
||||
let height = "auto";
|
||||
editable.style.height = "0px";
|
||||
let heightSet = false;
|
||||
if (lines === 2) {
|
||||
const actualLineCount = wrapText(
|
||||
editable.value,
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
).split("\n").length;
|
||||
// This is browser behaviour when setting height to "auto"
|
||||
// It sets the height needed for 2 lines even if actual
|
||||
// line count is 1 as mentioned above as well
|
||||
// hence reducing the height by half if actual line count is 1
|
||||
// so single line aligns vertically when deleting
|
||||
if (actualLineCount === 1) {
|
||||
height = `${editable.scrollHeight / 2}px`;
|
||||
editable.style.height = height;
|
||||
heightSet = true;
|
||||
}
|
||||
}
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
const { width, height } = measureText(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
|
||||
if (!heightSet) {
|
||||
editable.style.height = `${editable.scrollHeight}px`;
|
||||
}
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
|
@ -130,7 +130,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
text: string;
|
||||
baseline: number;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
|
@ -36,13 +36,11 @@ import {
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
MIME_TYPES,
|
||||
SVG_NS,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementOffset,
|
||||
getContainerElement,
|
||||
} from "../element/textElement";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
@ -280,22 +278,19 @@ const drawElementOnCanvas = (
|
||||
const lineHeight = element.containerId
|
||||
? getApproxLineHeight(getFontString(element))
|
||||
: element.height / lines.length;
|
||||
let verticalOffset = element.height - element.baseline;
|
||||
if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
verticalOffset = getBoundTextElementOffset(element);
|
||||
}
|
||||
|
||||
const horizontalOffset =
|
||||
element.textAlign === "center"
|
||||
? element.width / 2
|
||||
: element.textAlign === "right"
|
||||
? element.width
|
||||
: 0;
|
||||
context.textBaseline = "bottom";
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
context.fillText(
|
||||
lines[index],
|
||||
horizontalOffset,
|
||||
(index + 1) * lineHeight - verticalOffset,
|
||||
(index + 1) * lineHeight,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
@ -1300,7 +1295,7 @@ export const renderElementToSvg = (
|
||||
);
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.height / lines.length;
|
||||
const verticalOffset = element.height - element.baseline;
|
||||
const verticalOffset = element.height;
|
||||
const horizontalOffset =
|
||||
element.textAlign === "center"
|
||||
? element.width / 2
|
||||
|
@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 1px; height: 0px; left: 39.5px; top: 20px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -20px; font: Emoji 20px 20px; line-height: 0px; font-family: Virgil, Segoe UI Emoji;"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 24px; left: 35px; top: 8px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
@ -282,7 +282,6 @@ exports[`restoreElements should restore text element correctly passing value for
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"baseline": 0,
|
||||
"boundElements": Array [],
|
||||
"containerId": null,
|
||||
"fillStyle": "hachure",
|
||||
@ -312,8 +311,8 @@ Object {
|
||||
"versionNonce": 0,
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": -0.5,
|
||||
"y": 0,
|
||||
"x": -20,
|
||||
"y": -8.4,
|
||||
}
|
||||
`;
|
||||
|
||||
@ -321,7 +320,6 @@ exports[`restoreElements should restore text element correctly with unknown font
|
||||
Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"baseline": 0,
|
||||
"boundElements": Array [],
|
||||
"containerId": null,
|
||||
"fillStyle": "hachure",
|
||||
|
@ -1031,7 +1031,7 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 10,
|
||||
"height": 128,
|
||||
"width": 367,
|
||||
}
|
||||
`);
|
||||
@ -1039,8 +1039,8 @@ describe("Test Linear Elements", () => {
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 386.5,
|
||||
"y": 70,
|
||||
"x": 272,
|
||||
"y": 46,
|
||||
}
|
||||
`);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
@ -1052,11 +1052,11 @@ describe("Test Linear Elements", () => {
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
20,
|
||||
60,
|
||||
391.8122896842806,
|
||||
70,
|
||||
36,
|
||||
502,
|
||||
94,
|
||||
205.9061448421403,
|
||||
65,
|
||||
53,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1090,7 +1090,7 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 0,
|
||||
"height": 128,
|
||||
"width": 340,
|
||||
}
|
||||
`);
|
||||
@ -1098,8 +1098,8 @@ describe("Test Linear Elements", () => {
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 189.5,
|
||||
"y": 20,
|
||||
"x": 75,
|
||||
"y": -4,
|
||||
}
|
||||
`);
|
||||
expect(textElement.text).toMatchInlineSnapshot(`
|
||||
|
Loading…
x
Reference in New Issue
Block a user