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) => {
|
selectedElements.forEach((element) => {
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const { width, height, baseline } = measureText(
|
const { width, height } = measureText(
|
||||||
boundTextElement.originalText,
|
boundTextElement.originalText,
|
||||||
getFontString(boundTextElement),
|
getFontString(boundTextElement),
|
||||||
);
|
);
|
||||||
@ -51,7 +51,6 @@ export const actionUnbindText = register({
|
|||||||
containerId: null,
|
containerId: null,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
baseline,
|
|
||||||
text: boundTextElement.originalText,
|
text: boundTextElement.originalText,
|
||||||
});
|
});
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
|
@ -2674,14 +2674,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
element,
|
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({
|
this.setState({
|
||||||
|
@ -9,6 +9,9 @@ export const isFirefox =
|
|||||||
"netscape" in window &&
|
"netscape" in window &&
|
||||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||||
navigator.userAgent.indexOf("Gecko") > 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";
|
export const APP_NAME = "Excalidraw";
|
||||||
|
|
||||||
|
@ -171,7 +171,6 @@ const restoreElement = (
|
|||||||
fontSize,
|
fontSize,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
text: element.text ?? "",
|
text: element.text ?? "",
|
||||||
baseline: element.baseline,
|
|
||||||
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||||
containerId: element.containerId ?? null,
|
containerId: element.containerId ?? null,
|
||||||
|
@ -153,7 +153,6 @@ 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,
|
||||||
},
|
},
|
||||||
@ -170,18 +169,13 @@ const getAdjustedDimensions = (
|
|||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
baseline: number;
|
|
||||||
} => {
|
} => {
|
||||||
let maxWidth = null;
|
|
||||||
const container = getContainerElement(element);
|
const container = getContainerElement(element);
|
||||||
if (container) {
|
|
||||||
maxWidth = getMaxContainerWidth(container);
|
const { width: nextWidth, height: nextHeight } = measureText(
|
||||||
}
|
nextText,
|
||||||
const {
|
getFontString(element),
|
||||||
width: nextWidth,
|
);
|
||||||
height: nextHeight,
|
|
||||||
baseline: nextBaseline,
|
|
||||||
} = measureText(nextText, getFontString(element), maxWidth);
|
|
||||||
const { textAlign, verticalAlign } = element;
|
const { textAlign, verticalAlign } = element;
|
||||||
let x: number;
|
let x: number;
|
||||||
let y: number;
|
let y: number;
|
||||||
@ -190,11 +184,7 @@ const getAdjustedDimensions = (
|
|||||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||||
!element.containerId
|
!element.containerId
|
||||||
) {
|
) {
|
||||||
const prevMetrics = measureText(
|
const prevMetrics = measureText(element.text, getFontString(element));
|
||||||
element.text,
|
|
||||||
getFontString(element),
|
|
||||||
maxWidth,
|
|
||||||
);
|
|
||||||
const offsets = getTextElementPositionOffsets(element, {
|
const offsets = getTextElementPositionOffsets(element, {
|
||||||
width: nextWidth - prevMetrics.width,
|
width: nextWidth - prevMetrics.width,
|
||||||
height: nextHeight - prevMetrics.height,
|
height: nextHeight - prevMetrics.height,
|
||||||
@ -258,7 +248,6 @@ const getAdjustedDimensions = (
|
|||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
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,
|
||||||
baseline: nextBaseline,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -45,8 +45,6 @@ import {
|
|||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
measureText,
|
|
||||||
getMaxContainerHeight,
|
|
||||||
getMaxContainerWidth,
|
getMaxContainerWidth,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
|
|
||||||
@ -192,11 +190,10 @@ const rescalePointsInElement = (
|
|||||||
|
|
||||||
const MIN_FONT_SIZE = 1;
|
const MIN_FONT_SIZE = 1;
|
||||||
|
|
||||||
const measureFontSizeFromWH = (
|
const measureFontSizeFromWidth = (
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
nextWidth: number,
|
nextWidth: number,
|
||||||
nextHeight: number,
|
): number | null => {
|
||||||
): { 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;
|
||||||
|
|
||||||
@ -211,15 +208,8 @@ const measureFontSizeFromWH = (
|
|||||||
if (nextFontSize < MIN_FONT_SIZE) {
|
if (nextFontSize < MIN_FONT_SIZE) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const metrics = measureText(
|
|
||||||
element.text,
|
return nextFontSize;
|
||||||
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
|
||||||
element.containerId ? width : null,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
size: nextFontSize,
|
|
||||||
baseline: metrics.baseline + (nextHeight - metrics.height),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSidesForTransformHandle = (
|
const getSidesForTransformHandle = (
|
||||||
@ -290,8 +280,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 nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight);
|
const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
|
||||||
if (nextFont === null) {
|
if (nextFontSize === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
||||||
@ -315,10 +305,9 @@ const resizeSingleTextElement = (
|
|||||||
deltaY2,
|
deltaY2,
|
||||||
);
|
);
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
fontSize: nextFont.size,
|
fontSize: nextFontSize,
|
||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
baseline: nextFont.baseline,
|
|
||||||
x: nextElementX,
|
x: nextElementX,
|
||||||
y: nextElementY,
|
y: nextElementY,
|
||||||
});
|
});
|
||||||
@ -371,7 +360,7 @@ export const resizeSingleElement = (
|
|||||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||||
|
|
||||||
let boundTextFont: { fontSize?: number; baseline?: number } = {};
|
let boundTextFont: { fontSize?: number } = {};
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
|
||||||
if (transformHandleDirection.includes("e")) {
|
if (transformHandleDirection.includes("e")) {
|
||||||
@ -423,7 +412,6 @@ export const resizeSingleElement = (
|
|||||||
if (stateOfBoundTextElementAtResize) {
|
if (stateOfBoundTextElementAtResize) {
|
||||||
boundTextFont = {
|
boundTextFont = {
|
||||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||||
baseline: stateOfBoundTextElementAtResize.baseline,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (shouldMaintainAspectRatio) {
|
if (shouldMaintainAspectRatio) {
|
||||||
@ -433,17 +421,15 @@ export const resizeSingleElement = (
|
|||||||
height: eleNewHeight,
|
height: eleNewHeight,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextFont = measureFontSizeFromWH(
|
const nextFontSize = measureFontSizeFromWidth(
|
||||||
boundTextElement,
|
boundTextElement,
|
||||||
getMaxContainerWidth(updatedElement),
|
getMaxContainerWidth(updatedElement),
|
||||||
getMaxContainerHeight(updatedElement),
|
|
||||||
);
|
);
|
||||||
if (nextFont === null) {
|
if (nextFontSize === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boundTextFont = {
|
boundTextFont = {
|
||||||
fontSize: nextFont.size,
|
fontSize: nextFontSize,
|
||||||
baseline: nextFont.baseline,
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||||
@ -687,7 +673,6 @@ const resizeMultipleElements = (
|
|||||||
y: number;
|
y: number;
|
||||||
points?: Point[];
|
points?: Point[];
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
baseline?: number;
|
|
||||||
} = {
|
} = {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@ -696,7 +681,7 @@ const resizeMultipleElements = (
|
|||||||
...rescaledPoints,
|
...rescaledPoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
let boundTextUpdates: { fontSize: number } | null = null;
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element.latest);
|
const boundTextElement = getBoundTextElement(element.latest);
|
||||||
|
|
||||||
@ -706,25 +691,22 @@ const resizeMultipleElements = (
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
};
|
};
|
||||||
const textMeasurements = measureFontSizeFromWH(
|
const fontSize = measureFontSizeFromWidth(
|
||||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||||
getMaxContainerWidth(updatedElement),
|
getMaxContainerWidth(updatedElement),
|
||||||
getMaxContainerHeight(updatedElement),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!textMeasurements) {
|
if (!fontSize) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTextElement(element.orig)) {
|
if (isTextElement(element.orig)) {
|
||||||
update.fontSize = textMeasurements.size;
|
update.fontSize = fontSize;
|
||||||
update.baseline = textMeasurements.baseline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
boundTextUpdates = {
|
boundTextUpdates = {
|
||||||
fontSize: textMeasurements.size,
|
fontSize,
|
||||||
baseline: textMeasurements.baseline,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,6 @@ import {
|
|||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getMaxContainerWidth,
|
getMaxContainerWidth,
|
||||||
getMaxContainerHeight,
|
getMaxContainerHeight,
|
||||||
measureText,
|
|
||||||
wrapText,
|
wrapText,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { FontString } from "./types";
|
import { FontString } from "./types";
|
||||||
@ -73,6 +72,13 @@ up`,
|
|||||||
width: 250,
|
width: 250,
|
||||||
res: "Hello whats up",
|
res: "Hello whats up",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
desc: "should push the word if its equal to max width",
|
||||||
|
width: 60,
|
||||||
|
res: `Hello
|
||||||
|
whats
|
||||||
|
up`,
|
||||||
|
},
|
||||||
].forEach((data) => {
|
].forEach((data) => {
|
||||||
it(`should ${data.desc}`, () => {
|
it(`should ${data.desc}`, () => {
|
||||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||||
@ -80,6 +86,7 @@ up`,
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("When text contain new lines", () => {
|
describe("When text contain new lines", () => {
|
||||||
const text = `Hello
|
const text = `Hello
|
||||||
whats up`;
|
whats up`;
|
||||||
@ -170,38 +177,6 @@ break it now`,
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Test measureText", () => {
|
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", () => {
|
describe("Test getContainerCoords", () => {
|
||||||
const params = { width: 200, height: 100, x: 10, y: 20 };
|
const params = { width: 200, height: 100, x: 10, y: 20 };
|
||||||
|
|
||||||
|
@ -50,7 +50,6 @@ 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;
|
||||||
@ -66,12 +65,10 @@ export const redrawTextBoundingBox = (
|
|||||||
const metrics = measureText(
|
const metrics = measureText(
|
||||||
boundTextUpdates.text,
|
boundTextUpdates.text,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
maxWidth,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
boundTextUpdates.width = metrics.width;
|
boundTextUpdates.width = metrics.width;
|
||||||
boundTextUpdates.height = metrics.height;
|
boundTextUpdates.height = metrics.height;
|
||||||
boundTextUpdates.baseline = metrics.baseline;
|
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
@ -177,7 +174,6 @@ 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 = wrapText(
|
text = wrapText(
|
||||||
@ -186,14 +182,9 @@ export const handleBindTextResize = (
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const dimensions = measureText(
|
const dimensions = measureText(text, getFontString(textElement));
|
||||||
text,
|
|
||||||
getFontString(textElement),
|
|
||||||
maxWidth,
|
|
||||||
);
|
|
||||||
nextHeight = dimensions.height;
|
nextHeight = dimensions.height;
|
||||||
nextWidth = dimensions.width;
|
nextWidth = dimensions.width;
|
||||||
nextBaseLine = dimensions.baseline;
|
|
||||||
}
|
}
|
||||||
// increase height in case text element height exceeds
|
// increase height in case text element height exceeds
|
||||||
if (nextHeight > maxHeight) {
|
if (nextHeight > maxHeight) {
|
||||||
@ -221,7 +212,6 @@ export const handleBindTextResize = (
|
|||||||
text,
|
text,
|
||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
baseline: nextBaseLine,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isArrowElement(container)) {
|
if (!isArrowElement(container)) {
|
||||||
@ -267,51 +257,19 @@ const computeBoundTextPosition = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||||
export const measureText = (
|
|
||||||
text: string,
|
export const measureText = (text: string, font: FontString) => {
|
||||||
font: FontString,
|
|
||||||
maxWidth?: number | null,
|
|
||||||
) => {
|
|
||||||
text = text
|
text = text
|
||||||
.split("\n")
|
.split("\n")
|
||||||
// replace empty lines with single space because leading/trailing empty
|
// replace empty lines with single space because leading/trailing empty
|
||||||
// lines would be stripped from computation
|
// lines would be stripped from computation
|
||||||
.map((x) => x || " ")
|
.map((x) => x || " ")
|
||||||
.join("\n");
|
.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 height = getTextHeight(text, font);
|
||||||
const lineHeight = getApproxLineHeight(font);
|
const width = getTextWidth(text, 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 span = document.createElement("span");
|
return { width, height };
|
||||||
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 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||||
@ -321,40 +279,45 @@ export const getApproxLineHeight = (font: FontString) => {
|
|||||||
if (cacheApproxLineHeight[font]) {
|
if (cacheApproxLineHeight[font]) {
|
||||||
return 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];
|
return cacheApproxLineHeight[font];
|
||||||
};
|
};
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | undefined;
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
|
|
||||||
const getLineWidth = (text: string, font: FontString) => {
|
const getLineWidth = (text: string, font: FontString) => {
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
canvas = document.createElement("canvas");
|
canvas = document.createElement("canvas");
|
||||||
}
|
}
|
||||||
const canvas2dContext = canvas.getContext("2d")!;
|
const canvas2dContext = canvas.getContext("2d")!;
|
||||||
canvas2dContext.font = font;
|
canvas2dContext.font = font;
|
||||||
|
const width = canvas2dContext.measureText(text).width;
|
||||||
|
|
||||||
const metrics = canvas2dContext.measureText(text);
|
|
||||||
// since in test env the canvas measureText algo
|
// since in test env the canvas measureText algo
|
||||||
// doesn't measure text and instead just returns number of
|
// doesn't measure text and instead just returns number of
|
||||||
// characters hence we assume that each letteris 10px
|
// characters hence we assume that each letteris 10px
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
return metrics.width * 10;
|
return width * 10;
|
||||||
}
|
}
|
||||||
// Since measureText behaves differently in different browsers
|
return width;
|
||||||
// OS so considering a adjustment factor of 0.2
|
|
||||||
const adjustmentFactor = 0.2;
|
|
||||||
|
|
||||||
return metrics.width + adjustmentFactor;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTextWidth = (text: string, font: FontString) => {
|
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;
|
let width = 0;
|
||||||
lines.forEach((line) => {
|
lines.forEach((line) => {
|
||||||
width = Math.max(width, getLineWidth(line, font));
|
width = Math.max(width, getLineWidth(line, font));
|
||||||
});
|
});
|
||||||
return width;
|
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) => {
|
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||||
const lines: Array<string> = [];
|
const lines: Array<string> = [];
|
||||||
const originalLines = text.split("\n");
|
const originalLines = text.split("\n");
|
||||||
@ -376,16 +339,23 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||||||
let currentLineWidthTillNow = 0;
|
let currentLineWidthTillNow = 0;
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
while (index < words.length) {
|
while (index < words.length) {
|
||||||
const currentWordWidth = getLineWidth(words[index], font);
|
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
|
// 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
|
// push current line since the current word exceeds the max width
|
||||||
// so will be appended in next line
|
// so will be appended in next line
|
||||||
push(currentLine);
|
push(currentLine);
|
||||||
currentLine = "";
|
currentLine = "";
|
||||||
currentLineWidthTillNow = 0;
|
currentLineWidthTillNow = 0;
|
||||||
|
|
||||||
while (words[index].length > 0) {
|
while (words[index].length > 0) {
|
||||||
const currentChar = String.fromCodePoint(
|
const currentChar = String.fromCodePoint(
|
||||||
words[index].codePointAt(0)!,
|
words[index].codePointAt(0)!,
|
||||||
@ -486,9 +456,9 @@ export const charWidth = (() => {
|
|||||||
getCache,
|
getCache,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
export const getApproxMinLineWidth = (font: FontString) => {
|
export const getApproxMinLineWidth = (font: FontString) => {
|
||||||
const maxCharWidth = getMaxCharWidth(font);
|
const maxCharWidth = getMaxCharWidth(font);
|
||||||
|
|
||||||
if (maxCharWidth === 0) {
|
if (maxCharWidth === 0) {
|
||||||
return (
|
return (
|
||||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||||
|
@ -6,12 +6,11 @@ import { CODES, KEYS } from "../keys";
|
|||||||
import { fireEvent } from "../tests/test-utils";
|
import { fireEvent } from "../tests/test-utils";
|
||||||
import { queryByText } from "@testing-library/react";
|
import { queryByText } from "@testing-library/react";
|
||||||
|
|
||||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
import { FONT_FAMILY } from "../constants";
|
||||||
import {
|
import {
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import * as textElementUtils from "./textElement";
|
|
||||||
import { API } from "../tests/helpers/api";
|
import { API } from "../tests/helpers/api";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { resize } from "../tests/utils";
|
import { resize } from "../tests/utils";
|
||||||
@ -440,17 +439,6 @@ describe("textWysiwyg", () => {
|
|||||||
let rectangle: any;
|
let rectangle: any;
|
||||||
const { h } = window;
|
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 () => {
|
beforeEach(async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
h.elements = [];
|
h.elements = [];
|
||||||
@ -732,39 +720,6 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should wrap text and vertcially center align once text submitted", async () => {
|
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);
|
expect(h.elements.length).toBe(1);
|
||||||
|
|
||||||
Keyboard.keyDown(KEYS.ENTER);
|
Keyboard.keyDown(KEYS.ENTER);
|
||||||
@ -773,11 +728,6 @@ describe("textWysiwyg", () => {
|
|||||||
".excalidraw-textEditorContainer > textarea",
|
".excalidraw-textEditorContainer > textarea",
|
||||||
) as HTMLTextAreaElement;
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
// mock scroll height
|
|
||||||
jest
|
|
||||||
.spyOn(editor, "scrollHeight", "get")
|
|
||||||
.mockImplementation(() => APPROX_LINE_HEIGHT * 2);
|
|
||||||
|
|
||||||
fireEvent.change(editor, {
|
fireEvent.change(editor, {
|
||||||
target: {
|
target: {
|
||||||
value: "Hello World!",
|
value: "Hello World!",
|
||||||
@ -791,10 +741,12 @@ describe("textWysiwyg", () => {
|
|||||||
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.text).toBe("Hello \nWorld!");
|
expect(text.text).toBe("Hello \nWorld!");
|
||||||
expect(text.originalText).toBe("Hello World!");
|
expect(text.originalText).toBe("Hello World!");
|
||||||
expect(text.y).toBe(57.5);
|
expect(text.y).toBe(
|
||||||
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
|
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||||
expect(text.height).toBe(APPROX_LINE_HEIGHT * 2);
|
);
|
||||||
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 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
|
// Edit and text by removing second line and it should
|
||||||
// still vertically align correctly
|
// 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"));
|
editor.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
@ -825,10 +772,12 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
expect(text.text).toBe("Hello");
|
expect(text.text).toBe("Hello");
|
||||||
expect(text.originalText).toBe("Hello");
|
expect(text.originalText).toBe("Hello");
|
||||||
expect(text.y).toBe(57.5);
|
expect(text.height).toBe(24);
|
||||||
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
|
expect(text.width).toBe(50);
|
||||||
expect(text.height).toBe(APPROX_LINE_HEIGHT);
|
expect(text.y).toBe(
|
||||||
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
|
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 () => {
|
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]);
|
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
109.5,
|
85,
|
||||||
17,
|
5,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -942,7 +891,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
15,
|
15,
|
||||||
90,
|
66,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -965,7 +914,7 @@ describe("textWysiwyg", () => {
|
|||||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
424,
|
375,
|
||||||
-539,
|
-539,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -1080,9 +1029,9 @@ describe("textWysiwyg", () => {
|
|||||||
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
|
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
|
||||||
mouse.up(rectangle.x + 100, rectangle.y + 50);
|
mouse.up(rectangle.x + 100, rectangle.y + 50);
|
||||||
expect(rectangle.x).toBe(80);
|
expect(rectangle.x).toBe(80);
|
||||||
expect(rectangle.y).toBe(85);
|
expect(rectangle.y).toBe(-35);
|
||||||
expect(text.x).toBe(89.5);
|
expect(text.x).toBe(85);
|
||||||
expect(text.y).toBe(90);
|
expect(text.y).toBe(-30);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress(KEYS.Z);
|
Keyboard.keyPress(KEYS.Z);
|
||||||
@ -1112,29 +1061,6 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
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;
|
const originalRectHeight = rectangle.height;
|
||||||
expect(rectangle.height).toBe(originalRectHeight);
|
expect(rectangle.height).toBe(originalRectHeight);
|
||||||
|
|
||||||
@ -1148,7 +1074,7 @@ describe("textWysiwyg", () => {
|
|||||||
target: { value: "Online whiteboard collaboration made easy" },
|
target: { value: "Online whiteboard collaboration made easy" },
|
||||||
});
|
});
|
||||||
editor.blur();
|
editor.blur();
|
||||||
expect(rectangle.height).toBe(135);
|
expect(rectangle.height).toBe(178);
|
||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
@ -1174,7 +1100,7 @@ describe("textWysiwyg", () => {
|
|||||||
editor.blur();
|
editor.blur();
|
||||||
|
|
||||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
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);
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||||
|
|
||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
@ -1186,13 +1112,12 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
editor.blur();
|
editor.blur();
|
||||||
expect(rectangle.height).toBe(215);
|
expect(rectangle.height).toBe(156);
|
||||||
// cache updated again
|
// 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("should reset the container height cache when font properties updated", async () => {
|
||||||
it.skip("should reset the container height cache when font properties updated", async () => {
|
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||||
|
|
||||||
@ -1218,7 +1143,9 @@ describe("textWysiwyg", () => {
|
|||||||
expect(
|
expect(
|
||||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
||||||
).toEqual(36);
|
).toEqual(36);
|
||||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
|
||||||
|
96.39999999999999,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should align correctly", () => {
|
describe("should align correctly", () => {
|
||||||
@ -1256,7 +1183,7 @@ describe("textWysiwyg", () => {
|
|||||||
fireEvent.click(screen.getByTitle("Align top"));
|
fireEvent.click(screen.getByTitle("Align top"));
|
||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
94.5,
|
30,
|
||||||
25,
|
25,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -1268,7 +1195,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
174,
|
45,
|
||||||
25,
|
25,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -1280,7 +1207,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
15,
|
15,
|
||||||
25,
|
45.5,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@ -1291,8 +1218,8 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
-25,
|
30,
|
||||||
25,
|
45.5,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@ -1303,8 +1230,8 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
174,
|
45,
|
||||||
25,
|
45.5,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@ -1316,7 +1243,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
15,
|
15,
|
||||||
25,
|
66,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@ -1326,8 +1253,8 @@ describe("textWysiwyg", () => {
|
|||||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
94.5,
|
30,
|
||||||
25,
|
66,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@ -1337,8 +1264,8 @@ describe("textWysiwyg", () => {
|
|||||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
174,
|
45,
|
||||||
25,
|
66,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
@ -11,7 +11,7 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { CLASSES, VERTICAL_ALIGN } from "../constants";
|
import { CLASSES, isFirefox, isSafari, VERTICAL_ALIGN } from "../constants";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@ -29,6 +29,7 @@ import {
|
|||||||
getContainerElement,
|
getContainerElement,
|
||||||
getTextElementAngle,
|
getTextElementAngle,
|
||||||
getTextWidth,
|
getTextWidth,
|
||||||
|
measureText,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
wrapText,
|
wrapText,
|
||||||
@ -159,7 +160,7 @@ export const textWysiwyg = ({
|
|||||||
let maxWidth = updatedTextElement.width;
|
let maxWidth = updatedTextElement.width;
|
||||||
|
|
||||||
let maxHeight = updatedTextElement.height;
|
let maxHeight = updatedTextElement.height;
|
||||||
const width = updatedTextElement.width;
|
let textElementWidth = updatedTextElement.width;
|
||||||
// Set to element height by default since that's
|
// Set to element height by default since that's
|
||||||
// what is going to be used for unbounded text
|
// what is going to be used for unbounded text
|
||||||
let textElementHeight = updatedTextElement.height;
|
let textElementHeight = updatedTextElement.height;
|
||||||
@ -272,7 +273,10 @@ export const textWysiwyg = ({
|
|||||||
if (!container) {
|
if (!container) {
|
||||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
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
|
// 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;
|
||||||
@ -280,12 +284,12 @@ export const textWysiwyg = ({
|
|||||||
font: getFontString(updatedTextElement),
|
font: getFontString(updatedTextElement),
|
||||||
// must be defined *after* font ¯\_(ツ)_/¯
|
// must be defined *after* font ¯\_(ツ)_/¯
|
||||||
lineHeight: `${lineHeight}px`,
|
lineHeight: `${lineHeight}px`,
|
||||||
width: `${Math.min(width, maxWidth)}px`,
|
width: `${textElementWidth}px`,
|
||||||
height: `${textElementHeight}px`,
|
height: `${textElementHeight}px`,
|
||||||
left: `${viewportX}px`,
|
left: `${viewportX}px`,
|
||||||
top: `${viewportY}px`,
|
top: `${viewportY}px`,
|
||||||
transform: getTransform(
|
transform: getTransform(
|
||||||
width,
|
textElementWidth,
|
||||||
textElementHeight,
|
textElementHeight,
|
||||||
getTextElementAngle(updatedTextElement),
|
getTextElementAngle(updatedTextElement),
|
||||||
appState,
|
appState,
|
||||||
@ -378,55 +382,16 @@ export const textWysiwyg = ({
|
|||||||
id,
|
id,
|
||||||
) as ExcalidrawTextElement;
|
) as ExcalidrawTextElement;
|
||||||
const font = getFontString(updatedTextElement);
|
const font = getFontString(updatedTextElement);
|
||||||
// using scrollHeight here since we need to calculate
|
if (isBoundToContainer(element)) {
|
||||||
// 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) {
|
|
||||||
const container = getContainerElement(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(
|
const wrappedText = wrapText(
|
||||||
normalizeText(editable.value),
|
normalizeText(editable.value),
|
||||||
font,
|
font,
|
||||||
getMaxContainerWidth(container!),
|
getMaxContainerWidth(container!),
|
||||||
);
|
);
|
||||||
const width = getTextWidth(wrappedText, font);
|
const { width, height } = measureText(wrappedText, font);
|
||||||
editable.style.width = `${width}px`;
|
editable.style.width = `${width}px`;
|
||||||
|
editable.style.height = `${height}px`;
|
||||||
if (!heightSet) {
|
|
||||||
editable.style.height = `${editable.scrollHeight}px`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onChange(normalizeText(editable.value));
|
onChange(normalizeText(editable.value));
|
||||||
};
|
};
|
||||||
|
@ -130,7 +130,6 @@ 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;
|
||||||
|
@ -36,13 +36,11 @@ import {
|
|||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
VERTICAL_ALIGN,
|
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||||
import {
|
import {
|
||||||
getApproxLineHeight,
|
getApproxLineHeight,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getBoundTextElementOffset,
|
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
@ -280,22 +278,19 @@ const drawElementOnCanvas = (
|
|||||||
const lineHeight = element.containerId
|
const lineHeight = element.containerId
|
||||||
? getApproxLineHeight(getFontString(element))
|
? getApproxLineHeight(getFontString(element))
|
||||||
: element.height / lines.length;
|
: element.height / lines.length;
|
||||||
let verticalOffset = element.height - element.baseline;
|
|
||||||
if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
|
||||||
verticalOffset = getBoundTextElementOffset(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
const horizontalOffset =
|
const horizontalOffset =
|
||||||
element.textAlign === "center"
|
element.textAlign === "center"
|
||||||
? element.width / 2
|
? element.width / 2
|
||||||
: element.textAlign === "right"
|
: element.textAlign === "right"
|
||||||
? element.width
|
? element.width
|
||||||
: 0;
|
: 0;
|
||||||
|
context.textBaseline = "bottom";
|
||||||
|
|
||||||
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) * lineHeight - verticalOffset,
|
(index + 1) * lineHeight,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
context.restore();
|
context.restore();
|
||||||
@ -1300,7 +1295,7 @@ export const renderElementToSvg = (
|
|||||||
);
|
);
|
||||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||||
const lineHeight = element.height / lines.length;
|
const lineHeight = element.height / lines.length;
|
||||||
const verticalOffset = element.height - element.baseline;
|
const verticalOffset = element.height;
|
||||||
const horizontalOffset =
|
const horizontalOffset =
|
||||||
element.textAlign === "center"
|
element.textAlign === "center"
|
||||||
? element.width / 2
|
? element.width / 2
|
||||||
|
@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
|
|||||||
class="excalidraw-wysiwyg"
|
class="excalidraw-wysiwyg"
|
||||||
data-type="wysiwyg"
|
data-type="wysiwyg"
|
||||||
dir="auto"
|
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"
|
tabindex="0"
|
||||||
wrap="off"
|
wrap="off"
|
||||||
/>
|
/>
|
||||||
|
@ -282,7 +282,6 @@ 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",
|
||||||
@ -312,8 +311,8 @@ Object {
|
|||||||
"versionNonce": 0,
|
"versionNonce": 0,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": -0.5,
|
"x": -20,
|
||||||
"y": 0,
|
"y": -8.4,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -321,7 +320,6 @@ 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",
|
||||||
|
@ -1031,7 +1031,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect({ width: container.width, height: container.height })
|
expect({ width: container.width, height: container.height })
|
||||||
.toMatchInlineSnapshot(`
|
.toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"height": 10,
|
"height": 128,
|
||||||
"width": 367,
|
"width": 367,
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
@ -1039,8 +1039,8 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(getBoundTextElementPosition(container, textElement))
|
expect(getBoundTextElementPosition(container, textElement))
|
||||||
.toMatchInlineSnapshot(`
|
.toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"x": 386.5,
|
"x": 272,
|
||||||
"y": 70,
|
"y": 46,
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||||
@ -1052,11 +1052,11 @@ describe("Test Linear Elements", () => {
|
|||||||
.toMatchInlineSnapshot(`
|
.toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
20,
|
20,
|
||||||
60,
|
36,
|
||||||
391.8122896842806,
|
502,
|
||||||
70,
|
94,
|
||||||
205.9061448421403,
|
205.9061448421403,
|
||||||
65,
|
53,
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@ -1090,7 +1090,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect({ width: container.width, height: container.height })
|
expect({ width: container.width, height: container.height })
|
||||||
.toMatchInlineSnapshot(`
|
.toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"height": 0,
|
"height": 128,
|
||||||
"width": 340,
|
"width": 340,
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
@ -1098,8 +1098,8 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(getBoundTextElementPosition(container, textElement))
|
expect(getBoundTextElementPosition(container, textElement))
|
||||||
.toMatchInlineSnapshot(`
|
.toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"x": 189.5,
|
"x": 75,
|
||||||
"y": 20,
|
"y": -4,
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
expect(textElement.text).toMatchInlineSnapshot(`
|
expect(textElement.text).toMatchInlineSnapshot(`
|
||||||
|
Loading…
x
Reference in New Issue
Block a user