mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax
This commit is contained in:
commit
4c939cefad
85 changed files with 902 additions and 820 deletions
|
@ -22,15 +22,15 @@ import { getElementAbsoluteCoords } from ".";
|
|||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureTextElement,
|
||||
normalizeText,
|
||||
wrapTextElement,
|
||||
getMaxContainerWidth,
|
||||
} from "./textElement";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { getSubtypeMethods, isValidSubtype } from "../subtypes";
|
||||
|
||||
|
@ -189,7 +189,6 @@ export const newTextElement = (
|
|||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
},
|
||||
|
@ -206,18 +205,12 @@ 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,
|
||||
} = measureTextElement(element, { text: nextText }, maxWidth);
|
||||
|
||||
const { width: nextWidth, height: nextHeight } = measureTextElement(element, {
|
||||
text: nextText,
|
||||
});
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
let y: number;
|
||||
|
@ -226,11 +219,9 @@ const getAdjustedDimensions = (
|
|||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId
|
||||
) {
|
||||
const prevMetrics = measureTextElement(
|
||||
element,
|
||||
{ fontSize: element.fontSize },
|
||||
maxWidth,
|
||||
);
|
||||
const prevMetrics = measureTextElement(element, {
|
||||
fontSize: element.fontSize,
|
||||
});
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
height: nextHeight - prevMetrics.height,
|
||||
|
@ -294,7 +285,6 @@ const getAdjustedDimensions = (
|
|||
height: nextHeight,
|
||||
x: Number.isFinite(x) ? x : element.x,
|
||||
y: Number.isFinite(y) ? y : element.y,
|
||||
baseline: nextBaseline,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -312,38 +302,6 @@ export const refreshTextDimensions = (
|
|||
return { text, ...dimensions };
|
||||
};
|
||||
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerWidth <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.width;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return containerWidth;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.height;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const updateTextElement = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
{
|
||||
|
|
|
@ -43,12 +43,10 @@ import {
|
|||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
getBoundTextElementOffset,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
measureTextElement,
|
||||
getMaxContainerWidth,
|
||||
} from "./textElement";
|
||||
import { getMaxContainerWidth } from "./newElement";
|
||||
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
if (angle >= 2 * Math.PI) {
|
||||
|
@ -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 = measureTextElement(
|
||||
element,
|
||||
{ fontSize: nextFontSize },
|
||||
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,23 +412,24 @@ export const resizeSingleElement = (
|
|||
if (stateOfBoundTextElementAtResize) {
|
||||
boundTextFont = {
|
||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||
baseline: stateOfBoundTextElementAtResize.baseline,
|
||||
};
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
const boundTextElementPadding =
|
||||
getBoundTextElementOffset(boundTextElement);
|
||||
const nextFont = measureFontSizeFromWH(
|
||||
const updatedElement = {
|
||||
...element,
|
||||
width: eleNewWidth,
|
||||
height: eleNewHeight,
|
||||
};
|
||||
|
||||
const nextFontSize = measureFontSizeFromWidth(
|
||||
boundTextElement,
|
||||
eleNewWidth - boundTextElementPadding * 2,
|
||||
eleNewHeight - boundTextElementPadding * 2,
|
||||
getMaxContainerWidth(updatedElement),
|
||||
);
|
||||
if (nextFont === null) {
|
||||
if (nextFontSize === null) {
|
||||
return;
|
||||
}
|
||||
boundTextFont = {
|
||||
fontSize: nextFont.size,
|
||||
baseline: nextFont.baseline,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
|
@ -683,7 +673,6 @@ const resizeMultipleElements = (
|
|||
y: number;
|
||||
points?: Point[];
|
||||
fontSize?: number;
|
||||
baseline?: number;
|
||||
} = {
|
||||
width,
|
||||
height,
|
||||
|
@ -692,31 +681,32 @@ const resizeMultipleElements = (
|
|||
...rescaledPoints,
|
||||
};
|
||||
|
||||
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
||||
let boundTextUpdates: { fontSize: number } | null = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(element.latest);
|
||||
|
||||
if (boundTextElement || isTextElement(element.orig)) {
|
||||
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
|
||||
const textMeasurements = measureFontSizeFromWH(
|
||||
const updatedElement = {
|
||||
...element.latest,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const fontSize = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
width - optionalPadding,
|
||||
height - optionalPadding,
|
||||
getMaxContainerWidth(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { measureText, wrapText } from "./textElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
computeContainerHeightForBoundText,
|
||||
getContainerCoords,
|
||||
getMaxContainerWidth,
|
||||
getMaxContainerHeight,
|
||||
wrapText,
|
||||
} from "./textElement";
|
||||
import { FontString } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
|
@ -65,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);
|
||||
|
@ -72,6 +86,7 @@ up`,
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello
|
||||
whats up`;
|
||||
|
@ -162,35 +177,115 @@ break it now`,
|
|||
});
|
||||
|
||||
describe("Test measureText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
const text = "Hello World";
|
||||
describe("Test getContainerCoords", () => {
|
||||
const params = { width: 200, height: 100, x: 10, y: 20 };
|
||||
|
||||
it("should add correct attributes when maxWidth is passed", () => {
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = measureText(text, font, maxWidth);
|
||||
it("should compute coords correctly when ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 44.2893218813452455,
|
||||
y: 39.64466094067262,
|
||||
});
|
||||
});
|
||||
|
||||
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 compute coords correctly when rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 15,
|
||||
y: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 65,
|
||||
y: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should add correct attributes when maxWidth is not passed", () => {
|
||||
const res = measureText(text, font);
|
||||
describe("Test computeContainerHeightForBoundText", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
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>
|
||||
`);
|
||||
it("should compute container height correctly for rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerHeightForBoundText(element, 150)).toEqual(160);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerHeightForBoundText(element, 150)).toEqual(226);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerHeightForBoundText(element, 150)).toEqual(320);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getMaxContainerWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(79);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getMaxContainerHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(87);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,7 +13,6 @@ import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
|
|||
import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isTextElement } from ".";
|
||||
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isImageElement,
|
||||
|
@ -30,16 +29,16 @@ import {
|
|||
updateOriginalContainerCache,
|
||||
} from "./textWysiwyg";
|
||||
|
||||
export const measureTextElement = function (element, next, maxWidth) {
|
||||
export const measureTextElement = function (element, next) {
|
||||
const map = getSubtypeMethods(element.subtype);
|
||||
if (map?.measureText) {
|
||||
return map.measureText(element, next, maxWidth);
|
||||
return map.measureText(element, next);
|
||||
}
|
||||
|
||||
const fontSize = next?.fontSize ?? element.fontSize;
|
||||
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
|
||||
const text = next?.text ?? element.text;
|
||||
return measureText(text, font, maxWidth);
|
||||
return measureText(text, font);
|
||||
} as SubtypeMethods["measureText"];
|
||||
|
||||
export const wrapTextElement = function (element, containerWidth, next) {
|
||||
|
@ -69,78 +68,69 @@ export const redrawTextBoundingBox = (
|
|||
container: ExcalidrawElement | null,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
let text = textElement.text;
|
||||
|
||||
const boundTextUpdates = {
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
text: textElement.text,
|
||||
width: textElement.width,
|
||||
height: textElement.height,
|
||||
};
|
||||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
text = wrapTextElement(textElement, maxWidth);
|
||||
boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
|
||||
}
|
||||
const width = measureTextElement(
|
||||
textElement,
|
||||
{ text: textElement.originalText },
|
||||
maxWidth,
|
||||
).width;
|
||||
const { height, baseline } = measureTextElement(textElement, { text });
|
||||
const metrics = { width, height, baseline };
|
||||
let coordY = textElement.y;
|
||||
let coordX = textElement.x;
|
||||
const metrics = measureTextElement(textElement, {
|
||||
text: boundTextUpdates.text,
|
||||
});
|
||||
|
||||
boundTextUpdates.width = metrics.width;
|
||||
boundTextUpdates.height = metrics.height;
|
||||
|
||||
// Maintain coordX for non left-aligned text in case the width has changed
|
||||
if (!container) {
|
||||
if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
coordX += textElement.width - metrics.width;
|
||||
boundTextUpdates.x += textElement.width - metrics.width;
|
||||
} else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
|
||||
coordX += textElement.width / 2 - metrics.width / 2;
|
||||
boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
|
||||
}
|
||||
}
|
||||
// Resize container and vertically center align the text
|
||||
if (container) {
|
||||
if (!isArrowElement(container)) {
|
||||
const containerDims = getContainerDims(container);
|
||||
let nextHeight = containerDims.height;
|
||||
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
coordY = container.y;
|
||||
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
metrics.height -
|
||||
BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
|
||||
if (metrics.height > getMaxContainerHeight(container)) {
|
||||
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
|
||||
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
||||
}
|
||||
}
|
||||
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
coordX =
|
||||
container.x +
|
||||
containerDims.width -
|
||||
metrics.width -
|
||||
BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
|
||||
}
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
} else {
|
||||
if (isArrowElement(container)) {
|
||||
const centerX = textElement.x + textElement.width / 2;
|
||||
const centerY = textElement.y + textElement.height / 2;
|
||||
const diffWidth = metrics.width - textElement.width;
|
||||
const diffHeight = metrics.height - textElement.height;
|
||||
coordY = centerY - (textElement.height + diffHeight) / 2;
|
||||
coordX = centerX - (textElement.width + diffWidth) / 2;
|
||||
boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
|
||||
boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
|
||||
} else {
|
||||
const containerDims = getContainerDims(container);
|
||||
let maxContainerHeight = getMaxContainerHeight(container);
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerHeightForBoundText(
|
||||
container,
|
||||
metrics.height,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
maxContainerHeight = getMaxContainerHeight(container);
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
}
|
||||
}
|
||||
mutateElement(textElement, {
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
y: coordY,
|
||||
x: coordX,
|
||||
text,
|
||||
});
|
||||
|
||||
mutateElement(textElement, boundTextUpdates);
|
||||
};
|
||||
|
||||
export const bindTextToShapeAfterDuplication = (
|
||||
|
@ -212,23 +202,21 @@ 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 = wrapTextElement(textElement, maxWidth);
|
||||
}
|
||||
const dimensions = measureTextElement(
|
||||
textElement,
|
||||
{ text },
|
||||
container.width,
|
||||
);
|
||||
const dimensions = measureTextElement(textElement, { text });
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
nextBaseLine = dimensions.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > maxHeight) {
|
||||
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
|
||||
containerHeight = computeContainerHeightForBoundText(
|
||||
container,
|
||||
nextHeight,
|
||||
);
|
||||
|
||||
const diff = containerHeight - containerDims.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
|
@ -248,94 +236,64 @@ export const handleBindTextResize = (
|
|||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
|
||||
if (!isArrowElement(container)) {
|
||||
updateBoundTextPosition(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
mutateElement(
|
||||
textElement,
|
||||
computeBoundTextPosition(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateBoundTextPosition = (
|
||||
const computeBoundTextPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const containerDims = getContainerDims(container);
|
||||
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
|
||||
const containerCoords = getContainerCoords(container);
|
||||
const maxContainerHeight = getMaxContainerHeight(container);
|
||||
const maxContainerWidth = getMaxContainerWidth(container);
|
||||
|
||||
let x;
|
||||
let y;
|
||||
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
y = container.y + boundTextElementPadding;
|
||||
y = containerCoords.y;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
boundTextElement.height -
|
||||
boundTextElementPadding;
|
||||
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
|
||||
} else {
|
||||
y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
|
||||
y =
|
||||
containerCoords.y +
|
||||
(maxContainerHeight / 2 - boundTextElement.height / 2);
|
||||
}
|
||||
const x =
|
||||
boundTextElement.textAlign === TEXT_ALIGN.LEFT
|
||||
? container.x + boundTextElementPadding
|
||||
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT
|
||||
? container.x +
|
||||
containerDims.width -
|
||||
boundTextElement.width -
|
||||
boundTextElementPadding
|
||||
: container.x + containerDims.width / 2 - boundTextElement.width / 2;
|
||||
|
||||
mutateElement(boundTextElement, { x, y });
|
||||
if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||
x = containerCoords.x;
|
||||
} else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
|
||||
} else {
|
||||
x =
|
||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||
}
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// 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();
|
||||
|
@ -345,40 +303,47 @@ export const getApproxLineHeight = (font: FontString) => {
|
|||
if (cacheApproxLineHeight[font]) {
|
||||
return cacheApproxLineHeight[font];
|
||||
}
|
||||
cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height;
|
||||
const fontSize = parseInt(font);
|
||||
|
||||
// Calculate line height relative to font size
|
||||
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");
|
||||
|
@ -400,16 +365,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)!,
|
||||
|
@ -510,9 +482,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 +
|
||||
|
@ -652,6 +624,26 @@ export const getContainerCenter = (
|
|||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
};
|
||||
|
||||
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
||||
let offsetX = BOUND_TEXT_PADDING;
|
||||
let offsetY = BOUND_TEXT_PADDING;
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
||||
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
|
||||
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
|
||||
}
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
|
||||
if (container.type === "diamond") {
|
||||
offsetX += container.width / 4;
|
||||
offsetY += container.height / 4;
|
||||
}
|
||||
return {
|
||||
x: container.x + offsetX,
|
||||
y: container.y + offsetY,
|
||||
};
|
||||
};
|
||||
|
||||
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
|
||||
const container = getContainerElement(textElement);
|
||||
if (!container || isArrowElement(container)) {
|
||||
|
@ -664,12 +656,13 @@ export const getBoundTextElementOffset = (
|
|||
boundTextElement: ExcalidrawTextElement | null,
|
||||
) => {
|
||||
const container = getContainerElement(boundTextElement);
|
||||
if (!container) {
|
||||
if (!container || !boundTextElement) {
|
||||
return 0;
|
||||
}
|
||||
if (isArrowElement(container)) {
|
||||
return BOUND_TEXT_PADDING * 8;
|
||||
}
|
||||
|
||||
return BOUND_TEXT_PADDING;
|
||||
};
|
||||
|
||||
|
@ -754,3 +747,76 @@ export const isValidTextContainer = (element: ExcalidrawElement) => {
|
|||
isArrowElement(element)
|
||||
);
|
||||
};
|
||||
|
||||
export const computeContainerHeightForBoundText = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
boundTextElementHeight: number,
|
||||
) => {
|
||||
if (container.type === "ellipse") {
|
||||
return Math.round(
|
||||
((boundTextElementHeight + BOUND_TEXT_PADDING * 2) / Math.sqrt(2)) * 2,
|
||||
);
|
||||
}
|
||||
if (isArrowElement(container)) {
|
||||
return boundTextElementHeight + BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
if (container.type === "diamond") {
|
||||
return 2 * (boundTextElementHeight + BOUND_TEXT_PADDING * 2);
|
||||
}
|
||||
return boundTextElementHeight + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerWidth <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.width;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return containerWidth;
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The width of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
|
||||
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (container.type === "diamond") {
|
||||
// The width of the largest rectangle inscribed inside a rhombus is
|
||||
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.height;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
if (container.type === "ellipse") {
|
||||
// The height of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
|
||||
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
|
||||
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (container.type === "diamond") {
|
||||
// The height of the largest rectangle inscribed inside a rhombus is
|
||||
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
|
|
@ -6,14 +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,
|
||||
FontString,
|
||||
} from "./types";
|
||||
import * as textElementUtils from "./textElement";
|
||||
import { getFontString } from "../utils";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { resize } from "../tests/utils";
|
||||
|
@ -442,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 = [];
|
||||
|
@ -734,53 +720,6 @@ describe("textWysiwyg", () => {
|
|||
});
|
||||
|
||||
it("should wrap text and vertcially center align once text submitted", async () => {
|
||||
const mockMeasureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth?: number | null,
|
||||
) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureText")
|
||||
.mockImplementation(mockMeasureText);
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureTextElement")
|
||||
.mockImplementation((element, next, maxWidth) => {
|
||||
return mockMeasureText(
|
||||
next?.text ?? element.text,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
});
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
|
@ -789,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!",
|
||||
|
@ -808,11 +742,11 @@ describe("textWysiwyg", () => {
|
|||
expect(text.text).toBe("Hello \nWorld!");
|
||||
expect(text.originalText).toBe("Hello World!");
|
||||
expect(text.y).toBe(
|
||||
rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2,
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
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.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
|
||||
|
@ -829,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));
|
||||
|
@ -843,12 +772,12 @@ describe("textWysiwyg", () => {
|
|||
|
||||
expect(text.text).toBe("Hello");
|
||||
expect(text.originalText).toBe("Hello");
|
||||
expect(text.height).toBe(24);
|
||||
expect(text.width).toBe(50);
|
||||
expect(text.y).toBe(
|
||||
rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2,
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
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.x).toBe(30);
|
||||
});
|
||||
|
||||
it("should unbind bound text when unbind action from context menu is triggered", async () => {
|
||||
|
@ -935,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,
|
||||
]
|
||||
`);
|
||||
|
||||
|
@ -950,6 +879,8 @@ describe("textWysiwyg", () => {
|
|||
editor.select();
|
||||
|
||||
fireEvent.click(screen.getByTitle("Left"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
|
@ -960,7 +891,7 @@ describe("textWysiwyg", () => {
|
|||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
90,
|
||||
66,
|
||||
]
|
||||
`);
|
||||
|
||||
|
@ -983,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,
|
||||
]
|
||||
`);
|
||||
|
@ -1098,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);
|
||||
|
@ -1130,43 +1061,6 @@ describe("textWysiwyg", () => {
|
|||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
const mockMeasureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth?: number | null,
|
||||
) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureText")
|
||||
.mockImplementation(mockMeasureText);
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureTextElement")
|
||||
.mockImplementation((element, next, maxWidth) => {
|
||||
return mockMeasureText(
|
||||
next?.text ?? element.text,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
});
|
||||
const originalRectHeight = rectangle.height;
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
|
||||
|
@ -1180,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,
|
||||
|
@ -1206,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);
|
||||
|
@ -1218,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);
|
||||
|
||||
|
@ -1250,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", () => {
|
||||
|
@ -1278,7 +1173,7 @@ describe("textWysiwyg", () => {
|
|||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
20,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
@ -1288,8 +1183,8 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Align top"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
94.5,
|
||||
20,
|
||||
30,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
@ -1299,22 +1194,22 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Align top"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
174,
|
||||
20,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
45,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when center left", async () => {
|
||||
fireEvent.click(screen.getByTitle("Center vertically"));
|
||||
fireEvent.click(screen.getByTitle("Left"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
15,
|
||||
45.5,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when center center", async () => {
|
||||
|
@ -1322,11 +1217,11 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Center vertically"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
-25,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
30,
|
||||
45.5,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when center right", async () => {
|
||||
|
@ -1334,11 +1229,11 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Center vertically"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
174,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
45,
|
||||
45.5,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when bottom left", async () => {
|
||||
|
@ -1346,33 +1241,33 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
15,
|
||||
66,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when bottom center", async () => {
|
||||
fireEvent.click(screen.getByTitle("Center"));
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
94.5,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
30,
|
||||
66,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when bottom right", async () => {
|
||||
fireEvent.click(screen.getByTitle("Right"));
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
174,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
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,
|
||||
|
@ -24,14 +24,17 @@ import { mutateElement } from "./mutateElement";
|
|||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
getBoundTextElementOffset,
|
||||
getContainerCoords,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
measureText,
|
||||
getTextWidth,
|
||||
measureText,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
|
@ -39,7 +42,6 @@ import {
|
|||
} from "../actions/actionProperties";
|
||||
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||
import App from "../components/App";
|
||||
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
|
||||
|
@ -155,19 +157,23 @@ export const textWysiwyg = ({
|
|||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
let coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
let eCoordY = coordY;
|
||||
const container = getContainerElement(updatedTextElement);
|
||||
let maxWidth = updatedTextElement.width;
|
||||
|
||||
// Editing metrics
|
||||
const eMetrics = measureText(
|
||||
updatedTextElement.originalText,
|
||||
container && updatedTextElement.containerId
|
||||
? wrapText(
|
||||
updatedTextElement.originalText,
|
||||
getFontString(updatedTextElement),
|
||||
getMaxContainerWidth(container),
|
||||
)
|
||||
: updatedTextElement.originalText,
|
||||
getFontString(updatedTextElement),
|
||||
container ? getContainerDims(container).width : null,
|
||||
);
|
||||
|
||||
let maxWidth = eMetrics.width;
|
||||
let maxHeight = eMetrics.height;
|
||||
const width = eMetrics.width;
|
||||
let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let textElementHeight = Math.max(updatedTextElement.height, maxHeight);
|
||||
|
@ -181,7 +187,6 @@ export const textWysiwyg = ({
|
|||
);
|
||||
coordX = boundTextCoords.x;
|
||||
coordY = boundTextCoords.y;
|
||||
eCoordY = coordY;
|
||||
}
|
||||
const propertiesUpdated = textPropertiesUpdated(
|
||||
updatedTextElement,
|
||||
|
@ -198,7 +203,11 @@ export const textWysiwyg = ({
|
|||
const font = getFontString(updatedTextElement);
|
||||
textElementHeight =
|
||||
getApproxLineHeight(font) *
|
||||
updatedTextElement.text.split("\n").length;
|
||||
wrapText(
|
||||
updatedTextElement.originalText,
|
||||
font,
|
||||
getMaxContainerWidth(container),
|
||||
).split("\n").length;
|
||||
textElementHeight = Math.max(
|
||||
textElementHeight,
|
||||
updatedTextElement.height,
|
||||
|
@ -248,25 +257,21 @@ export const textWysiwyg = ({
|
|||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
const containerCoords = getContainerCoords(container);
|
||||
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
container.y + containerDims.height / 2 - textElementHeight / 2;
|
||||
eCoordY = coordY + textElementHeight / 2 - eMetrics.height / 2;
|
||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
textElementHeight -
|
||||
getBoundTextElementOffset(updatedTextElement);
|
||||
eCoordY = coordY + textElementHeight - eMetrics.height;
|
||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, eCoordY);
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
const initialSelectionStart = editable.selectionStart;
|
||||
const initialSelectionEnd = editable.selectionEnd;
|
||||
const initialLength = editable.value.length;
|
||||
|
@ -308,6 +313,12 @@ export const textWysiwyg = ({
|
|||
: 0;
|
||||
const { width: w, height: h } = updatedTextElement;
|
||||
|
||||
let transformWidth = updatedTextElement.width;
|
||||
// As firefox, Safari needs little higher dimensions on DOM
|
||||
if (isFirefox || isSafari) {
|
||||
textElementWidth += 0.5;
|
||||
transformWidth += 0.5;
|
||||
}
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
|
@ -315,14 +326,14 @@ export const textWysiwyg = ({
|
|||
font: getFontString(updatedTextElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
width: `${Math.min(width, maxWidth)}px`,
|
||||
width: `${Math.min(textElementWidth, maxWidth)}px`,
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
transformOrigin: `${w / 2}px ${h / 2}px`,
|
||||
transform: getTransform(
|
||||
offsetX,
|
||||
updatedTextElement.width,
|
||||
transformWidth,
|
||||
updatedTextElement.height,
|
||||
getTextElementAngle(updatedTextElement),
|
||||
appState,
|
||||
|
@ -415,55 +426,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));
|
||||
};
|
||||
|
@ -500,7 +472,9 @@ export const textWysiwyg = ({
|
|||
event.code === CODES.BRACKET_RIGHT))
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
} else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
|
||||
outdent();
|
||||
} else {
|
||||
indent();
|
||||
|
@ -649,6 +623,7 @@ export const textWysiwyg = ({
|
|||
),
|
||||
});
|
||||
}
|
||||
redrawTextBoundingBox(updateElement, container);
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
|
|
|
@ -132,7 +132,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
|||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
text: string;
|
||||
baseline: number;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue