feat: support shrinking text containers to original height when text removed (#6025)
* fix:cache bind text containers height so that it could autoshrink to original height when text deleted * revert * rename * reset cache when resized * safe check * restore original containr height when text is unbind * update cache when redrawing bounding box * reset cache when unbind * make type-safe * add specs * skip one test * remoe mock * fix Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
9086674b27
commit
8ec5f7b982
@ -6,6 +6,10 @@ import {
|
|||||||
measureText,
|
measureText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
|
import {
|
||||||
|
getOriginalContainerHeightFromCache,
|
||||||
|
resetOriginalContainerCache,
|
||||||
|
} from "../element/textWysiwyg";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
@ -38,6 +42,11 @@ export const actionUnbindText = register({
|
|||||||
boundTextElement.originalText,
|
boundTextElement.originalText,
|
||||||
getFontString(boundTextElement),
|
getFontString(boundTextElement),
|
||||||
);
|
);
|
||||||
|
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||||
|
element.id,
|
||||||
|
);
|
||||||
|
resetOriginalContainerCache(element.id);
|
||||||
|
|
||||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||||
containerId: null,
|
containerId: null,
|
||||||
width,
|
width,
|
||||||
@ -49,6 +58,9 @@ export const actionUnbindText = register({
|
|||||||
boundElements: element.boundElements?.filter(
|
boundElements: element.boundElements?.filter(
|
||||||
(ele) => ele.id !== boundTextElement.id,
|
(ele) => ele.id !== boundTextElement.id,
|
||||||
),
|
),
|
||||||
|
height: originalContainerHeight
|
||||||
|
? originalContainerHeight
|
||||||
|
: element.height,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -24,6 +24,10 @@ import { isTextBindableContainer } from "./typeChecks";
|
|||||||
import { getElementAbsoluteCoords } from "../element";
|
import { getElementAbsoluteCoords } from "../element";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { isHittingElementNotConsideringBoundingBox } from "./collision";
|
import { isHittingElementNotConsideringBoundingBox } from "./collision";
|
||||||
|
import {
|
||||||
|
resetOriginalContainerCache,
|
||||||
|
updateOriginalContainerCache,
|
||||||
|
} from "./textWysiwyg";
|
||||||
|
|
||||||
export const normalizeText = (text: string) => {
|
export const normalizeText = (text: string) => {
|
||||||
return (
|
return (
|
||||||
@ -84,7 +88,7 @@ export const redrawTextBoundingBox = (
|
|||||||
} else {
|
} else {
|
||||||
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
|
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
|
||||||
}
|
}
|
||||||
|
updateOriginalContainerCache(container.id, nextHeight);
|
||||||
mutateElement(container, { height: nextHeight });
|
mutateElement(container, { height: nextHeight });
|
||||||
} else {
|
} else {
|
||||||
const centerX = textElement.x + textElement.width / 2;
|
const centerX = textElement.x + textElement.width / 2;
|
||||||
@ -149,6 +153,7 @@ export const handleBindTextResize = (
|
|||||||
if (!boundTextElementId) {
|
if (!boundTextElementId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
resetOriginalContainerCache(container.id);
|
||||||
let textElement = Scene.getScene(container)!.getElement(
|
let textElement = Scene.getScene(container)!.getElement(
|
||||||
boundTextElementId,
|
boundTextElementId,
|
||||||
) as ExcalidrawTextElement;
|
) as ExcalidrawTextElement;
|
||||||
|
@ -15,6 +15,7 @@ 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";
|
||||||
|
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
@ -1019,7 +1020,6 @@ describe("textWysiwyg", () => {
|
|||||||
const originalRectY = rectangle.y;
|
const originalRectY = rectangle.y;
|
||||||
const originalTextX = text.x;
|
const originalTextX = text.x;
|
||||||
const originalTextY = text.y;
|
const originalTextY = text.y;
|
||||||
|
|
||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
mouse.downAt(rectangle.x, rectangle.y);
|
mouse.downAt(rectangle.x, rectangle.y);
|
||||||
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
|
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
|
||||||
@ -1055,5 +1055,115 @@ describe("textWysiwyg", () => {
|
|||||||
expect(rectangle.boundElements).toStrictEqual([]);
|
expect(rectangle.boundElements).toStrictEqual([]);
|
||||||
expect(h.elements[1].isDeleted).toBe(true);
|
expect(h.elements[1].isDeleted).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(textElementUtils, "measureText")
|
||||||
|
.mockImplementation((text, font, maxWidth) => {
|
||||||
|
let width = INITIAL_WIDTH;
|
||||||
|
let height = APPROX_LINE_HEIGHT;
|
||||||
|
let baseline = 10;
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
baseline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
baseline = 30;
|
||||||
|
width = DUMMY_WIDTH;
|
||||||
|
height = APPROX_LINE_HEIGHT * 5;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
baseline,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const originalRectHeight = rectangle.height;
|
||||||
|
expect(rectangle.height).toBe(originalRectHeight);
|
||||||
|
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
const editor = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
fireEvent.change(editor, {
|
||||||
|
target: { value: "Online whiteboard collaboration made easy" },
|
||||||
|
});
|
||||||
|
editor.blur();
|
||||||
|
expect(rectangle.height).toBe(135);
|
||||||
|
mouse.select(rectangle);
|
||||||
|
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||||
|
button: 2,
|
||||||
|
clientX: 20,
|
||||||
|
clientY: 30,
|
||||||
|
});
|
||||||
|
const contextMenu = document.querySelector(".context-menu");
|
||||||
|
fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
|
||||||
|
expect(h.elements[0].boundElements).toEqual([]);
|
||||||
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||||
|
|
||||||
|
expect(rectangle.height).toBe(originalRectHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reset the container height cache when resizing", async () => {
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||||
|
let editor = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||||
|
expect(rectangle.height).toBe(215);
|
||||||
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||||
|
|
||||||
|
mouse.select(rectangle);
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
|
editor = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
editor.blur();
|
||||||
|
expect(rectangle.height).toBe(215);
|
||||||
|
// cache updated again
|
||||||
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
|
||||||
|
});
|
||||||
|
|
||||||
|
//@todo fix this test later once measureText is mocked correctly
|
||||||
|
it.skip("should reset the container height cache when font properties updated", async () => {
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||||
|
|
||||||
|
const editor = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||||
|
editor.blur();
|
||||||
|
|
||||||
|
mouse.select(rectangle);
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle(/code/i));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||||
|
).toEqual(FONT_FAMILY.Cascadia);
|
||||||
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle(/Very large/i));
|
||||||
|
expect(
|
||||||
|
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
||||||
|
).toEqual(36);
|
||||||
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
|
ExcalidrawTextContainer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
@ -60,6 +61,38 @@ const getTransform = (
|
|||||||
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const originalContainerCache: {
|
||||||
|
[id: ExcalidrawTextContainer["id"]]:
|
||||||
|
| {
|
||||||
|
height: ExcalidrawTextContainer["height"];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
export const updateOriginalContainerCache = (
|
||||||
|
id: ExcalidrawTextContainer["id"],
|
||||||
|
height: ExcalidrawTextContainer["height"],
|
||||||
|
) => {
|
||||||
|
const data =
|
||||||
|
originalContainerCache[id] || (originalContainerCache[id] = { height });
|
||||||
|
data.height = height;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetOriginalContainerCache = (
|
||||||
|
id: ExcalidrawTextContainer["id"],
|
||||||
|
) => {
|
||||||
|
if (originalContainerCache[id]) {
|
||||||
|
delete originalContainerCache[id];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOriginalContainerHeightFromCache = (
|
||||||
|
id: ExcalidrawTextContainer["id"],
|
||||||
|
) => {
|
||||||
|
return originalContainerCache[id]?.height ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const textWysiwyg = ({
|
export const textWysiwyg = ({
|
||||||
id,
|
id,
|
||||||
onChange,
|
onChange,
|
||||||
@ -87,6 +120,9 @@ export const textWysiwyg = ({
|
|||||||
updatedTextElement: ExcalidrawTextElement,
|
updatedTextElement: ExcalidrawTextElement,
|
||||||
editable: HTMLTextAreaElement,
|
editable: HTMLTextAreaElement,
|
||||||
) => {
|
) => {
|
||||||
|
if (!editable.style.fontFamily || !editable.style.fontSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const currentFont = editable.style.fontFamily.replace(/"/g, "");
|
const currentFont = editable.style.fontFamily.replace(/"/g, "");
|
||||||
if (
|
if (
|
||||||
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
|
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
|
||||||
@ -99,7 +135,6 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let originalContainerHeight: number;
|
|
||||||
|
|
||||||
const updateWysiwygStyle = () => {
|
const updateWysiwygStyle = () => {
|
||||||
const appState = app.state;
|
const appState = app.state;
|
||||||
@ -123,7 +158,7 @@ export const textWysiwyg = ({
|
|||||||
const width = updatedTextElement.width;
|
const width = 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 height = updatedTextElement.height;
|
let textElementHeight = updatedTextElement.height;
|
||||||
if (container && updatedTextElement.containerId) {
|
if (container && updatedTextElement.containerId) {
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const boundTextCoords =
|
const boundTextCoords =
|
||||||
@ -142,34 +177,52 @@ export const textWysiwyg = ({
|
|||||||
// using editor.style.height to get the accurate height of text editor
|
// using editor.style.height to get the accurate height of text editor
|
||||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||||
if (editorHeight > 0) {
|
if (editorHeight > 0) {
|
||||||
height = editorHeight;
|
textElementHeight = editorHeight;
|
||||||
}
|
}
|
||||||
if (propertiesUpdated) {
|
if (propertiesUpdated) {
|
||||||
originalContainerHeight = containerDims.height;
|
|
||||||
|
|
||||||
// update height of the editor after properties updated
|
// update height of the editor after properties updated
|
||||||
height = updatedTextElement.height;
|
textElementHeight = updatedTextElement.height;
|
||||||
}
|
}
|
||||||
if (!originalContainerHeight) {
|
|
||||||
originalContainerHeight = containerDims.height;
|
let originalContainerData;
|
||||||
|
if (propertiesUpdated) {
|
||||||
|
originalContainerData = updateOriginalContainerCache(
|
||||||
|
container.id,
|
||||||
|
containerDims.height,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
originalContainerData = originalContainerCache[container.id];
|
||||||
|
if (!originalContainerData) {
|
||||||
|
originalContainerData = updateOriginalContainerCache(
|
||||||
|
container.id,
|
||||||
|
containerDims.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maxWidth = getMaxContainerWidth(container);
|
maxWidth = getMaxContainerWidth(container);
|
||||||
maxHeight = getMaxContainerHeight(container);
|
maxHeight = getMaxContainerHeight(container);
|
||||||
|
|
||||||
// autogrow container height if text exceeds
|
// autogrow container height if text exceeds
|
||||||
|
|
||||||
if (!isArrowElement(container) && height > maxHeight) {
|
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||||
const diff = Math.min(height - maxHeight, approxLineHeight);
|
const diff = Math.min(
|
||||||
|
textElementHeight - maxHeight,
|
||||||
|
approxLineHeight,
|
||||||
|
);
|
||||||
mutateElement(container, { height: containerDims.height + diff });
|
mutateElement(container, { height: containerDims.height + diff });
|
||||||
return;
|
return;
|
||||||
} else if (
|
} else if (
|
||||||
// autoshrink container height until original container height
|
// autoshrink container height until original container height
|
||||||
// is reached when text is removed
|
// is reached when text is removed
|
||||||
!isArrowElement(container) &&
|
!isArrowElement(container) &&
|
||||||
containerDims.height > originalContainerHeight &&
|
containerDims.height > originalContainerData.height &&
|
||||||
height < maxHeight
|
textElementHeight < maxHeight
|
||||||
) {
|
) {
|
||||||
const diff = Math.min(maxHeight - height, approxLineHeight);
|
const diff = Math.min(
|
||||||
|
maxHeight - textElementHeight,
|
||||||
|
approxLineHeight,
|
||||||
|
);
|
||||||
mutateElement(container, { height: containerDims.height - diff });
|
mutateElement(container, { height: containerDims.height - diff });
|
||||||
}
|
}
|
||||||
// Start pushing text upward until a diff of 30px (padding)
|
// Start pushing text upward until a diff of 30px (padding)
|
||||||
@ -178,14 +231,15 @@ export const textWysiwyg = ({
|
|||||||
// 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 = container.y + containerDims.height / 2 - height / 2;
|
coordY =
|
||||||
|
container.y + containerDims.height / 2 - textElementHeight / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||||
coordY =
|
coordY =
|
||||||
container.y +
|
container.y +
|
||||||
containerDims.height -
|
containerDims.height -
|
||||||
height -
|
textElementHeight -
|
||||||
getBoundTextElementOffset(updatedTextElement);
|
getBoundTextElementOffset(updatedTextElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -226,12 +280,12 @@ export const textWysiwyg = ({
|
|||||||
// must be defined *after* font ¯\_(ツ)_/¯
|
// must be defined *after* font ¯\_(ツ)_/¯
|
||||||
lineHeight: `${lineHeight}px`,
|
lineHeight: `${lineHeight}px`,
|
||||||
width: `${Math.min(width, maxWidth)}px`,
|
width: `${Math.min(width, maxWidth)}px`,
|
||||||
height: `${height}px`,
|
height: `${textElementHeight}px`,
|
||||||
left: `${viewportX}px`,
|
left: `${viewportX}px`,
|
||||||
top: `${viewportY}px`,
|
top: `${viewportY}px`,
|
||||||
transform: getTransform(
|
transform: getTransform(
|
||||||
width,
|
width,
|
||||||
height,
|
textElementHeight,
|
||||||
getTextElementAngle(updatedTextElement),
|
getTextElementAngle(updatedTextElement),
|
||||||
appState,
|
appState,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user