From e0334f0f32ff37346ac5198b00a3e5004cf8c4ff Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 21 May 2025 15:12:08 +1000 Subject: [PATCH] feat: wrap texts from stats panel --- packages/element/src/resizeElements.ts | 241 ++++-------------- .../components/Stats/stats.test.tsx | 25 +- packages/excalidraw/components/Stats/utils.ts | 8 +- 3 files changed, 74 insertions(+), 200 deletions(-) diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index dea6e3d75..de3cc7336 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -2,7 +2,6 @@ import { pointCenter, normalizeRadians, pointFrom, - pointFromPair, pointRotateRads, type Radians, type LocalPoint, @@ -104,18 +103,6 @@ export const transformElements = ( ); updateBoundElements(element, scene); } - } else if (isTextElement(element) && transformHandleType) { - resizeSingleTextElement( - originalElements, - element, - scene, - transformHandleType, - shouldResizeFromCenter, - pointerX, - pointerY, - ); - updateBoundElements(element, scene); - return true; } else if (transformHandleType) { const elementId = selectedElements[0].id; const latestElement = elementsMap.get(elementId); @@ -150,6 +137,9 @@ export const transformElements = ( ); } } + if (isTextElement(element)) { + updateBoundElements(element, scene); + } return true; } else if (selectedElements.length > 1) { if (transformHandleType === "rotation") { @@ -282,151 +272,50 @@ export const measureFontSizeFromWidth = ( }; }; -const resizeSingleTextElement = ( - originalElements: PointerDownState["originalElements"], +export const resizeSingleTextElement = ( + origElement: NonDeleted, element: NonDeleted, scene: Scene, transformHandleType: TransformHandleDirection, shouldResizeFromCenter: boolean, - pointerX: number, - pointerY: number, + nextWidth: number, + nextHeight: number, ) => { const elementsMap = scene.getNonDeletedElementsMap(); - const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( - element, - elementsMap, - ); - // rotation pointer with reverse angle - const [rotatedX, rotatedY] = pointRotateRads( - pointFrom(pointerX, pointerY), - pointFrom(cx, cy), - -element.angle as Radians, - ); - let scaleX = 0; - let scaleY = 0; - if (transformHandleType !== "e" && transformHandleType !== "w") { - if (transformHandleType.includes("e")) { - scaleX = (rotatedX - x1) / (x2 - x1); - } - if (transformHandleType.includes("w")) { - scaleX = (x2 - rotatedX) / (x2 - x1); - } - if (transformHandleType.includes("n")) { - scaleY = (y2 - rotatedY) / (y2 - y1); - } - if (transformHandleType.includes("s")) { - scaleY = (rotatedY - y1) / (y2 - y1); - } + const metricsWidth = element.width * (nextHeight / element.height); + + const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth); + if (metrics === null) { + return; } - const scale = Math.max(scaleX, scaleY); + if (transformHandleType.includes("n") || transformHandleType.includes("s")) { + const previousOrigin = pointFrom(origElement.x, origElement.y); - if (scale > 0) { - const nextWidth = element.width * scale; - const nextHeight = element.height * scale; - const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth); - if (metrics === null) { - return; - } - - const startTopLeft = [x1, y1]; - const startBottomRight = [x2, y2]; - const startCenter = [cx, cy]; - - let newTopLeft = pointFrom(x1, y1); - if (["n", "w", "nw"].includes(transformHandleType)) { - newTopLeft = pointFrom( - startBottomRight[0] - Math.abs(nextWidth), - startBottomRight[1] - Math.abs(nextHeight), - ); - } - if (transformHandleType === "ne") { - const bottomLeft = [startTopLeft[0], startBottomRight[1]]; - newTopLeft = pointFrom( - bottomLeft[0], - bottomLeft[1] - Math.abs(nextHeight), - ); - } - if (transformHandleType === "sw") { - const topRight = [startBottomRight[0], startTopLeft[1]]; - newTopLeft = pointFrom( - topRight[0] - Math.abs(nextWidth), - topRight[1], - ); - } - - if (["s", "n"].includes(transformHandleType)) { - newTopLeft[0] = startCenter[0] - nextWidth / 2; - } - if (["e", "w"].includes(transformHandleType)) { - newTopLeft[1] = startCenter[1] - nextHeight / 2; - } - - if (shouldResizeFromCenter) { - newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2; - newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2; - } - - const angle = element.angle; - const rotatedTopLeft = pointRotateRads( - newTopLeft, - pointFrom(cx, cy), - angle, + const newOrigin = getResizedOrigin( + previousOrigin, + origElement.width, + origElement.height, + metricsWidth, + nextHeight, + origElement.angle, + transformHandleType, + false, + shouldResizeFromCenter, ); - const newCenter = pointFrom( - newTopLeft[0] + Math.abs(nextWidth) / 2, - newTopLeft[1] + Math.abs(nextHeight) / 2, - ); - const rotatedNewCenter = pointRotateRads( - newCenter, - pointFrom(cx, cy), - angle, - ); - newTopLeft = pointRotateRads( - rotatedTopLeft, - rotatedNewCenter, - -angle as Radians, - ); - const [nextX, nextY] = newTopLeft; scene.mutateElement(element, { fontSize: metrics.size, - width: nextWidth, + width: metricsWidth, height: nextHeight, - x: nextX, - y: nextY, + x: newOrigin.x, + y: newOrigin.y, }); + return; } if (transformHandleType === "e" || transformHandleType === "w") { - const stateAtResizeStart = originalElements.get(element.id)!; - const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( - stateAtResizeStart, - stateAtResizeStart.width, - stateAtResizeStart.height, - true, - ); - const startTopLeft = pointFrom(x1, y1); - const startBottomRight = pointFrom(x2, y2); - const startCenter = pointCenter(startTopLeft, startBottomRight); - - const rotatedPointer = pointRotateRads( - pointFrom(pointerX, pointerY), - startCenter, - -stateAtResizeStart.angle as Radians, - ); - - const [esx1, , esx2] = getResizedElementAbsoluteCoords( - element, - element.width, - element.height, - true, - ); - - const boundsCurrentWidth = esx2 - esx1; - - const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0]; const minWidth = getMinTextElementWidth( getFontString({ fontSize: element.fontSize, @@ -435,17 +324,7 @@ const resizeSingleTextElement = ( element.lineHeight, ); - let scaleX = atStartBoundsWidth / boundsCurrentWidth; - - if (transformHandleType.includes("e")) { - scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth; - } - if (transformHandleType.includes("w")) { - scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth; - } - - const newWidth = - element.width * scaleX < minWidth ? minWidth : element.width * scaleX; + const newWidth = Math.max(minWidth, nextWidth); const text = wrapText( element.originalText, @@ -458,49 +337,27 @@ const resizeSingleTextElement = ( element.lineHeight, ); - const eleNewHeight = metrics.height; + const newHeight = metrics.height; - const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = - getResizedElementAbsoluteCoords( - stateAtResizeStart, - newWidth, - eleNewHeight, - true, - ); - const newBoundsWidth = newBoundsX2 - newBoundsX1; - const newBoundsHeight = newBoundsY2 - newBoundsY1; + const previousOrigin = pointFrom(origElement.x, origElement.y); - let newTopLeft = [...startTopLeft] as [number, number]; - if (["n", "w", "nw"].includes(transformHandleType)) { - newTopLeft = [ - startBottomRight[0] - Math.abs(newBoundsWidth), - startTopLeft[1], - ]; - } - - // adjust topLeft to new rotation point - const angle = stateAtResizeStart.angle; - const rotatedTopLeft = pointRotateRads( - pointFromPair(newTopLeft), - startCenter, - angle, - ); - const newCenter = pointFrom( - newTopLeft[0] + Math.abs(newBoundsWidth) / 2, - newTopLeft[1] + Math.abs(newBoundsHeight) / 2, - ); - const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle); - newTopLeft = pointRotateRads( - rotatedTopLeft, - rotatedNewCenter, - -angle as Radians, + const newOrigin = getResizedOrigin( + previousOrigin, + origElement.width, + origElement.height, + newWidth, + newHeight, + element.angle, + transformHandleType, + false, + shouldResizeFromCenter, ); const resizedElement: Partial = { width: Math.abs(newWidth), height: Math.abs(metrics.height), - x: newTopLeft[0], - y: newTopLeft[1], + x: newOrigin.x, + y: newOrigin.y, text, autoResize: false, }; @@ -821,6 +678,18 @@ export const resizeSingleElement = ( shouldInformMutation?: boolean; } = {}, ) => { + if (isTextElement(latestElement) && isTextElement(origElement)) { + return resizeSingleTextElement( + origElement, + latestElement, + scene, + handleDirection, + shouldResizeFromCenter, + nextWidth, + nextHeight, + ); + } + let boundTextFont: { fontSize?: number } = {}; const elementsMap = scene.getNonDeletedElementsMap(); const boundTextElement = getBoundTextElement(latestElement, elementsMap); diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index cc1cfce98..49a072889 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -403,11 +403,23 @@ describe("stats for a non-generic element", () => { UI.updateInput(input, "36"); expect(text.fontSize).toBe(36); - // cannot change width or height - const width = UI.queryStatsProperty("W")?.querySelector(".drag-input"); - expect(width).toBeUndefined(); - const height = UI.queryStatsProperty("H")?.querySelector(".drag-input"); - expect(height).toBeUndefined(); + // can change width or height + const width = UI.queryStatsProperty("W")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(width).toBeDefined(); + const height = UI.queryStatsProperty("H")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + expect(height).toBeDefined(); + + const textHeightBeforeWrapping = text.height; + const textBeforeWrapping = text.text; + const originalTextBeforeWrapping = textBeforeWrapping; + UI.updateInput(width, "30"); + expect(text.height).toBeGreaterThan(textHeightBeforeWrapping); + expect(text.text).not.toBe(textBeforeWrapping); + expect(text.originalText).toBe(originalTextBeforeWrapping); // min font size is 4 UI.updateInput(input, "0"); @@ -630,12 +642,11 @@ describe("stats for multiple elements", () => { ) as HTMLInputElement; expect(fontSize).toBeDefined(); - // changing width does not affect text UI.updateInput(width, "200"); expect(rectangle?.width).toBe(200); expect(frame.width).toBe(200); - expect(text?.width).not.toBe(200); + expect(text?.width).toBe(200); UI.updateInput(angle, "40"); diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index f07a53dfe..68d202098 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -1,7 +1,7 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { getBoundTextElement } from "@excalidraw/element"; -import { isFrameLikeElement, isTextElement } from "@excalidraw/element"; +import { isFrameLikeElement } from "@excalidraw/element"; import { getSelectedGroupIds, @@ -41,12 +41,6 @@ export const isPropertyEditable = ( element: ExcalidrawElement, property: keyof ExcalidrawElement, ) => { - if (property === "height" && isTextElement(element)) { - return false; - } - if (property === "width" && isTextElement(element)) { - return false; - } if (property === "angle" && isFrameLikeElement(element)) { return false; }