import { getFontString, arrayToMap } from "../utils"; import { ExcalidrawElement, ExcalidrawTextContainer, ExcalidrawTextElement, ExcalidrawTextElementWithContainer, NonDeletedExcalidrawElement, } from "./types"; import { mutateElement } from "./mutateElement"; import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants"; import { MaybeTransformHandleType } from "./transformHandles"; import Scene from "../scene/Scene"; import { isTextElement } from "."; import { isBoundToContainer, isArrowElement } from "./typeChecks"; import { LinearElementEditor } from "./linearElementEditor"; import { AppState } from "../types"; import { isTextBindableContainer } from "./typeChecks"; import { getElementAbsoluteCoords } from "../element"; import { getSelectedElements } from "../scene"; import { isHittingElementNotConsideringBoundingBox } from "./collision"; import { resetOriginalContainerCache, updateOriginalContainerCache, } from "./textWysiwyg"; import { ExtractSetType } from "../utility-types"; import { measureText, wrapText } from "./textMeasurements"; export const normalizeText = (text: string) => { return ( text // replace tabs with spaces so they render and measure correctly .replace(/\t/g, " ") // normalize newlines .replace(/\r?\n|\r/g, "\n") ); }; export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, ) => { let maxWidth = undefined; const boundTextUpdates = { x: textElement.x, y: textElement.y, text: textElement.text, width: textElement.width, height: textElement.height, }; if (container) { maxWidth = getBoundTextMaxWidth(container); boundTextUpdates.text = wrapText( textElement.originalText, getFontString(textElement), maxWidth, ); } const metrics = measureText( boundTextUpdates.text, getFontString(textElement), ); boundTextUpdates.width = metrics.width; boundTextUpdates.height = metrics.height; if (container) { const containerDims = getContainerDims(container); const maxContainerHeight = getBoundTextMaxHeight( container, textElement as ExcalidrawTextElementWithContainer, ); let nextHeight = containerDims.height; 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); }; export const bindTextToShapeAfterDuplication = ( sceneElements: ExcalidrawElement[], oldElements: ExcalidrawElement[], oldIdToDuplicatedId: Map, ): void => { const sceneElementMap = arrayToMap(sceneElements) as Map< ExcalidrawElement["id"], ExcalidrawElement >; oldElements.forEach((element) => { const newElementId = oldIdToDuplicatedId.get(element.id) as string; const boundTextElementId = getBoundTextElementId(element); if (boundTextElementId) { const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId); if (newTextElementId) { const newContainer = sceneElementMap.get(newElementId); if (newContainer) { mutateElement(newContainer, { boundElements: (element.boundElements || []) .filter( (boundElement) => boundElement.id !== newTextElementId && boundElement.id !== boundTextElementId, ) .concat({ type: "text", id: newTextElementId, }), }); } const newTextElement = sceneElementMap.get(newTextElementId); if (newTextElement && isTextElement(newTextElement)) { mutateElement(newTextElement, { containerId: newContainer ? newElementId : null, }); } } } }); }; export const handleBindTextResize = ( container: NonDeletedExcalidrawElement, transformHandleType: MaybeTransformHandleType, ) => { const boundTextElementId = getBoundTextElementId(container); if (!boundTextElementId) { return; } resetOriginalContainerCache(container.id); let textElement = Scene.getScene(container)!.getElement( boundTextElementId, ) as ExcalidrawTextElement; if (textElement && textElement.text) { if (!container) { return; } textElement = Scene.getScene(container)!.getElement( boundTextElementId, ) as ExcalidrawTextElement; let text = textElement.text; let nextHeight = textElement.height; let nextWidth = textElement.width; const containerDims = getContainerDims(container); const maxWidth = getBoundTextMaxWidth(container); const maxHeight = getBoundTextMaxHeight( container, textElement as ExcalidrawTextElementWithContainer, ); let containerHeight = containerDims.height; if (transformHandleType !== "n" && transformHandleType !== "s") { if (text) { text = wrapText( textElement.originalText, getFontString(textElement), maxWidth, ); } const dimensions = measureText(text, getFontString(textElement)); nextHeight = dimensions.height; nextWidth = dimensions.width; } // increase height in case text element height exceeds if (nextHeight > maxHeight) { containerHeight = computeContainerDimensionForBoundText( nextHeight, container.type, ); const diff = containerHeight - containerDims.height; // fix the y coord when resizing from ne/nw/n const updatedY = !isArrowElement(container) && (transformHandleType === "ne" || transformHandleType === "nw" || transformHandleType === "n") ? container.y - diff : container.y; mutateElement(container, { height: containerHeight, y: updatedY, }); } mutateElement(textElement, { text, width: nextWidth, height: nextHeight, }); if (!isArrowElement(container)) { mutateElement( textElement, computeBoundTextPosition( container, textElement as ExcalidrawTextElementWithContainer, ), ); } } }; export const computeBoundTextPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, ) => { const containerCoords = getContainerCoords(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) { y = containerCoords.y + (maxContainerHeight - boundTextElement.height); } else { y = containerCoords.y + (maxContainerHeight / 2 - boundTextElement.height / 2); } if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) { x = containerCoords.x; } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) { x = containerCoords.x + (maxContainerWidth - boundTextElement.width); } else { x = containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2); } return { x, y }; }; export const getBoundTextElementId = (container: ExcalidrawElement | null) => { return container?.boundElements?.length ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id || null : null; }; export const getBoundTextElement = (element: ExcalidrawElement | null) => { if (!element) { return null; } const boundTextElementId = getBoundTextElementId(element); if (boundTextElementId) { return ( (Scene.getScene(element)?.getElement( boundTextElementId, ) as ExcalidrawTextElementWithContainer) || null ); } return null; }; export const getContainerElement = ( element: | (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null; }) | null, ) => { if (!element) { return null; } if (element.containerId) { return Scene.getScene(element)?.getElement(element.containerId) || null; } return null; }; export const getContainerDims = (element: ExcalidrawElement) => { const MIN_WIDTH = 300; if (isArrowElement(element)) { const width = Math.max(element.width, MIN_WIDTH); const height = element.height; return { width, height }; } return { width: element.width, height: element.height }; }; export const getContainerCenter = ( container: ExcalidrawElement, appState: AppState, ) => { if (!isArrowElement(container)) { return { x: container.x + container.width / 2, y: container.y + container.height / 2, }; } const points = LinearElementEditor.getPointsGlobalCoordinates(container); if (points.length % 2 === 1) { const index = Math.floor(container.points.length / 2); const midPoint = LinearElementEditor.getPointGlobalCoordinates( container, container.points[index], ); return { x: midPoint[0], y: midPoint[1] }; } const index = container.points.length / 2 - 1; let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints( container, appState, )[index]; if (!midSegmentMidpoint) { midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( container, points[index], points[index + 1], index + 1, ); } return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; }; export const getContainerCoords = (container: NonDeletedExcalidrawElement) => { let offsetX = BOUND_TEXT_PADDING; let offsetY = BOUND_TEXT_PADDING; if (container.type === "ellipse") { // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172 offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2); offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2); } // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265 if (container.type === "diamond") { offsetX += container.width / 4; offsetY += container.height / 4; } return { x: container.x + offsetX, y: container.y + offsetY, }; }; export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { const container = getContainerElement(textElement); if (!container || isArrowElement(container)) { return textElement.angle; } return container.angle; }; export const getBoundTextElementOffset = ( boundTextElement: ExcalidrawTextElement | null, ) => { const container = getContainerElement(boundTextElement); if (!container || !boundTextElement) { return 0; } if (isArrowElement(container)) { return BOUND_TEXT_PADDING * 8; } return BOUND_TEXT_PADDING; }; export const shouldAllowVerticalAlign = ( selectedElements: NonDeletedExcalidrawElement[], ) => { return selectedElements.some((element) => { const hasBoundContainer = isBoundToContainer(element); if (hasBoundContainer) { const container = getContainerElement(element); if (isTextElement(element) && isArrowElement(container)) { return false; } return true; } return false; }); }; export const suppportsHorizontalAlign = ( selectedElements: NonDeletedExcalidrawElement[], ) => { return selectedElements.some((element) => { const hasBoundContainer = isBoundToContainer(element); if (hasBoundContainer) { const container = getContainerElement(element); if (isTextElement(element) && isArrowElement(container)) { return false; } return true; } return isTextElement(element); }); }; export const getTextBindableContainerAtPosition = ( elements: readonly ExcalidrawElement[], appState: AppState, x: number, y: number, ): ExcalidrawTextContainer | null => { const selectedElements = getSelectedElements(elements, appState); if (selectedElements.length === 1) { return isTextBindableContainer(selectedElements[0], false) ? selectedElements[0] : null; } let hitElement = null; // We need to to hit testing from front (end of the array) to back (beginning of the array) for (let index = elements.length - 1; index >= 0; --index) { if (elements[index].isDeleted) { continue; } const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]); if ( isArrowElement(elements[index]) && isHittingElementNotConsideringBoundingBox(elements[index], appState, [ x, y, ]) ) { hitElement = elements[index]; break; } else if (x1 < x && x < x2 && y1 < y && y < y2) { hitElement = elements[index]; break; } } return isTextBindableContainer(hitElement, false) ? hitElement : null; }; const VALID_CONTAINER_TYPES = new Set([ "rectangle", "ellipse", "diamond", "image", "arrow", ]); export const isValidTextContainer = (element: ExcalidrawElement) => VALID_CONTAINER_TYPES.has(element.type); export const computeContainerDimensionForBoundText = ( dimension: number, containerType: ExtractSetType, ) => { dimension = Math.ceil(dimension); const padding = BOUND_TEXT_PADDING * 2; if (containerType === "ellipse") { return Math.round(((dimension + padding) / Math.sqrt(2)) * 2); } if (containerType === "arrow") { return dimension + padding * 8; } if (containerType === "diamond") { return 2 * (dimension + padding); } return dimension + padding; }; export const getBoundTextMaxWidth = (container: ExcalidrawElement) => { const width = getContainerDims(container).width; if (isArrowElement(container)) { return width - BOUND_TEXT_PADDING * 8 * 2; } if (container.type === "ellipse") { // The width of the largest rectangle inscribed inside an ellipse is // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from // equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172 return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; } if (container.type === "diamond") { // The width of the largest rectangle inscribed inside a rhombus is // Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265 return Math.round(width / 2) - BOUND_TEXT_PADDING * 2; } return width - BOUND_TEXT_PADDING * 2; }; export const getBoundTextMaxHeight = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, ) => { const height = getContainerDims(container).height; if (isArrowElement(container)) { const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; if (containerHeight <= 0) { return boundTextElement.height; } return height; } if (container.type === "ellipse") { // The height of the largest rectangle inscribed inside an ellipse is // Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from // equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172 return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; } if (container.type === "diamond") { // The height of the largest rectangle inscribed inside a rhombus is // Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265 return Math.round(height / 2) - BOUND_TEXT_PADDING * 2; } return height - BOUND_TEXT_PADDING * 2; };