diff options
Diffstat (limited to 'packages/excalidraw/element/textElement.ts')
| -rw-r--r-- | packages/excalidraw/element/textElement.ts | 521 |
1 files changed, 521 insertions, 0 deletions
diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts new file mode 100644 index 0000000..de948d9 --- /dev/null +++ b/packages/excalidraw/element/textElement.ts @@ -0,0 +1,521 @@ +import { getFontString, arrayToMap } from "../utils"; +import type { + ElementsMap, + ExcalidrawElement, + ExcalidrawElementType, + ExcalidrawTextContainer, + ExcalidrawTextElement, + ExcalidrawTextElementWithContainer, + NonDeletedExcalidrawElement, +} from "./types"; +import { mutateElement } from "./mutateElement"; +import { + ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO, + ARROW_LABEL_WIDTH_FRACTION, + BOUND_TEXT_PADDING, + DEFAULT_FONT_SIZE, + TEXT_ALIGN, + VERTICAL_ALIGN, +} from "../constants"; +import type { MaybeTransformHandleType } from "./transformHandles"; +import { isTextElement } from "."; +import { wrapText } from "./textWrapping"; +import { isBoundToContainer, isArrowElement } from "./typeChecks"; +import { LinearElementEditor } from "./linearElementEditor"; +import type { AppState } from "../types"; +import { + resetOriginalContainerCache, + updateOriginalContainerCache, +} from "./containerCache"; +import type { ExtractSetType } from "../utility-types"; +import { measureText } from "./textMeasurements"; + +export const redrawTextBoundingBox = ( + textElement: ExcalidrawTextElement, + container: ExcalidrawElement | null, + elementsMap: ElementsMap, + informMutation = true, +) => { + let maxWidth = undefined; + const boundTextUpdates = { + x: textElement.x, + y: textElement.y, + text: textElement.text, + width: textElement.width, + height: textElement.height, + angle: container?.angle ?? textElement.angle, + }; + + boundTextUpdates.text = textElement.text; + + if (container || !textElement.autoResize) { + maxWidth = container + ? getBoundTextMaxWidth(container, textElement) + : textElement.width; + boundTextUpdates.text = wrapText( + textElement.originalText, + getFontString(textElement), + maxWidth, + ); + } + + const metrics = measureText( + boundTextUpdates.text, + getFontString(textElement), + textElement.lineHeight, + ); + + // Note: only update width for unwrapped text and bound texts (which always have autoResize set to true) + if (textElement.autoResize) { + boundTextUpdates.width = metrics.width; + } + boundTextUpdates.height = metrics.height; + + if (container) { + const maxContainerHeight = getBoundTextMaxHeight( + container, + textElement as ExcalidrawTextElementWithContainer, + ); + const maxContainerWidth = getBoundTextMaxWidth(container, textElement); + + if (!isArrowElement(container) && metrics.height > maxContainerHeight) { + const nextHeight = computeContainerDimensionForBoundText( + metrics.height, + container.type, + ); + mutateElement(container, { height: nextHeight }, informMutation); + updateOriginalContainerCache(container.id, nextHeight); + } + if (metrics.width > maxContainerWidth) { + const nextWidth = computeContainerDimensionForBoundText( + metrics.width, + container.type, + ); + mutateElement(container, { width: nextWidth }, informMutation); + } + const updatedTextElement = { + ...textElement, + ...boundTextUpdates, + } as ExcalidrawTextElementWithContainer; + const { x, y } = computeBoundTextPosition( + container, + updatedTextElement, + elementsMap, + ); + boundTextUpdates.x = x; + boundTextUpdates.y = y; + } + + mutateElement(textElement, boundTextUpdates, informMutation); +}; + +export const bindTextToShapeAfterDuplication = ( + newElements: ExcalidrawElement[], + oldElements: ExcalidrawElement[], + oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, +): void => { + const newElementsMap = arrayToMap(newElements) 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 = newElementsMap.get(newElementId); + if (newContainer) { + mutateElement(newContainer, { + boundElements: (element.boundElements || []) + .filter( + (boundElement) => + boundElement.id !== newTextElementId && + boundElement.id !== boundTextElementId, + ) + .concat({ + type: "text", + id: newTextElementId, + }), + }); + } + const newTextElement = newElementsMap.get(newTextElementId); + if (newTextElement && isTextElement(newTextElement)) { + mutateElement(newTextElement, { + containerId: newContainer ? newElementId : null, + }); + } + } + } + }); +}; + +export const handleBindTextResize = ( + container: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + transformHandleType: MaybeTransformHandleType, + shouldMaintainAspectRatio = false, +) => { + const boundTextElementId = getBoundTextElementId(container); + if (!boundTextElementId) { + return; + } + resetOriginalContainerCache(container.id); + const textElement = getBoundTextElement(container, elementsMap); + if (textElement && textElement.text) { + if (!container) { + return; + } + + let text = textElement.text; + let nextHeight = textElement.height; + let nextWidth = textElement.width; + const maxWidth = getBoundTextMaxWidth(container, textElement); + const maxHeight = getBoundTextMaxHeight(container, textElement); + let containerHeight = container.height; + if ( + shouldMaintainAspectRatio || + (transformHandleType !== "n" && transformHandleType !== "s") + ) { + if (text) { + text = wrapText( + textElement.originalText, + getFontString(textElement), + maxWidth, + ); + } + const metrics = measureText( + text, + getFontString(textElement), + textElement.lineHeight, + ); + nextHeight = metrics.height; + nextWidth = metrics.width; + } + // increase height in case text element height exceeds + if (nextHeight > maxHeight) { + containerHeight = computeContainerDimensionForBoundText( + nextHeight, + container.type, + ); + + const diff = containerHeight - container.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, elementsMap), + ); + } + } +}; + +export const computeBoundTextPosition = ( + container: ExcalidrawElement, + boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, +) => { + if (isArrowElement(container)) { + return LinearElementEditor.getBoundTextElementPosition( + container, + boundTextElement, + elementsMap, + ); + } + const containerCoords = getContainerCoords(container); + const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement); + const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement); + + let x; + let y; + 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?.find((ele) => ele.type === "text")?.id || null + : null; +}; + +export const getBoundTextElement = ( + element: ExcalidrawElement | null, + elementsMap: ElementsMap, +) => { + if (!element) { + return null; + } + const boundTextElementId = getBoundTextElementId(element); + + if (boundTextElementId) { + return (elementsMap.get(boundTextElementId) || + null) as ExcalidrawTextElementWithContainer | null; + } + return null; +}; + +export const getContainerElement = ( + element: ExcalidrawTextElement | null, + elementsMap: ElementsMap, +): ExcalidrawTextContainer | null => { + if (!element) { + return null; + } + if (element.containerId) { + return (elementsMap.get(element.containerId) || + null) as ExcalidrawTextContainer | null; + } + return null; +}; + +export const getContainerCenter = ( + container: ExcalidrawElement, + appState: AppState, + elementsMap: ElementsMap, +) => { + if (!isArrowElement(container)) { + return { + x: container.x + container.width / 2, + y: container.y + container.height / 2, + }; + } + const points = LinearElementEditor.getPointsGlobalCoordinates( + container, + elementsMap, + ); + if (points.length % 2 === 1) { + const index = Math.floor(container.points.length / 2); + const midPoint = LinearElementEditor.getPointGlobalCoordinates( + container, + container.points[index], + elementsMap, + ); + return { x: midPoint[0], y: midPoint[1] }; + } + const index = container.points.length / 2 - 1; + let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints( + container, + elementsMap, + appState, + )[index]; + if (!midSegmentMidpoint) { + midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( + container, + points[index], + points[index + 1], + index + 1, + elementsMap, + ); + } + 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, + container: ExcalidrawTextContainer | null, +) => { + if (!container || isArrowElement(container)) { + return textElement.angle; + } + return container.angle; +}; + +export const getBoundTextElementPosition = ( + container: ExcalidrawElement, + boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, +) => { + if (isArrowElement(container)) { + return LinearElementEditor.getBoundTextElementPosition( + container, + boundTextElement, + elementsMap, + ); + } +}; + +export const shouldAllowVerticalAlign = ( + selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, +) => { + return selectedElements.some((element) => { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + return false; + } + return true; + } + return false; + }); +}; + +export const suppportsHorizontalAlign = ( + selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, +) => { + return selectedElements.some((element) => { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + return false; + } + return true; + } + + return isTextElement(element); + }); +}; + +const VALID_CONTAINER_TYPES = new Set([ + "rectangle", + "ellipse", + "diamond", + "arrow", +]); + +export const isValidTextContainer = (element: { + type: ExcalidrawElementType; +}) => VALID_CONTAINER_TYPES.has(element.type); + +export const computeContainerDimensionForBoundText = ( + dimension: number, + containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>, +) => { + 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, + boundTextElement: ExcalidrawTextElement | null, +) => { + const { width } = container; + if (isArrowElement(container)) { + const minWidth = + (boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) * + ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO; + return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth); + } + 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 } = container; + 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; +}; + +/** retrieves text from text elements and concatenates to a single string */ +export const getTextFromElements = ( + elements: readonly ExcalidrawElement[], + separator = "\n\n", +) => { + const text = elements + .reduce((acc: string[], element) => { + if (isTextElement(element)) { + acc.push(element.text); + } + return acc; + }, []) + .join(separator); + return text; +}; |
