fix: improve text wrapping in ellipse and alignment (#6172)

* fix: improve text wrapping in ellipse

* compute height when font properties updated

* fix alignment

* fix alignment when resizing

* fix

* ad padding

* always compute height when redrawing bounding box and refactor

* lint

* fix specs

* fix

* redraw text bounding box when pasted or refreshed

* fix

* Add specs

* fix

* restore on font load

* add comments
This commit is contained in:
Aakansha Doshi 2023-02-21 12:36:43 +05:30 committed by GitHub
parent 0fcbddda8e
commit 88ff32e9b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 225 additions and 108 deletions

View File

@ -108,6 +108,7 @@ import {
textWysiwyg, textWysiwyg,
transformElements, transformElements,
updateTextElement, updateTextElement,
redrawTextBoundingBox,
} from "../element"; } from "../element";
import { import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
@ -264,6 +265,7 @@ import {
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerDims, getContainerDims,
getContainerElement,
getTextBindableContainerAtPosition, getTextBindableContainerAtPosition,
isValidTextContainer, isValidTextContainer,
} from "../element/textElement"; } from "../element/textElement";
@ -1637,6 +1639,14 @@ class App extends React.Component<AppProps, AppState> {
} }
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
nextElements.forEach((nextElement) => {
if (isTextElement(nextElement) && isBoundToContainer(nextElement)) {
const container = getContainerElement(nextElement);
redrawTextBoundingBox(nextElement, container);
}
});
this.history.resumeRecording(); this.history.resumeRecording();
this.setState( this.setState(

View File

@ -290,6 +290,11 @@ export const getMaxContainerWidth = (container: ExcalidrawElement) => {
return BOUND_TEXT_PADDING * 8 * 2; return BOUND_TEXT_PADDING * 8 * 2;
} }
return containerWidth; return containerWidth;
} else 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;
} }
return width - BOUND_TEXT_PADDING * 2; return width - BOUND_TEXT_PADDING * 2;
}; };
@ -306,6 +311,11 @@ export const getMaxContainerHeight = (container: ExcalidrawElement) => {
return BOUND_TEXT_PADDING * 8 * 2; return BOUND_TEXT_PADDING * 8 * 2;
} }
return height; return height;
} else 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;
} }
return height - BOUND_TEXT_PADDING * 2; return height - BOUND_TEXT_PADDING * 2;
}; };

View File

@ -1,5 +1,11 @@
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING } from "../constants";
import { measureText, wrapText } from "./textElement"; import { API } from "../tests/helpers/api";
import {
computeContainerHeightForBoundText,
getContainerCoords,
measureText,
wrapText,
} from "./textElement";
import { FontString } from "./types"; import { FontString } from "./types";
describe("Test wrapText", () => { describe("Test wrapText", () => {
@ -193,4 +199,49 @@ describe("Test measureText", () => {
</div> </div>
`); `);
}); });
describe("Test getContainerCoords", () => {
const params = { width: 200, height: 100, x: 10, y: 20 };
it("should compute coords correctly when ellipse", () => {
const ellipse = API.createElement({
type: "ellipse",
...params,
});
expect(getContainerCoords(ellipse)).toEqual({
x: 44.2893218813452455,
y: 39.64466094067262,
});
});
it("should compute coords correctly when rectangle", () => {
const rectangle = API.createElement({
type: "rectangle",
...params,
});
expect(getContainerCoords(rectangle)).toEqual({
x: 10,
y: 20,
});
});
});
describe("Test computeContainerHeightForBoundText", () => {
const params = {
width: 178,
height: 194,
};
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(212);
});
});
}); });

View File

@ -44,68 +44,69 @@ export const redrawTextBoundingBox = (
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
) => { ) => {
let maxWidth = undefined; let maxWidth = undefined;
let text = textElement.text;
const boundTextUpdates = {
x: textElement.x,
y: textElement.y,
text: textElement.text,
width: textElement.width,
height: textElement.height,
baseline: textElement.baseline,
};
boundTextUpdates.text = textElement.text;
if (container) { if (container) {
maxWidth = getMaxContainerWidth(container); maxWidth = getMaxContainerWidth(container);
text = wrapText( boundTextUpdates.text = wrapText(
textElement.originalText, textElement.originalText,
getFontString(textElement), getFontString(textElement),
maxWidth, maxWidth,
); );
} }
const metrics = measureText(text, getFontString(textElement), maxWidth); const metrics = measureText(
let coordY = textElement.y; boundTextUpdates.text,
let coordX = textElement.x; getFontString(textElement),
// Resize container and vertically center align the text maxWidth,
);
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
boundTextUpdates.baseline = metrics.baseline;
if (container) { if (container) {
if (!isArrowElement(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 {
const centerX = textElement.x + textElement.width / 2; const centerX = textElement.x + textElement.width / 2;
const centerY = textElement.y + textElement.height / 2; const centerY = textElement.y + textElement.height / 2;
const diffWidth = metrics.width - textElement.width; const diffWidth = metrics.width - textElement.width;
const diffHeight = metrics.height - textElement.height; const diffHeight = metrics.height - textElement.height;
coordY = centerY - (textElement.height + diffHeight) / 2; boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
coordX = centerX - (textElement.width + diffWidth) / 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, mutateElement(textElement, boundTextUpdates);
height: metrics.height,
baseline: metrics.baseline,
y: coordY,
x: coordX,
text,
});
}; };
export const bindTextToShapeAfterDuplication = ( export const bindTextToShapeAfterDuplication = (
@ -197,7 +198,11 @@ export const handleBindTextResize = (
} }
// increase height in case text element height exceeds // increase height in case text element height exceeds
if (nextHeight > maxHeight) { if (nextHeight > maxHeight) {
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2; containerHeight = computeContainerHeightForBoundText(
container,
nextHeight,
);
const diff = containerHeight - containerDims.height; const diff = containerHeight - containerDims.height;
// fix the y coord when resizing from ne/nw/n // fix the y coord when resizing from ne/nw/n
const updatedY = const updatedY =
@ -217,48 +222,57 @@ export const handleBindTextResize = (
text, text,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
baseline: nextBaseLine, baseline: nextBaseLine,
}); });
if (!isArrowElement(container)) { if (!isArrowElement(container)) {
updateBoundTextPosition( mutateElement(
container, textElement,
textElement as ExcalidrawTextElementWithContainer, computeBoundTextPosition(
container,
textElement as ExcalidrawTextElementWithContainer,
),
); );
} }
} }
}; };
const updateBoundTextPosition = ( const computeBoundTextPosition = (
container: ExcalidrawElement, container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
) => { ) => {
const containerDims = getContainerDims(container); const containerCoords = getContainerCoords(container);
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement); const maxContainerHeight = getMaxContainerHeight(container);
const maxContainerWidth = getMaxContainerWidth(container);
const padding = container.type === "ellipse" ? 0 : BOUND_TEXT_PADDING;
let x;
let y; let y;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) { if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = container.y + boundTextElementPadding; y = containerCoords.y + padding;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y = y =
container.y + containerCoords.y +
containerDims.height - (maxContainerHeight - boundTextElement.height + padding);
boundTextElement.height -
boundTextElementPadding;
} else { } else {
y = container.y + containerDims.height / 2 - boundTextElement.height / 2; y =
containerCoords.y +
(maxContainerHeight / 2 - boundTextElement.height / 2 + padding);
} }
const x = if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
boundTextElement.textAlign === TEXT_ALIGN.LEFT x = containerCoords.x + padding;
? container.x + boundTextElementPadding } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT x =
? container.x + containerCoords.x +
containerDims.width - (maxContainerWidth - boundTextElement.width + padding);
boundTextElement.width - } else {
boundTextElementPadding x =
: container.x + containerDims.width / 2 - boundTextElement.width / 2; containerCoords.x +
(maxContainerWidth / 2 - boundTextElement.width / 2 + padding);
mutateElement(boundTextElement, { x, y }); }
return { x, y };
}; };
// 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 = ( export const measureText = (
text: string, text: string,
@ -621,6 +635,24 @@ export const getContainerCenter = (
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
}; };
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
if (container.type === "ellipse") {
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
const offsetX =
(container.width / 2) * (1 - Math.sqrt(2) / 2) + BOUND_TEXT_PADDING;
const offsetY =
(container.height / 2) * (1 - Math.sqrt(2) / 2) + BOUND_TEXT_PADDING;
return {
x: container.x + offsetX,
y: container.y + offsetY,
};
}
return {
x: container.x,
y: container.y,
};
};
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
const container = getContainerElement(textElement); const container = getContainerElement(textElement);
if (!container || isArrowElement(container)) { if (!container || isArrowElement(container)) {
@ -633,12 +665,13 @@ export const getBoundTextElementOffset = (
boundTextElement: ExcalidrawTextElement | null, boundTextElement: ExcalidrawTextElement | null,
) => { ) => {
const container = getContainerElement(boundTextElement); const container = getContainerElement(boundTextElement);
if (!container) { if (!container || !boundTextElement) {
return 0; return 0;
} }
if (isArrowElement(container)) { if (isArrowElement(container)) {
return BOUND_TEXT_PADDING * 8; return BOUND_TEXT_PADDING * 8;
} }
return BOUND_TEXT_PADDING; return BOUND_TEXT_PADDING;
}; };
@ -723,3 +756,16 @@ export const isValidTextContainer = (element: ExcalidrawElement) => {
isArrowElement(element) isArrowElement(element)
); );
}; };
export const computeContainerHeightForBoundText = (
container: NonDeletedExcalidrawElement,
boundTextElementHeight: number,
) => {
if (container.type === "ellipse") {
return Math.round((boundTextElementHeight / Math.sqrt(2)) * 2);
}
if (isArrowElement(container)) {
return boundTextElementHeight + BOUND_TEXT_PADDING * 8 * 2;
}
return boundTextElementHeight + BOUND_TEXT_PADDING * 2;
};

View File

@ -791,9 +791,7 @@ 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( expect(text.y).toBe(27.5);
rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2,
);
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
expect(text.height).toBe(APPROX_LINE_HEIGHT * 2); expect(text.height).toBe(APPROX_LINE_HEIGHT * 2);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
@ -827,9 +825,7 @@ 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( expect(text.y).toBe(40);
rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2,
);
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING); expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
expect(text.height).toBe(APPROX_LINE_HEIGHT); expect(text.height).toBe(APPROX_LINE_HEIGHT);
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
@ -1248,7 +1244,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,
20, 25,
] ]
`); `);
}); });
@ -1259,7 +1255,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 [
94.5, 94.5,
20, 25,
] ]
`); `);
}); });
@ -1269,22 +1265,22 @@ 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 [
174, 174,
20, 25,
] ]
`); `);
}); });
it("when center left", async () => { it("when center left", async () => {
fireEvent.click(screen.getByTitle("Center vertically")); fireEvent.click(screen.getByTitle("Center vertically"));
fireEvent.click(screen.getByTitle("Left")); fireEvent.click(screen.getByTitle("Left"));
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, 20,
] ]
`); `);
}); });
it("when center center", async () => { it("when center center", async () => {
@ -1292,11 +1288,11 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Center vertically")); fireEvent.click(screen.getByTitle("Center vertically"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
-25, -25,
25, 20,
] ]
`); `);
}); });
it("when center right", async () => { it("when center right", async () => {
@ -1304,11 +1300,11 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Center vertically")); fireEvent.click(screen.getByTitle("Center vertically"));
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
174, 174,
25, 20,
] ]
`); `);
}); });
it("when bottom left", async () => { it("when bottom left", async () => {

View File

@ -25,6 +25,7 @@ import {
getApproxLineHeight, getApproxLineHeight,
getBoundTextElementId, getBoundTextElementId,
getBoundTextElementOffset, getBoundTextElementOffset,
getContainerCoords,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
@ -230,19 +231,22 @@ export const textWysiwyg = ({
// Start pushing text upward until a diff of 30px (padding) // Start pushing text upward until a diff of 30px (padding)
// is reached // is reached
else { else {
const padding =
container.type === "ellipse"
? 0
: getBoundTextElementOffset(updatedTextElement);
const containerCoords = getContainerCoords(container);
// vertically center align the text // vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) { if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
if (!isArrowElement(container)) { if (!isArrowElement(container)) {
coordY = coordY =
container.y + containerDims.height / 2 - textElementHeight / 2; containerCoords.y + maxHeight / 2 - textElementHeight / 2;
} }
} }
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY = coordY =
container.y + containerCoords.y + (maxHeight - textElementHeight + padding);
containerDims.height -
textElementHeight -
getBoundTextElementOffset(updatedTextElement);
} }
} }
} }