mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-30 18:34:22 +01:00 
			
		
		
		
	Compare commits
	
		
			19 Commits
		
	
	
		
			v0.17.0
			...
			aakansha-r
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 993294ac08 | ||
|   | f584416c9a | ||
|   | 87b0c7a679 | ||
|   | ee8fff8e8b | ||
|   | b799490ece | ||
|   | fd18896293 | ||
|   | e900cb0b64 | ||
|   | 54bf3d9092 | ||
|   | 15f19835fe | ||
|   | 96c4cff805 | ||
|   | 1ac580136d | ||
|   | 8c89fdfa51 | ||
|   | 0e54994187 | ||
|   | 91f6e87317 | ||
|   | a05db6864e | ||
|   | eacee9a158 | ||
|   | 7722de4ef2 | ||
|   | 0a295e523b | ||
|   | 60deddb0e2 | 
| @@ -4,9 +4,9 @@ import { mutateElement } from "../element/mutateElement"; | ||||
| import { | ||||
|   computeContainerDimensionForBoundText, | ||||
|   getBoundTextElement, | ||||
|   measureText, | ||||
|   redrawTextBoundingBox, | ||||
| } from "../element/textElement"; | ||||
| import { measureText } from "../element/textMeasurements"; | ||||
| import { | ||||
|   getOriginalContainerHeightFromCache, | ||||
|   resetOriginalContainerCache, | ||||
|   | ||||
| @@ -54,8 +54,8 @@ import { mutateElement, newElementWith } from "../element/mutateElement"; | ||||
| import { | ||||
|   getBoundTextElement, | ||||
|   getContainerElement, | ||||
|   getDefaultLineHeight, | ||||
| } from "../element/textElement"; | ||||
| import { getDefaultLineHeight } from "../element/textMeasurements"; | ||||
| import { | ||||
|   isBoundToContainer, | ||||
|   isLinearElement, | ||||
|   | ||||
| @@ -12,16 +12,14 @@ import { | ||||
|   DEFAULT_FONT_FAMILY, | ||||
|   DEFAULT_TEXT_ALIGN, | ||||
| } from "../constants"; | ||||
| import { | ||||
|   getBoundTextElement, | ||||
|   getDefaultLineHeight, | ||||
| } from "../element/textElement"; | ||||
| import { getBoundTextElement } from "../element/textElement"; | ||||
| import { | ||||
|   hasBoundTextElement, | ||||
|   canApplyRoundnessTypeToElement, | ||||
|   getDefaultRoundnessTypeForElement, | ||||
| } from "../element/typeChecks"; | ||||
| import { getSelectedElements } from "../scene"; | ||||
| import { getDefaultLineHeight } from "../element/textMeasurements"; | ||||
|  | ||||
| // `copiedStyles` is exported only for tests. | ||||
| export let copiedStyles: string = "{}"; | ||||
|   | ||||
| @@ -260,18 +260,20 @@ import throttle from "lodash.throttle"; | ||||
| import { fileOpen, FileSystemHandle } from "../data/filesystem"; | ||||
| import { | ||||
|   bindTextToShapeAfterDuplication, | ||||
|   getApproxMinLineHeight, | ||||
|   getApproxMinLineWidth, | ||||
|   getBoundTextElement, | ||||
|   getContainerCenter, | ||||
|   getContainerDims, | ||||
|   getContainerElement, | ||||
|   getDefaultLineHeight, | ||||
|   getLineHeightInPx, | ||||
|   getTextBindableContainerAtPosition, | ||||
|   isMeasureTextSupported, | ||||
|   isValidTextContainer, | ||||
| } from "../element/textElement"; | ||||
| import { | ||||
|   getApproxMinContainerHeight, | ||||
|   getApproxMinContainerWidth, | ||||
|   isMeasureTextSupported, | ||||
|   getLineHeightInPx, | ||||
|   getDefaultLineHeight, | ||||
| } from "../element/textMeasurements"; | ||||
| import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; | ||||
| import { | ||||
|   normalizeLink, | ||||
| @@ -2622,11 +2624,11 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         fontSize, | ||||
|         fontFamily, | ||||
|       }; | ||||
|       const minWidth = getApproxMinLineWidth( | ||||
|       const minWidth = getApproxMinContainerWidth( | ||||
|         getFontString(fontString), | ||||
|         lineHeight, | ||||
|       ); | ||||
|       const minHeight = getApproxMinLineHeight(fontSize, lineHeight); | ||||
|       const minHeight = getApproxMinContainerHeight(fontSize, lineHeight); | ||||
|       const containerDims = getContainerDims(container); | ||||
|       const newHeight = Math.max(containerDims.height, minHeight); | ||||
|       const newWidth = Math.max(containerDims.width, minWidth); | ||||
|   | ||||
| @@ -35,7 +35,10 @@ import { getUpdatedTimestamp, updateActiveTool } from "../utils"; | ||||
| import { arrayToMap } from "../utils"; | ||||
| import oc from "open-color"; | ||||
| import { MarkOptional, Mutable } from "../utility-types"; | ||||
| import { detectLineHeight, getDefaultLineHeight } from "../element/textElement"; | ||||
| import { | ||||
|   detectLineHeight, | ||||
|   getDefaultLineHeight, | ||||
| } from "../element/textMeasurements"; | ||||
|  | ||||
| type RestoredAppState = Omit< | ||||
|   AppState, | ||||
|   | ||||
| @@ -25,15 +25,17 @@ import { | ||||
|   getBoundTextElementOffset, | ||||
|   getContainerDims, | ||||
|   getContainerElement, | ||||
|   measureText, | ||||
|   normalizeText, | ||||
|   wrapText, | ||||
|   getMaxContainerWidth, | ||||
|   getDefaultLineHeight, | ||||
|   getBoundTextMaxWidth, | ||||
| } from "./textElement"; | ||||
| import { VERTICAL_ALIGN } from "../constants"; | ||||
| import { isArrowElement } from "./typeChecks"; | ||||
| import { MarkOptional, Merge, Mutable } from "../utility-types"; | ||||
| import { | ||||
|   measureText, | ||||
|   wrapText, | ||||
|   getDefaultLineHeight, | ||||
| } from "./textMeasurements"; | ||||
|  | ||||
| type ElementConstructorOpts = MarkOptional< | ||||
|   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, | ||||
| @@ -270,7 +272,7 @@ export const refreshTextDimensions = ( | ||||
|     text = wrapText( | ||||
|       text, | ||||
|       getFontString(textElement), | ||||
|       getMaxContainerWidth(container), | ||||
|       getBoundTextMaxWidth(container), | ||||
|     ); | ||||
|   } | ||||
|   const dimensions = getAdjustedDimensions(textElement, text); | ||||
|   | ||||
| @@ -39,15 +39,16 @@ import { | ||||
| import { Point, PointerDownState } from "../types"; | ||||
| import Scene from "../scene/Scene"; | ||||
| import { | ||||
|   getApproxMinLineWidth, | ||||
|   getBoundTextElement, | ||||
|   getBoundTextElementId, | ||||
|   getContainerElement, | ||||
|   handleBindTextResize, | ||||
|   getMaxContainerWidth, | ||||
|   getApproxMinLineHeight, | ||||
|   getBoundTextMaxWidth, | ||||
| } from "./textElement"; | ||||
|  | ||||
| import { | ||||
|   getApproxMinContainerHeight, | ||||
|   getApproxMinContainerWidth, | ||||
| } from "./textMeasurements"; | ||||
| export const normalizeAngle = (angle: number): number => { | ||||
|   if (angle >= 2 * Math.PI) { | ||||
|     return angle - 2 * Math.PI; | ||||
| @@ -201,7 +202,7 @@ const measureFontSizeFromWidth = ( | ||||
|   if (hasContainer) { | ||||
|     const container = getContainerElement(element); | ||||
|     if (container) { | ||||
|       width = getMaxContainerWidth(container); | ||||
|       width = getBoundTextMaxWidth(container); | ||||
|     } | ||||
|   } | ||||
|   const nextFontSize = element.fontSize * (nextWidth / width); | ||||
| @@ -421,18 +422,18 @@ export const resizeSingleElement = ( | ||||
|  | ||||
|       const nextFontSize = measureFontSizeFromWidth( | ||||
|         boundTextElement, | ||||
|         getMaxContainerWidth(updatedElement), | ||||
|         getBoundTextMaxWidth(updatedElement), | ||||
|       ); | ||||
|       if (nextFontSize === null) { | ||||
|         return; | ||||
|       } | ||||
|       boundTextFontSize = nextFontSize; | ||||
|     } else { | ||||
|       const minWidth = getApproxMinLineWidth( | ||||
|       const minWidth = getApproxMinContainerWidth( | ||||
|         getFontString(boundTextElement), | ||||
|         boundTextElement.lineHeight, | ||||
|       ); | ||||
|       const minHeight = getApproxMinLineHeight( | ||||
|       const minHeight = getApproxMinContainerHeight( | ||||
|         boundTextElement.fontSize, | ||||
|         boundTextElement.lineHeight, | ||||
|       ); | ||||
| @@ -698,7 +699,7 @@ const resizeMultipleElements = ( | ||||
|       const fontSize = measureFontSizeFromWidth( | ||||
|         boundTextElement ?? (element.orig as ExcalidrawTextElement), | ||||
|         boundTextElement | ||||
|           ? getMaxContainerWidth(updatedElement) | ||||
|           ? getBoundTextMaxWidth(updatedElement) | ||||
|           : updatedElement.width, | ||||
|       ); | ||||
|  | ||||
|   | ||||
| @@ -1,189 +1,11 @@ | ||||
| import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; | ||||
| import { API } from "../tests/helpers/api"; | ||||
| import { | ||||
|   computeContainerDimensionForBoundText, | ||||
|   getContainerCoords, | ||||
|   getMaxContainerWidth, | ||||
|   getMaxContainerHeight, | ||||
|   wrapText, | ||||
|   detectLineHeight, | ||||
|   getLineHeightInPx, | ||||
|   getDefaultLineHeight, | ||||
|   getBoundTextMaxWidth, | ||||
|   getBoundTextMaxHeight, | ||||
| } from "./textElement"; | ||||
| import { FontString } from "./types"; | ||||
|  | ||||
| describe("Test wrapText", () => { | ||||
|   const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; | ||||
|  | ||||
|   it("shouldn't add new lines for trailing spaces", () => { | ||||
|     const text = "Hello whats up     "; | ||||
|     const maxWidth = 200 - BOUND_TEXT_PADDING * 2; | ||||
|     const res = wrapText(text, font, maxWidth); | ||||
|     expect(res).toBe(text); | ||||
|   }); | ||||
|  | ||||
|   it("should work with emojis", () => { | ||||
|     const text = "😀"; | ||||
|     const maxWidth = 1; | ||||
|     const res = wrapText(text, font, maxWidth); | ||||
|     expect(res).toBe("😀"); | ||||
|   }); | ||||
|  | ||||
|   it("should show the text correctly when max width reached", () => { | ||||
|     const text = "Hello😀"; | ||||
|     const maxWidth = 10; | ||||
|     const res = wrapText(text, font, maxWidth); | ||||
|     expect(res).toBe("H\ne\nl\nl\no\n😀"); | ||||
|   }); | ||||
|  | ||||
|   describe("When text doesn't contain new lines", () => { | ||||
|     const text = "Hello whats up"; | ||||
|  | ||||
|     [ | ||||
|       { | ||||
|         desc: "break all words when width of each word is less than container width", | ||||
|         width: 80, | ||||
|         res: `Hello \nwhats \nup`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "break all characters when width of each character is less than container width", | ||||
|         width: 25, | ||||
|         res: `H | ||||
| e | ||||
| l | ||||
| l | ||||
| o | ||||
| w | ||||
| h | ||||
| a | ||||
| t | ||||
| s | ||||
| u | ||||
| p`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "break words as per the width", | ||||
|  | ||||
|         width: 140, | ||||
|         res: `Hello whats \nup`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "fit the container", | ||||
|  | ||||
|         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); | ||||
|         expect(res).toEqual(data.res); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe("When text contain new lines", () => { | ||||
|     const text = `Hello | ||||
| whats up`; | ||||
|     [ | ||||
|       { | ||||
|         desc: "break all words when width of each word is less than container width", | ||||
|         width: 80, | ||||
|         res: `Hello\nwhats \nup`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "break all characters when width of each character is less than container width", | ||||
|         width: 25, | ||||
|         res: `H | ||||
| e | ||||
| l | ||||
| l | ||||
| o | ||||
| w | ||||
| h | ||||
| a | ||||
| t | ||||
| s | ||||
| u | ||||
| p`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "break words as per the width", | ||||
|  | ||||
|         width: 150, | ||||
|         res: `Hello | ||||
| whats up`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "fit the container", | ||||
|  | ||||
|         width: 250, | ||||
|         res: `Hello | ||||
| whats up`, | ||||
|       }, | ||||
|     ].forEach((data) => { | ||||
|       it(`should respect new lines and ${data.desc}`, () => { | ||||
|         const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); | ||||
|         expect(res).toEqual(data.res); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe("When text is long", () => { | ||||
|     const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`; | ||||
|     [ | ||||
|       { | ||||
|         desc: "fit characters of long string as per container width", | ||||
|         width: 170, | ||||
|         res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         desc: "fit characters of long string as per container width and break words as per the width", | ||||
|  | ||||
|         width: 130, | ||||
|         res: `hellolongte | ||||
| xtthisiswha | ||||
| tsupwithyou | ||||
| Iamtypinggg | ||||
| ggandtyping | ||||
| gg break it | ||||
| now`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "fit the long text when container width is greater than text length and move the rest to next line", | ||||
|  | ||||
|         width: 600, | ||||
|         res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`, | ||||
|       }, | ||||
|     ].forEach((data) => { | ||||
|       it(`should ${data.desc}`, () => { | ||||
|         const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); | ||||
|         expect(res).toEqual(data.res); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it("should wrap the text correctly when word length is exactly equal to max width", () => { | ||||
|     const text = "Hello Excalidraw"; | ||||
|     // Length of "Excalidraw" is 100 and exacty equal to max width | ||||
|     const res = wrapText(text, font, 100); | ||||
|     expect(res).toEqual(`Hello \nExcalidraw`); | ||||
|   }); | ||||
|  | ||||
|   it("should return the text as is if max width is invalid", () => { | ||||
|     const text = "Hello Excalidraw"; | ||||
|     expect(wrapText(text, font, NaN)).toEqual(text); | ||||
|     expect(wrapText(text, font, -1)).toEqual(text); | ||||
|     expect(wrapText(text, font, Infinity)).toEqual(text); | ||||
|   }); | ||||
| }); | ||||
| import { ExcalidrawTextElementWithContainer } from "./types"; | ||||
|  | ||||
| describe("Test measureText", () => { | ||||
|   describe("Test getContainerCoords", () => { | ||||
| @@ -260,7 +82,7 @@ describe("Test measureText", () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe("Test getMaxContainerWidth", () => { | ||||
|   describe("Test getBoundTextMaxWidth", () => { | ||||
|     const params = { | ||||
|       width: 178, | ||||
|       height: 194, | ||||
| @@ -268,71 +90,84 @@ describe("Test measureText", () => { | ||||
|  | ||||
|     it("should return max width when container is rectangle", () => { | ||||
|       const container = API.createElement({ type: "rectangle", ...params }); | ||||
|       expect(getMaxContainerWidth(container)).toBe(168); | ||||
|       expect(getBoundTextMaxWidth(container)).toBe(168); | ||||
|     }); | ||||
|  | ||||
|     it("should return max width when container is ellipse", () => { | ||||
|       const container = API.createElement({ type: "ellipse", ...params }); | ||||
|       expect(getMaxContainerWidth(container)).toBe(116); | ||||
|       expect(getBoundTextMaxWidth(container)).toBe(116); | ||||
|     }); | ||||
|  | ||||
|     it("should return max width when container is diamond", () => { | ||||
|       const container = API.createElement({ type: "diamond", ...params }); | ||||
|       expect(getMaxContainerWidth(container)).toBe(79); | ||||
|       expect(getBoundTextMaxWidth(container)).toBe(79); | ||||
|     }); | ||||
|  | ||||
|     it("should return max width when container is arrow", () => { | ||||
|       const container = API.createElement({ | ||||
|         type: "arrow", | ||||
|         ...params, | ||||
|       }); | ||||
|       expect(getBoundTextMaxWidth(container)).toBe(220); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe("Test getMaxContainerHeight", () => { | ||||
|   describe("Test getBoundTextMaxHeight", () => { | ||||
|     const params = { | ||||
|       width: 178, | ||||
|       height: 194, | ||||
|       id: "container-id", | ||||
|     }; | ||||
|  | ||||
|     const boundTextElement = API.createElement({ | ||||
|       type: "text", | ||||
|       id: "text-id", | ||||
|       x: 560.51171875, | ||||
|       y: 202.033203125, | ||||
|       width: 154, | ||||
|       height: 175, | ||||
|       fontSize: 20, | ||||
|       fontFamily: 1, | ||||
|       text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams", | ||||
|       textAlign: "center", | ||||
|       verticalAlign: "middle", | ||||
|       containerId: params.id, | ||||
|     }) as ExcalidrawTextElementWithContainer; | ||||
|  | ||||
|     it("should return max height when container is rectangle", () => { | ||||
|       const container = API.createElement({ type: "rectangle", ...params }); | ||||
|       expect(getMaxContainerHeight(container)).toBe(184); | ||||
|       expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184); | ||||
|     }); | ||||
|  | ||||
|     it("should return max height when container is ellipse", () => { | ||||
|       const container = API.createElement({ type: "ellipse", ...params }); | ||||
|       expect(getMaxContainerHeight(container)).toBe(127); | ||||
|       expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127); | ||||
|     }); | ||||
|  | ||||
|     it("should return max height when container is diamond", () => { | ||||
|       const container = API.createElement({ type: "diamond", ...params }); | ||||
|       expect(getMaxContainerHeight(container)).toBe(87); | ||||
|       expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87); | ||||
|     }); | ||||
|  | ||||
|     it("should return max height when container is arrow", () => { | ||||
|       const container = API.createElement({ | ||||
|         type: "arrow", | ||||
|         ...params, | ||||
|       }); | ||||
|       expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194); | ||||
|     }); | ||||
|  | ||||
|     it("should return max height when container is arrow and height is less than threshold", () => { | ||||
|       const container = API.createElement({ | ||||
|         type: "arrow", | ||||
|         ...params, | ||||
|         height: 70, | ||||
|         boundElements: [{ type: "text", id: "text-id" }], | ||||
|       }); | ||||
|  | ||||
|       expect(getBoundTextMaxHeight(container, boundTextElement)).toBe( | ||||
|         boundTextElement.height, | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| const textElement = API.createElement({ | ||||
|   type: "text", | ||||
|   text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams", | ||||
|   fontSize: 20, | ||||
|   fontFamily: 1, | ||||
|   height: 175, | ||||
| }); | ||||
|  | ||||
| describe("Test detectLineHeight", () => { | ||||
|   it("should return correct line height", () => { | ||||
|     expect(detectLineHeight(textElement)).toBe(1.25); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| describe("Test getLineHeightInPx", () => { | ||||
|   it("should return correct line height", () => { | ||||
|     expect( | ||||
|       getLineHeightInPx(textElement.fontSize, textElement.lineHeight), | ||||
|     ).toBe(25); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| describe("Test getDefaultLineHeight", () => { | ||||
|   it("should return line height using default font family when not passed", () => { | ||||
|     //@ts-ignore | ||||
|     expect(getDefaultLineHeight()).toBe(1.25); | ||||
|   }); | ||||
|   it("should return correct line height", () => { | ||||
|     expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -1,22 +1,13 @@ | ||||
| import { getFontString, arrayToMap, isTestEnv } from "../utils"; | ||||
| import { getFontString, arrayToMap } from "../utils"; | ||||
| import { | ||||
|   ExcalidrawElement, | ||||
|   ExcalidrawTextContainer, | ||||
|   ExcalidrawTextElement, | ||||
|   ExcalidrawTextElementWithContainer, | ||||
|   FontFamilyValues, | ||||
|   FontString, | ||||
|   NonDeletedExcalidrawElement, | ||||
| } from "./types"; | ||||
| import { mutateElement } from "./mutateElement"; | ||||
| import { | ||||
|   BOUND_TEXT_PADDING, | ||||
|   DEFAULT_FONT_FAMILY, | ||||
|   DEFAULT_FONT_SIZE, | ||||
|   FONT_FAMILY, | ||||
|   TEXT_ALIGN, | ||||
|   VERTICAL_ALIGN, | ||||
| } from "../constants"; | ||||
| import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants"; | ||||
| import { MaybeTransformHandleType } from "./transformHandles"; | ||||
| import Scene from "../scene/Scene"; | ||||
| import { isTextElement } from "."; | ||||
| @@ -32,6 +23,7 @@ import { | ||||
|   updateOriginalContainerCache, | ||||
| } from "./textWysiwyg"; | ||||
| import { ExtractSetType } from "../utility-types"; | ||||
| import { measureText, wrapText } from "./textMeasurements"; | ||||
|  | ||||
| export const normalizeText = (text: string) => { | ||||
|   return ( | ||||
| @@ -43,10 +35,6 @@ export const normalizeText = (text: string) => { | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const splitIntoLines = (text: string) => { | ||||
|   return normalizeText(text).split("\n"); | ||||
| }; | ||||
|  | ||||
| export const redrawTextBoundingBox = ( | ||||
|   textElement: ExcalidrawTextElement, | ||||
|   container: ExcalidrawElement | null, | ||||
| @@ -60,16 +48,15 @@ export const redrawTextBoundingBox = ( | ||||
|     height: textElement.height, | ||||
|   }; | ||||
|  | ||||
|   boundTextUpdates.text = textElement.text; | ||||
|  | ||||
|   if (container) { | ||||
|     maxWidth = getMaxContainerWidth(container); | ||||
|     maxWidth = getBoundTextMaxWidth(container); | ||||
|     boundTextUpdates.text = wrapText( | ||||
|       textElement.originalText, | ||||
|       getFontString(textElement), | ||||
|       maxWidth, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const metrics = measureText( | ||||
|     boundTextUpdates.text, | ||||
|     getFontString(textElement), | ||||
| @@ -80,35 +67,29 @@ export const redrawTextBoundingBox = ( | ||||
|   boundTextUpdates.height = metrics.height; | ||||
|  | ||||
|   if (container) { | ||||
|     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; | ||||
|       boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2; | ||||
|       boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2; | ||||
|     } else { | ||||
|       const containerDims = getContainerDims(container); | ||||
|       let maxContainerHeight = getMaxContainerHeight(container); | ||||
|     const containerDims = getContainerDims(container); | ||||
|     const maxContainerHeight = getBoundTextMaxHeight( | ||||
|       container, | ||||
|       textElement as ExcalidrawTextElementWithContainer, | ||||
|     ); | ||||
|     let nextHeight = containerDims.height; | ||||
|  | ||||
|       let nextHeight = containerDims.height; | ||||
|       if (metrics.height > maxContainerHeight) { | ||||
|         nextHeight = computeContainerDimensionForBoundText( | ||||
|           metrics.height, | ||||
|           container.type, | ||||
|         ); | ||||
|         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; | ||||
|     if (metrics.height > maxContainerHeight) { | ||||
|       nextHeight = computeContainerDimensionForBoundText( | ||||
|         metrics.height, | ||||
|         container.type, | ||||
|       ); | ||||
|       mutateElement(container, { height: nextHeight }); | ||||
|       updateOriginalContainerCache(container.id, nextHeight); | ||||
|     } | ||||
|  | ||||
|     const updatedTextElement = { | ||||
|       ...textElement, | ||||
|       ...boundTextUpdates, | ||||
|     } as ExcalidrawTextElementWithContainer; | ||||
|     const { x, y } = computeBoundTextPosition(container, updatedTextElement); | ||||
|     boundTextUpdates.x = x; | ||||
|     boundTextUpdates.y = y; | ||||
|   } | ||||
|  | ||||
|   mutateElement(textElement, boundTextUpdates); | ||||
| @@ -180,8 +161,11 @@ export const handleBindTextResize = ( | ||||
|     let nextHeight = textElement.height; | ||||
|     let nextWidth = textElement.width; | ||||
|     const containerDims = getContainerDims(container); | ||||
|     const maxWidth = getMaxContainerWidth(container); | ||||
|     const maxHeight = getMaxContainerHeight(container); | ||||
|     const maxWidth = getBoundTextMaxWidth(container); | ||||
|     const maxHeight = getBoundTextMaxHeight( | ||||
|       container, | ||||
|       textElement as ExcalidrawTextElementWithContainer, | ||||
|     ); | ||||
|     let containerHeight = containerDims.height; | ||||
|     if (transformHandleType !== "n" && transformHandleType !== "s") { | ||||
|       if (text) { | ||||
| @@ -239,16 +223,22 @@ export const handleBindTextResize = ( | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const computeBoundTextPosition = ( | ||||
| export const computeBoundTextPosition = ( | ||||
|   container: ExcalidrawElement, | ||||
|   boundTextElement: ExcalidrawTextElementWithContainer, | ||||
| ) => { | ||||
|   const containerCoords = getContainerCoords(container); | ||||
|   const maxContainerHeight = getMaxContainerHeight(container); | ||||
|   const maxContainerWidth = getMaxContainerWidth(container); | ||||
|   const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement); | ||||
|   const maxContainerWidth = getBoundTextMaxWidth(container); | ||||
|  | ||||
|   let x; | ||||
|   let y; | ||||
|   if (isArrowElement(container)) { | ||||
|     return LinearElementEditor.getBoundTextElementPosition( | ||||
|       container, | ||||
|       boundTextElement, | ||||
|     ); | ||||
|   } | ||||
|   if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) { | ||||
|     y = containerCoords.y; | ||||
|   } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) { | ||||
| @@ -269,298 +259,6 @@ const computeBoundTextPosition = ( | ||||
|   return { x, y }; | ||||
| }; | ||||
|  | ||||
| // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js | ||||
|  | ||||
| export const measureText = ( | ||||
|   text: string, | ||||
|   font: FontString, | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   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 fontSize = parseFloat(font); | ||||
|   const height = getTextHeight(text, fontSize, lineHeight); | ||||
|   const width = getTextWidth(text, font); | ||||
|  | ||||
|   return { width, height }; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * To get unitless line-height (if unknown) we can calculate it by dividing | ||||
|  * height-per-line by fontSize. | ||||
|  */ | ||||
| export const detectLineHeight = (textElement: ExcalidrawTextElement) => { | ||||
|   const lineCount = splitIntoLines(textElement.text).length; | ||||
|   return (textElement.height / | ||||
|     lineCount / | ||||
|     textElement.fontSize) as ExcalidrawTextElement["lineHeight"]; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * We calculate the line height from the font size and the unitless line height, | ||||
|  * aligning with the W3C spec. | ||||
|  */ | ||||
| export const getLineHeightInPx = ( | ||||
|   fontSize: ExcalidrawTextElement["fontSize"], | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   return fontSize * lineHeight; | ||||
| }; | ||||
|  | ||||
| // FIXME rename to getApproxMinContainerHeight | ||||
| export const getApproxMinLineHeight = ( | ||||
|   fontSize: ExcalidrawTextElement["fontSize"], | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2; | ||||
| }; | ||||
|  | ||||
| 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; | ||||
|  | ||||
|   // 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 width * 10; | ||||
|   } | ||||
|   return width; | ||||
| }; | ||||
|  | ||||
| export const getTextWidth = (text: string, font: FontString) => { | ||||
|   const lines = splitIntoLines(text); | ||||
|   let width = 0; | ||||
|   lines.forEach((line) => { | ||||
|     width = Math.max(width, getLineWidth(line, font)); | ||||
|   }); | ||||
|   return width; | ||||
| }; | ||||
|  | ||||
| export const getTextHeight = ( | ||||
|   text: string, | ||||
|   fontSize: number, | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   const lineCount = splitIntoLines(text).length; | ||||
|   return getLineHeightInPx(fontSize, lineHeight) * lineCount; | ||||
| }; | ||||
|  | ||||
| export const wrapText = (text: string, font: FontString, maxWidth: number) => { | ||||
|   // if maxWidth is not finite or NaN which can happen in case of bugs in | ||||
|   // computation, we need to make sure we don't continue as we'll end up | ||||
|   // in an infinite loop | ||||
|   if (!Number.isFinite(maxWidth) || maxWidth < 0) { | ||||
|     return text; | ||||
|   } | ||||
|  | ||||
|   const lines: Array<string> = []; | ||||
|   const originalLines = text.split("\n"); | ||||
|   const spaceWidth = getLineWidth(" ", font); | ||||
|  | ||||
|   let currentLine = ""; | ||||
|   let currentLineWidthTillNow = 0; | ||||
|  | ||||
|   const push = (str: string) => { | ||||
|     if (str.trim()) { | ||||
|       lines.push(str); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const resetParams = () => { | ||||
|     currentLine = ""; | ||||
|     currentLineWidthTillNow = 0; | ||||
|   }; | ||||
|  | ||||
|   originalLines.forEach((originalLine) => { | ||||
|     const currentLineWidth = getTextWidth(originalLine, font); | ||||
|  | ||||
|     //Push the line if its <= maxWidth | ||||
|     if (currentLineWidth <= maxWidth) { | ||||
|       lines.push(originalLine); | ||||
|       return; // continue | ||||
|     } | ||||
|     const words = originalLine.split(" "); | ||||
|  | ||||
|     resetParams(); | ||||
|  | ||||
|     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 | ||||
|       else if (currentWordWidth > maxWidth) { | ||||
|         // push current line since the current word exceeds the max width | ||||
|         // so will be appended in next line | ||||
|         push(currentLine); | ||||
|  | ||||
|         resetParams(); | ||||
|  | ||||
|         while (words[index].length > 0) { | ||||
|           const currentChar = String.fromCodePoint( | ||||
|             words[index].codePointAt(0)!, | ||||
|           ); | ||||
|           const width = charWidth.calculate(currentChar, font); | ||||
|           currentLineWidthTillNow += width; | ||||
|           words[index] = words[index].slice(currentChar.length); | ||||
|  | ||||
|           if (currentLineWidthTillNow >= maxWidth) { | ||||
|             push(currentLine); | ||||
|             currentLine = currentChar; | ||||
|             currentLineWidthTillNow = width; | ||||
|           } else { | ||||
|             currentLine += currentChar; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         // push current line if appending space exceeds max width | ||||
|         if (currentLineWidthTillNow + spaceWidth >= maxWidth) { | ||||
|           push(currentLine); | ||||
|           resetParams(); | ||||
|         } else { | ||||
|           // space needs to be appended before next word | ||||
|           // as currentLine contains chars which couldn't be appended | ||||
|           // to previous line | ||||
|           currentLine += " "; | ||||
|           currentLineWidthTillNow += spaceWidth; | ||||
|         } | ||||
|         index++; | ||||
|       } else { | ||||
|         // Start appending words in a line till max width reached | ||||
|         while (currentLineWidthTillNow < maxWidth && index < words.length) { | ||||
|           const word = words[index]; | ||||
|           currentLineWidthTillNow = getLineWidth(currentLine + word, font); | ||||
|  | ||||
|           if (currentLineWidthTillNow > maxWidth) { | ||||
|             push(currentLine); | ||||
|             resetParams(); | ||||
|  | ||||
|             break; | ||||
|           } | ||||
|           index++; | ||||
|           currentLine += `${word} `; | ||||
|  | ||||
|           // Push the word if appending space exceeds max width | ||||
|           if (currentLineWidthTillNow + spaceWidth >= maxWidth) { | ||||
|             const word = currentLine.slice(0, -1); | ||||
|             push(word); | ||||
|             resetParams(); | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     if (currentLine.slice(-1) === " ") { | ||||
|       // only remove last trailing space which we have added when joining words | ||||
|       currentLine = currentLine.slice(0, -1); | ||||
|       push(currentLine); | ||||
|     } | ||||
|   }); | ||||
|   return lines.join("\n"); | ||||
| }; | ||||
|  | ||||
| export const charWidth = (() => { | ||||
|   const cachedCharWidth: { [key: FontString]: Array<number> } = {}; | ||||
|  | ||||
|   const calculate = (char: string, font: FontString) => { | ||||
|     const ascii = char.charCodeAt(0); | ||||
|     if (!cachedCharWidth[font]) { | ||||
|       cachedCharWidth[font] = []; | ||||
|     } | ||||
|     if (!cachedCharWidth[font][ascii]) { | ||||
|       const width = getLineWidth(char, font); | ||||
|       cachedCharWidth[font][ascii] = width; | ||||
|     } | ||||
|  | ||||
|     return cachedCharWidth[font][ascii]; | ||||
|   }; | ||||
|  | ||||
|   const getCache = (font: FontString) => { | ||||
|     return cachedCharWidth[font]; | ||||
|   }; | ||||
|   return { | ||||
|     calculate, | ||||
|     getCache, | ||||
|   }; | ||||
| })(); | ||||
|  | ||||
| const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); | ||||
|  | ||||
| // FIXME rename to getApproxMinContainerWidth | ||||
| export const getApproxMinLineWidth = ( | ||||
|   font: FontString, | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   const maxCharWidth = getMaxCharWidth(font); | ||||
|   if (maxCharWidth === 0) { | ||||
|     return ( | ||||
|       measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width + | ||||
|       BOUND_TEXT_PADDING * 2 | ||||
|     ); | ||||
|   } | ||||
|   return maxCharWidth + BOUND_TEXT_PADDING * 2; | ||||
| }; | ||||
|  | ||||
| export const getMinCharWidth = (font: FontString) => { | ||||
|   const cache = charWidth.getCache(font); | ||||
|   if (!cache) { | ||||
|     return 0; | ||||
|   } | ||||
|   const cacheWithOutEmpty = cache.filter((val) => val !== undefined); | ||||
|  | ||||
|   return Math.min(...cacheWithOutEmpty); | ||||
| }; | ||||
|  | ||||
| export const getMaxCharWidth = (font: FontString) => { | ||||
|   const cache = charWidth.getCache(font); | ||||
|   if (!cache) { | ||||
|     return 0; | ||||
|   } | ||||
|   const cacheWithOutEmpty = cache.filter((val) => val !== undefined); | ||||
|   return Math.max(...cacheWithOutEmpty); | ||||
| }; | ||||
|  | ||||
| export const getApproxCharsToFitInWidth = (font: FontString, width: number) => { | ||||
|   // Generally lower case is used so converting to lower case | ||||
|   const dummyText = DUMMY_TEXT.toLocaleLowerCase(); | ||||
|   const batchLength = 6; | ||||
|   let index = 0; | ||||
|   let widthTillNow = 0; | ||||
|   let str = ""; | ||||
|   while (widthTillNow <= width) { | ||||
|     const batch = dummyText.substr(index, index + batchLength); | ||||
|     str += batch; | ||||
|     widthTillNow += getLineWidth(str, font); | ||||
|     if (index === dummyText.length - 1) { | ||||
|       index = 0; | ||||
|     } | ||||
|     index = index + batchLength; | ||||
|   } | ||||
|  | ||||
|   while (widthTillNow > width) { | ||||
|     str = str.substr(0, str.length - 1); | ||||
|     widthTillNow = getLineWidth(str, font); | ||||
|   } | ||||
|   return str.length; | ||||
| }; | ||||
|  | ||||
| export const getBoundTextElementId = (container: ExcalidrawElement | null) => { | ||||
|   return container?.boundElements?.length | ||||
|     ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id || | ||||
| @@ -686,18 +384,6 @@ export const getBoundTextElementOffset = ( | ||||
|   return BOUND_TEXT_PADDING; | ||||
| }; | ||||
|  | ||||
| export const getBoundTextElementPosition = ( | ||||
|   container: ExcalidrawElement, | ||||
|   boundTextElement: ExcalidrawTextElementWithContainer, | ||||
| ) => { | ||||
|   if (isArrowElement(container)) { | ||||
|     return LinearElementEditor.getBoundTextElementPosition( | ||||
|       container, | ||||
|       boundTextElement, | ||||
|     ); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export const shouldAllowVerticalAlign = ( | ||||
|   selectedElements: NonDeletedExcalidrawElement[], | ||||
| ) => { | ||||
| @@ -798,18 +484,10 @@ export const computeContainerDimensionForBoundText = ( | ||||
|   return dimension + padding; | ||||
| }; | ||||
|  | ||||
| export const getMaxContainerWidth = (container: ExcalidrawElement) => { | ||||
| export const getBoundTextMaxWidth = (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 * 8 * 2; | ||||
|   } | ||||
|  | ||||
|   if (container.type === "ellipse") { | ||||
| @@ -826,16 +504,15 @@ export const getMaxContainerWidth = (container: ExcalidrawElement) => { | ||||
|   return width - BOUND_TEXT_PADDING * 2; | ||||
| }; | ||||
|  | ||||
| export const getMaxContainerHeight = (container: ExcalidrawElement) => { | ||||
| export const getBoundTextMaxHeight = ( | ||||
|   container: ExcalidrawElement, | ||||
|   boundTextElement: ExcalidrawTextElementWithContainer, | ||||
| ) => { | ||||
|   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 boundTextElement.height; | ||||
|     } | ||||
|     return height; | ||||
|   } | ||||
| @@ -852,43 +529,3 @@ export const getMaxContainerHeight = (container: ExcalidrawElement) => { | ||||
|   } | ||||
|   return height - BOUND_TEXT_PADDING * 2; | ||||
| }; | ||||
|  | ||||
| export const isMeasureTextSupported = () => { | ||||
|   const width = getTextWidth( | ||||
|     DUMMY_TEXT, | ||||
|     getFontString({ | ||||
|       fontSize: DEFAULT_FONT_SIZE, | ||||
|       fontFamily: DEFAULT_FONT_FAMILY, | ||||
|     }), | ||||
|   ); | ||||
|   return width > 0; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Unitless line height | ||||
|  * | ||||
|  * In previous versions we used `normal` line height, which browsers interpret | ||||
|  * differently, and based on font-family and font-size. | ||||
|  * | ||||
|  * To make line heights consistent across browsers we hardcode the values for | ||||
|  * each of our fonts based on most common average line-heights. | ||||
|  * See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 | ||||
|  * where the values come from. | ||||
|  */ | ||||
| const DEFAULT_LINE_HEIGHT = { | ||||
|   // ~1.25 is the average for Virgil in WebKit and Blink. | ||||
|   // Gecko (FF) uses ~1.28. | ||||
|   [FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"], | ||||
|   // ~1.15 is the average for Virgil in WebKit and Blink. | ||||
|   // Gecko if all over the place. | ||||
|   [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"], | ||||
|   // ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too | ||||
|   [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], | ||||
| }; | ||||
|  | ||||
| export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { | ||||
|   if (fontFamily) { | ||||
|     return DEFAULT_LINE_HEIGHT[fontFamily]; | ||||
|   } | ||||
|   return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY]; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										213
									
								
								src/element/textMeasurements.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								src/element/textMeasurements.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,213 @@ | ||||
| import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; | ||||
| import { API } from "../tests/helpers/api"; | ||||
| import { | ||||
|   detectLineHeight, | ||||
|   getDefaultLineHeight, | ||||
|   getLineHeightInPx, | ||||
|   wrapText, | ||||
| } from "./textMeasurements"; | ||||
| import { FontString } from "./types"; | ||||
|  | ||||
| describe("Test wrapText", () => { | ||||
|   const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; | ||||
|  | ||||
|   it("shouldn't add new lines for trailing spaces", () => { | ||||
|     const text = "Hello whats up     "; | ||||
|     const maxWidth = 200 - BOUND_TEXT_PADDING * 2; | ||||
|     const res = wrapText(text, font, maxWidth); | ||||
|     expect(res).toBe(text); | ||||
|   }); | ||||
|  | ||||
|   it("should work with emojis", () => { | ||||
|     const text = "😀"; | ||||
|     const maxWidth = 1; | ||||
|     const res = wrapText(text, font, maxWidth); | ||||
|     expect(res).toBe("😀"); | ||||
|   }); | ||||
|  | ||||
|   it("should show the text correctly when max width reached", () => { | ||||
|     const text = "Hello😀"; | ||||
|     const maxWidth = 10; | ||||
|     const res = wrapText(text, font, maxWidth); | ||||
|     expect(res).toBe("H\ne\nl\nl\no\n😀"); | ||||
|   }); | ||||
|  | ||||
|   describe("When text doesn't contain new lines", () => { | ||||
|     const text = "Hello whats up"; | ||||
|  | ||||
|     [ | ||||
|       { | ||||
|         desc: "break all words when width of each word is less than container width", | ||||
|         width: 80, | ||||
|         res: `Hello \nwhats \nup`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "break all characters when width of each character is less than container width", | ||||
|         width: 25, | ||||
|         res: `H | ||||
| e | ||||
| l | ||||
| l | ||||
| o | ||||
| w | ||||
| h | ||||
| a | ||||
| t | ||||
| s | ||||
| u | ||||
| p`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "break words as per the width", | ||||
|  | ||||
|         width: 140, | ||||
|         res: `Hello whats \nup`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "fit the container", | ||||
|  | ||||
|         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); | ||||
|         expect(res).toEqual(data.res); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe("When text contain new lines", () => { | ||||
|     const text = `Hello | ||||
| whats up`; | ||||
|     [ | ||||
|       { | ||||
|         desc: "break all words when width of each word is less than container width", | ||||
|         width: 80, | ||||
|         res: `Hello\nwhats \nup`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "break all characters when width of each character is less than container width", | ||||
|         width: 25, | ||||
|         res: `H | ||||
| e | ||||
| l | ||||
| l | ||||
| o | ||||
| w | ||||
| h | ||||
| a | ||||
| t | ||||
| s | ||||
| u | ||||
| p`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "break words as per the width", | ||||
|  | ||||
|         width: 150, | ||||
|         res: `Hello | ||||
| whats up`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "fit the container", | ||||
|  | ||||
|         width: 250, | ||||
|         res: `Hello | ||||
| whats up`, | ||||
|       }, | ||||
|     ].forEach((data) => { | ||||
|       it(`should respect new lines and ${data.desc}`, () => { | ||||
|         const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); | ||||
|         expect(res).toEqual(data.res); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe("When text is long", () => { | ||||
|     const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`; | ||||
|     [ | ||||
|       { | ||||
|         desc: "fit characters of long string as per container width", | ||||
|         width: 170, | ||||
|         res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         desc: "fit characters of long string as per container width and break words as per the width", | ||||
|  | ||||
|         width: 130, | ||||
|         res: `hellolongte | ||||
| xtthisiswha | ||||
| tsupwithyou | ||||
| Iamtypinggg | ||||
| ggandtyping | ||||
| gg break it | ||||
| now`, | ||||
|       }, | ||||
|       { | ||||
|         desc: "fit the long text when container width is greater than text length and move the rest to next line", | ||||
|  | ||||
|         width: 600, | ||||
|         res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`, | ||||
|       }, | ||||
|     ].forEach((data) => { | ||||
|       it(`should ${data.desc}`, () => { | ||||
|         const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2); | ||||
|         expect(res).toEqual(data.res); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it("should wrap the text correctly when word length is exactly equal to max width", () => { | ||||
|     const text = "Hello Excalidraw"; | ||||
|     // Length of "Excalidraw" is 100 and exacty equal to max width | ||||
|     const res = wrapText(text, font, 100); | ||||
|     expect(res).toEqual(`Hello \nExcalidraw`); | ||||
|   }); | ||||
|  | ||||
|   it("should return the text as is if max width is invalid", () => { | ||||
|     const text = "Hello Excalidraw"; | ||||
|     expect(wrapText(text, font, NaN)).toEqual(text); | ||||
|     expect(wrapText(text, font, -1)).toEqual(text); | ||||
|     expect(wrapText(text, font, Infinity)).toEqual(text); | ||||
|   }); | ||||
| }); | ||||
| const textElement = API.createElement({ | ||||
|   type: "text", | ||||
|   text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams", | ||||
|   fontSize: 20, | ||||
|   fontFamily: 1, | ||||
|   height: 175, | ||||
| }); | ||||
|  | ||||
| describe("Test detectLineHeight", () => { | ||||
|   it("should return correct line height", () => { | ||||
|     expect(detectLineHeight(textElement)).toBe(1.25); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| describe("Test getLineHeightInPx", () => { | ||||
|   it("should return correct line height", () => { | ||||
|     expect( | ||||
|       getLineHeightInPx(textElement.fontSize, textElement.lineHeight), | ||||
|     ).toBe(25); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| describe("Test getDefaultLineHeight", () => { | ||||
|   it("should return line height using default font family when not passed", () => { | ||||
|     //@ts-ignore | ||||
|     expect(getDefaultLineHeight()).toBe(1.25); | ||||
|   }); | ||||
|   it("should return correct line height", () => { | ||||
|     expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										339
									
								
								src/element/textMeasurements.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										339
									
								
								src/element/textMeasurements.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,339 @@ | ||||
| import { | ||||
|   BOUND_TEXT_PADDING, | ||||
|   DEFAULT_FONT_FAMILY, | ||||
|   DEFAULT_FONT_SIZE, | ||||
|   FONT_FAMILY, | ||||
| } from "../constants"; | ||||
| import { getFontString, isTestEnv } from "../utils"; | ||||
| import { normalizeText } from "./textElement"; | ||||
| import { ExcalidrawTextElement, FontFamilyValues, FontString } from "./types"; | ||||
|  | ||||
| const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); | ||||
|  | ||||
| let canvas: HTMLCanvasElement | undefined; | ||||
|  | ||||
| // since in test env the canvas measureText algo | ||||
| // doesn't measure text and instead just returns number of | ||||
| // characters hence we assume that each letter is 10px | ||||
| const DUMMY_CHAR_WIDTH = 10; | ||||
|  | ||||
| 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; | ||||
|  | ||||
|   /* istanbul ignore else */ | ||||
|   if (isTestEnv()) { | ||||
|     return width * DUMMY_CHAR_WIDTH; | ||||
|   } | ||||
|   /* istanbul ignore next */ | ||||
|   return width; | ||||
| }; | ||||
|  | ||||
| export const getTextWidth = (text: string, font: FontString) => { | ||||
|   const lines = splitIntoLines(text); | ||||
|   let width = 0; | ||||
|   lines.forEach((line) => { | ||||
|     width = Math.max(width, getLineWidth(line, font)); | ||||
|   }); | ||||
|   return width; | ||||
| }; | ||||
|  | ||||
| export const getTextHeight = ( | ||||
|   text: string, | ||||
|   fontSize: number, | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   const lineCount = splitIntoLines(text).length; | ||||
|   return getLineHeightInPx(fontSize, lineHeight) * lineCount; | ||||
| }; | ||||
|  | ||||
| export const splitIntoLines = (text: string) => { | ||||
|   return normalizeText(text).split("\n"); | ||||
| }; | ||||
|  | ||||
| export const measureText = ( | ||||
|   text: string, | ||||
|   font: FontString, | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   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 fontSize = parseFloat(font); | ||||
|   const height = getTextHeight(text, fontSize, lineHeight); | ||||
|   const width = getTextWidth(text, font); | ||||
|  | ||||
|   return { width, height }; | ||||
| }; | ||||
|  | ||||
| export const getApproxMinContainerWidth = ( | ||||
|   font: FontString, | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   const maxCharWidth = getMaxCharWidth(font); | ||||
|   if (maxCharWidth === 0) { | ||||
|     return ( | ||||
|       measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width + | ||||
|       BOUND_TEXT_PADDING * 2 | ||||
|     ); | ||||
|   } | ||||
|   return maxCharWidth + BOUND_TEXT_PADDING * 2; | ||||
| }; | ||||
|  | ||||
| export const getApproxMinContainerHeight = ( | ||||
|   fontSize: ExcalidrawTextElement["fontSize"], | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2; | ||||
| }; | ||||
|  | ||||
| export const charWidth = (() => { | ||||
|   const cachedCharWidth: { [key: FontString]: Array<number> } = {}; | ||||
|  | ||||
|   const calculate = (char: string, font: FontString) => { | ||||
|     const ascii = char.charCodeAt(0); | ||||
|     if (!cachedCharWidth[font]) { | ||||
|       cachedCharWidth[font] = []; | ||||
|     } | ||||
|     if (!cachedCharWidth[font][ascii]) { | ||||
|       const width = getLineWidth(char, font); | ||||
|       cachedCharWidth[font][ascii] = width; | ||||
|     } | ||||
|  | ||||
|     return cachedCharWidth[font][ascii]; | ||||
|   }; | ||||
|  | ||||
|   const getCache = (font: FontString) => { | ||||
|     return cachedCharWidth[font]; | ||||
|   }; | ||||
|   return { | ||||
|     calculate, | ||||
|     getCache, | ||||
|   }; | ||||
| })(); | ||||
|  | ||||
| export const getMaxCharWidth = (font: FontString) => { | ||||
|   const cache = charWidth.getCache(font); | ||||
|   if (!cache) { | ||||
|     return 0; | ||||
|   } | ||||
|   const cacheWithOutEmpty = cache.filter((val) => val !== undefined); | ||||
|   return Math.max(...cacheWithOutEmpty); | ||||
| }; | ||||
|  | ||||
| /** this is not used currently but might be useful | ||||
|  * in future hence keeping it | ||||
|  */ | ||||
| /* istanbul ignore next */ | ||||
| export const getApproxCharsToFitInWidth = (font: FontString, width: number) => { | ||||
|   // Generally lower case is used so converting to lower case | ||||
|   const dummyText = DUMMY_TEXT.toLocaleLowerCase(); | ||||
|   const batchLength = 6; | ||||
|   let index = 0; | ||||
|   let widthTillNow = 0; | ||||
|   let str = ""; | ||||
|   while (widthTillNow <= width) { | ||||
|     const batch = dummyText.substr(index, index + batchLength); | ||||
|     str += batch; | ||||
|     widthTillNow += getLineWidth(str, font); | ||||
|     if (index === dummyText.length - 1) { | ||||
|       index = 0; | ||||
|     } | ||||
|     index = index + batchLength; | ||||
|   } | ||||
|  | ||||
|   while (widthTillNow > width) { | ||||
|     str = str.substr(0, str.length - 1); | ||||
|     widthTillNow = getLineWidth(str, font); | ||||
|   } | ||||
|   return str.length; | ||||
| }; | ||||
|  | ||||
| export const wrapText = (text: string, font: FontString, maxWidth: number) => { | ||||
|   // if maxWidth is not finite or NaN which can happen in case of bugs in | ||||
|   // computation, we need to make sure we don't continue as we'll end up | ||||
|   // in an infinite loop | ||||
|   if (!Number.isFinite(maxWidth) || maxWidth < 0) { | ||||
|     return text; | ||||
|   } | ||||
|  | ||||
|   const lines: Array<string> = []; | ||||
|   const originalLines = text.split("\n"); | ||||
|   const spaceWidth = getLineWidth(" ", font); | ||||
|  | ||||
|   let currentLine = ""; | ||||
|   let currentLineWidthTillNow = 0; | ||||
|  | ||||
|   const push = (str: string) => { | ||||
|     if (str.trim()) { | ||||
|       lines.push(str); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const resetParams = () => { | ||||
|     currentLine = ""; | ||||
|     currentLineWidthTillNow = 0; | ||||
|   }; | ||||
|  | ||||
|   originalLines.forEach((originalLine) => { | ||||
|     const currentLineWidth = getTextWidth(originalLine, font); | ||||
|  | ||||
|     //Push the line if its <= maxWidth | ||||
|     if (currentLineWidth <= maxWidth) { | ||||
|       lines.push(originalLine); | ||||
|       return; // continue | ||||
|     } | ||||
|     const words = originalLine.split(" "); | ||||
|  | ||||
|     resetParams(); | ||||
|  | ||||
|     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 | ||||
|       else if (currentWordWidth > maxWidth) { | ||||
|         // push current line since the current word exceeds the max width | ||||
|         // so will be appended in next line | ||||
|         push(currentLine); | ||||
|  | ||||
|         resetParams(); | ||||
|  | ||||
|         while (words[index].length > 0) { | ||||
|           const currentChar = String.fromCodePoint( | ||||
|             words[index].codePointAt(0)!, | ||||
|           ); | ||||
|           const width = charWidth.calculate(currentChar, font); | ||||
|           currentLineWidthTillNow += width; | ||||
|           words[index] = words[index].slice(currentChar.length); | ||||
|  | ||||
|           if (currentLineWidthTillNow >= maxWidth) { | ||||
|             push(currentLine); | ||||
|             currentLine = currentChar; | ||||
|             currentLineWidthTillNow = width; | ||||
|           } else { | ||||
|             currentLine += currentChar; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         // push current line if appending space exceeds max width | ||||
|         if (currentLineWidthTillNow + spaceWidth >= maxWidth) { | ||||
|           push(currentLine); | ||||
|           resetParams(); | ||||
|         } else { | ||||
|           // space needs to be appended before next word | ||||
|           // as currentLine contains chars which couldn't be appended | ||||
|           // to previous line | ||||
|           currentLine += " "; | ||||
|           currentLineWidthTillNow += spaceWidth; | ||||
|         } | ||||
|         index++; | ||||
|       } else { | ||||
|         // Start appending words in a line till max width reached | ||||
|         while (currentLineWidthTillNow < maxWidth && index < words.length) { | ||||
|           const word = words[index]; | ||||
|           currentLineWidthTillNow = getLineWidth(currentLine + word, font); | ||||
|  | ||||
|           if (currentLineWidthTillNow > maxWidth) { | ||||
|             push(currentLine); | ||||
|             resetParams(); | ||||
|  | ||||
|             break; | ||||
|           } | ||||
|           index++; | ||||
|           currentLine += `${word} `; | ||||
|  | ||||
|           // Push the word if appending space exceeds max width | ||||
|           if (currentLineWidthTillNow + spaceWidth >= maxWidth) { | ||||
|             const word = currentLine.slice(0, -1); | ||||
|             push(word); | ||||
|             resetParams(); | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     if (currentLine.slice(-1) === " ") { | ||||
|       // only remove last trailing space which we have added when joining words | ||||
|       currentLine = currentLine.slice(0, -1); | ||||
|       push(currentLine); | ||||
|     } | ||||
|   }); | ||||
|   return lines.join("\n"); | ||||
| }; | ||||
|  | ||||
| export const isMeasureTextSupported = () => { | ||||
|   const width = getTextWidth( | ||||
|     DUMMY_TEXT, | ||||
|     getFontString({ | ||||
|       fontSize: DEFAULT_FONT_SIZE, | ||||
|       fontFamily: DEFAULT_FONT_FAMILY, | ||||
|     }), | ||||
|   ); | ||||
|   return width > 0; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * We calculate the line height from the font size and the unitless line height, | ||||
|  * aligning with the W3C spec. | ||||
|  */ | ||||
| export const getLineHeightInPx = ( | ||||
|   fontSize: ExcalidrawTextElement["fontSize"], | ||||
|   lineHeight: ExcalidrawTextElement["lineHeight"], | ||||
| ) => { | ||||
|   return fontSize * lineHeight; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * To get unitless line-height (if unknown) we can calculate it by dividing | ||||
|  * height-per-line by fontSize. | ||||
|  */ | ||||
| export const detectLineHeight = (textElement: ExcalidrawTextElement) => { | ||||
|   const lineCount = splitIntoLines(textElement.text).length; | ||||
|   return (textElement.height / | ||||
|     lineCount / | ||||
|     textElement.fontSize) as ExcalidrawTextElement["lineHeight"]; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Unitless line height | ||||
|  * | ||||
|  * In previous versions we used `normal` line height, which browsers interpret | ||||
|  * differently, and based on font-family and font-size. | ||||
|  * | ||||
|  * To make line heights consistent across browsers we hardcode the values for | ||||
|  * each of our fonts based on most common average line-heights. | ||||
|  * See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971 | ||||
|  * where the values come from. | ||||
|  */ | ||||
| const DEFAULT_LINE_HEIGHT = { | ||||
|   // ~1.25 is the average for Virgil in WebKit and Blink. | ||||
|   // Gecko (FF) uses ~1.28. | ||||
|   [FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"], | ||||
|   // ~1.15 is the average for Helvetica in WebKit and Blink. | ||||
|   // Gecko if all over the place. | ||||
|   [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"], | ||||
|   // ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too | ||||
|   [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], | ||||
| }; | ||||
|  | ||||
| export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { | ||||
|   if (fontFamily) { | ||||
|     return DEFAULT_LINE_HEIGHT[fontFamily]; | ||||
|   } | ||||
|   return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY]; | ||||
| }; | ||||
| @@ -11,7 +11,7 @@ import { | ||||
|   isBoundToContainer, | ||||
|   isTextElement, | ||||
| } from "./typeChecks"; | ||||
| import { CLASSES, VERTICAL_ALIGN } from "../constants"; | ||||
| import { CLASSES } from "../constants"; | ||||
| import { | ||||
|   ExcalidrawElement, | ||||
|   ExcalidrawLinearElement, | ||||
| @@ -23,17 +23,14 @@ import { AppState } from "../types"; | ||||
| import { mutateElement } from "./mutateElement"; | ||||
| import { | ||||
|   getBoundTextElementId, | ||||
|   getContainerCoords, | ||||
|   getContainerDims, | ||||
|   getContainerElement, | ||||
|   getTextElementAngle, | ||||
|   getTextWidth, | ||||
|   measureText, | ||||
|   normalizeText, | ||||
|   redrawTextBoundingBox, | ||||
|   wrapText, | ||||
|   getMaxContainerHeight, | ||||
|   getMaxContainerWidth, | ||||
|   getBoundTextMaxHeight, | ||||
|   getBoundTextMaxWidth, | ||||
|   computeBoundTextPosition, | ||||
| } from "./textElement"; | ||||
| import { | ||||
|   actionDecreaseFontSize, | ||||
| @@ -43,6 +40,12 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; | ||||
| import App from "../components/App"; | ||||
| import { LinearElementEditor } from "./linearElementEditor"; | ||||
| import { parseClipboard } from "../clipboard"; | ||||
| import { | ||||
|   getTextWidth, | ||||
|   measureText, | ||||
|   wrapText, | ||||
|   getTextHeight, | ||||
| } from "./textMeasurements"; | ||||
|  | ||||
| const getTransform = ( | ||||
|   width: number, | ||||
| @@ -177,15 +180,12 @@ export const textWysiwyg = ({ | ||||
|           editable, | ||||
|         ); | ||||
|         const containerDims = getContainerDims(container); | ||||
|         // using editor.style.height to get the accurate height of text editor | ||||
|         const editorHeight = Number(editable.style.height.slice(0, -2)); | ||||
|         if (editorHeight > 0) { | ||||
|           textElementHeight = editorHeight; | ||||
|         } | ||||
|         if (propertiesUpdated) { | ||||
|           // update height of the editor after properties updated | ||||
|           textElementHeight = updatedTextElement.height; | ||||
|         } | ||||
|  | ||||
|         textElementHeight = getTextHeight( | ||||
|           updatedTextElement.text, | ||||
|           updatedTextElement.fontSize, | ||||
|           updatedTextElement.lineHeight, | ||||
|         ); | ||||
|  | ||||
|         let originalContainerData; | ||||
|         if (propertiesUpdated) { | ||||
| @@ -203,8 +203,11 @@ export const textWysiwyg = ({ | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         maxWidth = getMaxContainerWidth(container); | ||||
|         maxHeight = getMaxContainerHeight(container); | ||||
|         maxWidth = getBoundTextMaxWidth(container); | ||||
|         maxHeight = getBoundTextMaxHeight( | ||||
|           container, | ||||
|           updatedTextElement as ExcalidrawTextElementWithContainer, | ||||
|         ); | ||||
|  | ||||
|         // autogrow container height if text exceeds | ||||
|         if (!isArrowElement(container) && textElementHeight > maxHeight) { | ||||
| @@ -226,22 +229,12 @@ export const textWysiwyg = ({ | ||||
|             element.lineHeight, | ||||
|           ); | ||||
|           mutateElement(container, { height: containerDims.height - diff }); | ||||
|         } | ||||
|         // 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 = | ||||
|                 containerCoords.y + maxHeight / 2 - textElementHeight / 2; | ||||
|             } | ||||
|           } | ||||
|           if (verticalAlign === VERTICAL_ALIGN.BOTTOM) { | ||||
|             coordY = containerCoords.y + (maxHeight - textElementHeight); | ||||
|           } | ||||
|         } else { | ||||
|           const { y } = computeBoundTextPosition( | ||||
|             container, | ||||
|             updatedTextElement as ExcalidrawTextElementWithContainer, | ||||
|           ); | ||||
|           coordY = y; | ||||
|         } | ||||
|       } | ||||
|       const [viewportX, viewportY] = getViewportCoords(coordX, coordY); | ||||
| @@ -362,7 +355,7 @@ export const textWysiwyg = ({ | ||||
|         const wrappedText = wrapText( | ||||
|           `${editable.value}${data}`, | ||||
|           font, | ||||
|           getMaxContainerWidth(container), | ||||
|           getBoundTextMaxWidth(container), | ||||
|         ); | ||||
|         const width = getTextWidth(wrappedText, font); | ||||
|         editable.style.width = `${width}px`; | ||||
| @@ -379,7 +372,7 @@ export const textWysiwyg = ({ | ||||
|         const wrappedText = wrapText( | ||||
|           normalizeText(editable.value), | ||||
|           font, | ||||
|           getMaxContainerWidth(container!), | ||||
|           getBoundTextMaxWidth(container!), | ||||
|         ); | ||||
|         const { width, height } = measureText( | ||||
|           wrappedText, | ||||
|   | ||||
| @@ -43,11 +43,11 @@ import { | ||||
|   getBoundTextElement, | ||||
|   getContainerCoords, | ||||
|   getContainerElement, | ||||
|   getLineHeightInPx, | ||||
|   getMaxContainerHeight, | ||||
|   getMaxContainerWidth, | ||||
|   getBoundTextMaxHeight, | ||||
|   getBoundTextMaxWidth, | ||||
| } from "../element/textElement"; | ||||
| import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| import { getLineHeightInPx } from "../element/textMeasurements"; | ||||
|  | ||||
| // using a stronger invert (100% vs our regular 93%) and saturate | ||||
| // as a temp hack to make images in dark theme look closer to original | ||||
| @@ -279,7 +279,6 @@ const drawElementOnCanvas = ( | ||||
|  | ||||
|         // Canvas does not support multiline text by default | ||||
|         const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); | ||||
|  | ||||
|         const horizontalOffset = | ||||
|           element.textAlign === "center" | ||||
|             ? element.width / 2 | ||||
| @@ -823,14 +822,17 @@ const drawElementFromCanvas = ( | ||||
|       process.env.REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX && | ||||
|       hasBoundTextElement(element) | ||||
|     ) { | ||||
|       const textElement = getBoundTextElement( | ||||
|         element, | ||||
|       ) as ExcalidrawTextElementWithContainer; | ||||
|       const coords = getContainerCoords(element); | ||||
|       context.strokeStyle = "#c92a2a"; | ||||
|       context.lineWidth = 3; | ||||
|       context.strokeRect( | ||||
|         (coords.x + renderConfig.scrollX) * window.devicePixelRatio, | ||||
|         (coords.y + renderConfig.scrollY) * window.devicePixelRatio, | ||||
|         getMaxContainerWidth(element) * window.devicePixelRatio, | ||||
|         getMaxContainerHeight(element) * window.devicePixelRatio, | ||||
|         getBoundTextMaxWidth(element) * window.devicePixelRatio, | ||||
|         getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -3,12 +3,13 @@ import { render, waitFor, GlobalTestState } from "./test-utils"; | ||||
| import { Pointer, Keyboard } from "./helpers/ui"; | ||||
| import ExcalidrawApp from "../excalidraw-app"; | ||||
| import { KEYS } from "../keys"; | ||||
|  | ||||
| import { getElementBounds } from "../element"; | ||||
| import { NormalizedZoomValue } from "../types"; | ||||
| import { | ||||
|   getDefaultLineHeight, | ||||
|   getLineHeightInPx, | ||||
| } from "../element/textElement"; | ||||
| import { getElementBounds } from "../element"; | ||||
| import { NormalizedZoomValue } from "../types"; | ||||
| } from "../element/textMeasurements"; | ||||
|  | ||||
| const { h } = window; | ||||
|  | ||||
|   | ||||
| @@ -17,11 +17,8 @@ import { KEYS } from "../keys"; | ||||
| import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| import { queryByTestId, queryByText } from "@testing-library/react"; | ||||
| import { resize, rotate } from "./utils"; | ||||
| import { | ||||
|   getBoundTextElementPosition, | ||||
|   wrapText, | ||||
|   getMaxContainerWidth, | ||||
| } from "../element/textElement"; | ||||
| import { getBoundTextMaxWidth } from "../element/textElement"; | ||||
| import { wrapText } from "../element/textMeasurements"; | ||||
| import * as textElementUtils from "../element/textElement"; | ||||
| import { ROUNDNESS } from "../constants"; | ||||
|  | ||||
| @@ -729,7 +726,7 @@ describe("Test Linear Elements", () => { | ||||
|         type: "text", | ||||
|         x: 0, | ||||
|         y: 0, | ||||
|         text: wrapText(text, font, getMaxContainerWidth(container)), | ||||
|         text: wrapText(text, font, getBoundTextMaxWidth(container)), | ||||
|         containerId: container.id, | ||||
|         width: 30, | ||||
|         height: 20, | ||||
| @@ -937,8 +934,9 @@ describe("Test Linear Elements", () => { | ||||
|  | ||||
|       expect(container.angle).toBe(0); | ||||
|       expect(textElement.angle).toBe(0); | ||||
|       expect(getBoundTextElementPosition(arrow, textElement)) | ||||
|         .toMatchInlineSnapshot(` | ||||
|       expect( | ||||
|         LinearElementEditor.getBoundTextElementPosition(arrow, textElement), | ||||
|       ).toMatchInlineSnapshot(` | ||||
|         Object { | ||||
|           "x": 75, | ||||
|           "y": 60, | ||||
| @@ -964,8 +962,9 @@ describe("Test Linear Elements", () => { | ||||
|       rotate(container, -35, 55); | ||||
|       expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`); | ||||
|       expect(textElement.angle).toBe(0); | ||||
|       expect(getBoundTextElementPosition(container, textElement)) | ||||
|         .toMatchInlineSnapshot(` | ||||
|       expect( | ||||
|         LinearElementEditor.getBoundTextElementPosition(container, textElement), | ||||
|       ).toMatchInlineSnapshot(` | ||||
|         Object { | ||||
|           "x": 21.73926141863671, | ||||
|           "y": 73.31003398390868, | ||||
| @@ -1002,8 +1001,9 @@ describe("Test Linear Elements", () => { | ||||
|       ); | ||||
|       expect(container.width).toBe(70); | ||||
|       expect(container.height).toBe(50); | ||||
|       expect(getBoundTextElementPosition(container, textElement)) | ||||
|         .toMatchInlineSnapshot(` | ||||
|       expect( | ||||
|         LinearElementEditor.getBoundTextElementPosition(container, textElement), | ||||
|       ).toMatchInlineSnapshot(` | ||||
|         Object { | ||||
|           "x": 75, | ||||
|           "y": 60, | ||||
| @@ -1036,8 +1036,9 @@ describe("Test Linear Elements", () => { | ||||
|         } | ||||
|       `); | ||||
|  | ||||
|       expect(getBoundTextElementPosition(container, textElement)) | ||||
|         .toMatchInlineSnapshot(` | ||||
|       expect( | ||||
|         LinearElementEditor.getBoundTextElementPosition(container, textElement), | ||||
|       ).toMatchInlineSnapshot(` | ||||
|         Object { | ||||
|           "x": 272, | ||||
|           "y": 45, | ||||
| @@ -1070,8 +1071,9 @@ describe("Test Linear Elements", () => { | ||||
|         arrow, | ||||
|       ); | ||||
|       expect(container.width).toBe(40); | ||||
|       expect(getBoundTextElementPosition(container, textElement)) | ||||
|         .toMatchInlineSnapshot(` | ||||
|       expect( | ||||
|         LinearElementEditor.getBoundTextElementPosition(container, textElement), | ||||
|       ).toMatchInlineSnapshot(` | ||||
|         Object { | ||||
|           "x": 25, | ||||
|           "y": 10, | ||||
| @@ -1095,8 +1097,9 @@ describe("Test Linear Elements", () => { | ||||
|         } | ||||
|       `); | ||||
|  | ||||
|       expect(getBoundTextElementPosition(container, textElement)) | ||||
|         .toMatchInlineSnapshot(` | ||||
|       expect( | ||||
|         LinearElementEditor.getBoundTextElementPosition(container, textElement), | ||||
|       ).toMatchInlineSnapshot(` | ||||
|         Object { | ||||
|           "x": 75, | ||||
|           "y": -5, | ||||
| @@ -1149,7 +1152,7 @@ describe("Test Linear Elements", () => { | ||||
|       expect(rect.x).toBe(400); | ||||
|       expect(rect.y).toBe(0); | ||||
|       expect( | ||||
|         wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)), | ||||
|         wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)), | ||||
|       ).toMatchInlineSnapshot(` | ||||
|         "Online whiteboard collaboration | ||||
|         made easy" | ||||
| @@ -1172,7 +1175,7 @@ describe("Test Linear Elements", () => { | ||||
|         false, | ||||
|       ); | ||||
|       expect( | ||||
|         wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)), | ||||
|         wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)), | ||||
|       ).toMatchInlineSnapshot(` | ||||
|         "Online whiteboard  | ||||
|         collaboration made  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user