diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index f3d93fcf15..f473460364 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -1,8 +1,8 @@ import { BOUND_TEXT_PADDING, ROUNDNESS, - VERTICAL_ALIGN, TEXT_ALIGN, + VERTICAL_ALIGN, } from "../constants"; import { isTextElement, newElement } from "../element"; import { mutateElement } from "../element/mutateElement"; @@ -142,6 +142,7 @@ export const actionBindText = register({ containerId: container.id, verticalAlign: VERTICAL_ALIGN.MIDDLE, textAlign: TEXT_ALIGN.CENTER, + autoResize: true, }); mutateElement(container, { boundElements: (container.boundElements || []).concat({ @@ -296,6 +297,7 @@ export const actionWrapTextInContainer = register({ verticalAlign: VERTICAL_ALIGN.MIDDLE, boundElements: null, textAlign: TEXT_ALIGN.CENTER, + autoResize: true, }, false, ); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index b26e12de0d..d48f78ba44 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -167,7 +167,7 @@ const offsetElementAfterFontResize = ( prevElement: ExcalidrawTextElement, nextElement: ExcalidrawTextElement, ) => { - if (isBoundToContainer(nextElement)) { + if (isBoundToContainer(nextElement) || !nextElement.autoResize) { return nextElement; } return mutateElement( diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts new file mode 100644 index 0000000000..3093f3090d --- /dev/null +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -0,0 +1,48 @@ +import { isTextElement } from "../element"; +import { newElementWith } from "../element/mutateElement"; +import { measureText } from "../element/textElement"; +import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; +import type { AppClassProperties } from "../types"; +import { getFontString } from "../utils"; +import { register } from "./register"; + +export const actionTextAutoResize = register({ + name: "autoResize", + label: "labels.autoResize", + icon: null, + trackEvent: { category: "element" }, + predicate: (elements, appState, _: unknown, app: AppClassProperties) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length === 1 && + isTextElement(selectedElements[0]) && + !selectedElements[0].autoResize + ); + }, + perform: (elements, appState, _, app) => { + const selectedElements = getSelectedElements(elements, appState); + + return { + appState, + elements: elements.map((element) => { + if (element.id === selectedElements[0].id && isTextElement(element)) { + const metrics = measureText( + element.originalText, + getFontString(element), + element.lineHeight, + ); + + return newElementWith(element, { + autoResize: true, + width: metrics.width, + height: metrics.height, + text: element.originalText, + }); + } + return element; + }), + storeAction: StoreAction.CAPTURE, + }; + }, +}); diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 401fe7432d..28034bdb6b 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -134,7 +134,8 @@ export type ActionName = | "setEmbeddableAsActiveTool" | "createContainerFromText" | "wrapTextInContainer" - | "commandPalette"; + | "commandPalette" + | "autoResize"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d405b7213c..deb33c568d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -114,7 +114,7 @@ import { newTextElement, newImageElement, transformElements, - updateTextElement, + refreshTextDimensions, redrawTextBoundingBox, getElementAbsoluteCoords, } from "../element"; @@ -429,6 +429,7 @@ import { isPointHittingLinkIcon, } from "./hyperlink/helpers"; import { getShortcutFromShortcutName } from "../actions/shortcuts"; +import { actionTextAutoResize } from "../actions/actionTextAutoResize"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -4298,25 +4299,22 @@ class App extends React.Component { ) { const elementsMap = this.scene.getElementsMapIncludingDeleted(); - const updateElement = ( - text: string, - originalText: string, - isDeleted: boolean, - ) => { + const updateElement = (nextOriginalText: string, isDeleted: boolean) => { this.scene.replaceAllElements([ // Not sure why we include deleted elements as well hence using deleted elements map ...this.scene.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id && isTextElement(_element)) { - return updateTextElement( - _element, - getContainerElement(_element, elementsMap), - elementsMap, - { - text, - isDeleted, - originalText, - }, - ); + return newElementWith(_element, { + originalText: nextOriginalText, + isDeleted: isDeleted ?? _element.isDeleted, + // returns (wrapped) text and new dimensions + ...refreshTextDimensions( + _element, + getContainerElement(_element, elementsMap), + elementsMap, + nextOriginalText, + ), + }); } return _element; }), @@ -4339,15 +4337,15 @@ class App extends React.Component { viewportY - this.state.offsetTop, ]; }, - onChange: withBatchedUpdates((text) => { - updateElement(text, text, false); + onChange: withBatchedUpdates((nextOriginalText) => { + updateElement(nextOriginalText, false); if (isNonDeletedElement(element)) { updateBoundElements(element, elementsMap); } }), - onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => { - const isDeleted = !text.trim(); - updateElement(text, originalText, isDeleted); + onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { + const isDeleted = !nextOriginalText.trim(); + updateElement(nextOriginalText, isDeleted); // select the created text element only if submitting via keyboard // (when submitting via click it should act as signal to deselect) if (!isDeleted && viaKeyboard) { @@ -4392,7 +4390,7 @@ class App extends React.Component { // do an initial update to re-initialize element position since we were // modifying element's x/y for sake of editor (case: syncing to remote) - updateElement(element.text, element.originalText, false); + updateElement(element.originalText, false); } private deselectElements() { @@ -9631,6 +9629,7 @@ class App extends React.Component { } return [ + CONTEXT_MENU_SEPARATOR, actionCut, actionCopy, actionPaste, @@ -9643,6 +9642,7 @@ class App extends React.Component { actionPasteStyles, CONTEXT_MENU_SEPARATOR, actionGroup, + actionTextAutoResize, actionUnbindText, actionBindText, actionWrapTextInContainer, diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index adb5b03728..46105b967a 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -228,6 +228,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -273,6 +274,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -378,6 +380,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id48", @@ -478,6 +481,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id37", @@ -652,6 +656,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id41", @@ -692,6 +697,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -737,6 +743,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": [ { @@ -1194,6 +1201,7 @@ exports[`Test Transform > should transform regular shapes 6`] = ` exports[`Test Transform > should transform text element 1`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": null, @@ -1234,6 +1242,7 @@ exports[`Test Transform > should transform text element 1`] = ` exports[`Test Transform > should transform text element 2`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": null, @@ -1566,6 +1575,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "B", @@ -1608,6 +1618,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "A", @@ -1650,6 +1661,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Alice", @@ -1692,6 +1704,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Bob", @@ -1734,6 +1747,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Bob_Alice", @@ -1774,6 +1788,7 @@ exports[`Test Transform > should transform the elements correctly when linear el exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "Bob_B", @@ -2022,6 +2037,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id25", @@ -2062,6 +2078,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id26", @@ -2102,6 +2119,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id27", @@ -2143,6 +2161,7 @@ LABELLED ARROW", exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id28", @@ -2406,6 +2425,7 @@ exports[`Test Transform > should transform to text containers when label provide exports[`Test Transform > should transform to text containers when label provided 7`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id13", @@ -2446,6 +2466,7 @@ exports[`Test Transform > should transform to text containers when label provide exports[`Test Transform > should transform to text containers when label provided 8`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id14", @@ -2487,6 +2508,7 @@ CONTAINER", exports[`Test Transform > should transform to text containers when label provided 9`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id15", @@ -2530,6 +2552,7 @@ CONTAINER", exports[`Test Transform > should transform to text containers when label provided 10`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id16", @@ -2571,6 +2594,7 @@ TEXT CONTAINER", exports[`Test Transform > should transform to text containers when label provided 11`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id17", @@ -2613,6 +2637,7 @@ CONTAINER", exports[`Test Transform > should transform to text containers when label provided 12`] = ` { "angle": 0, + "autoResize": true, "backgroundColor": "transparent", "boundElements": null, "containerId": "id18", diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index e935931554..70d209cc26 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -208,7 +208,7 @@ const restoreElement = ( verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, containerId: element.containerId ?? null, originalText: element.originalText || text, - + autoResize: element.autoResize ?? true, lineHeight, }); diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index b9c203a369..35661608e4 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -9,7 +9,6 @@ import { isLinearElementType } from "./typeChecks"; export { newElement, newTextElement, - updateTextElement, refreshTextDimensions, newLinearElement, newImageElement, diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index bdee048e8c..3fa203f49f 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -240,24 +240,28 @@ export const newTextElement = ( metrics, ); - const textElement = newElementWith( - { - ..._newElementBase("text", opts), - text, - fontSize, - fontFamily, - textAlign, - verticalAlign, - x: opts.x - offsets.x, - y: opts.y - offsets.y, - width: metrics.width, - height: metrics.height, - containerId: opts.containerId || null, - originalText: text, - lineHeight, - }, + const textElementProps: ExcalidrawTextElement = { + ..._newElementBase("text", opts), + text, + fontSize, + fontFamily, + textAlign, + verticalAlign, + x: opts.x - offsets.x, + y: opts.y - offsets.y, + width: metrics.width, + height: metrics.height, + containerId: opts.containerId || null, + originalText: text, + autoResize: true, + lineHeight, + }; + + const textElement: ExcalidrawTextElement = newElementWith( + textElementProps, {}, ); + return textElement; }; @@ -271,18 +275,25 @@ const getAdjustedDimensions = ( width: number; height: number; } => { - const { width: nextWidth, height: nextHeight } = measureText( + let { width: nextWidth, height: nextHeight } = measureText( nextText, getFontString(element), element.lineHeight, ); + + // wrapped text + if (!element.autoResize) { + nextWidth = element.width; + } + const { textAlign, verticalAlign } = element; let x: number; let y: number; if ( textAlign === "center" && verticalAlign === VERTICAL_ALIGN.MIDDLE && - !element.containerId + !element.containerId && + element.autoResize ) { const prevMetrics = measureText( element.text, @@ -343,38 +354,19 @@ export const refreshTextDimensions = ( if (textElement.isDeleted) { return; } - if (container) { + if (container || !textElement.autoResize) { text = wrapText( text, getFontString(textElement), - getBoundTextMaxWidth(container, textElement), + container + ? getBoundTextMaxWidth(container, textElement) + : textElement.width, ); } const dimensions = getAdjustedDimensions(textElement, elementsMap, text); return { text, ...dimensions }; }; -export const updateTextElement = ( - textElement: ExcalidrawTextElement, - container: ExcalidrawTextContainer | null, - elementsMap: ElementsMap, - { - text, - isDeleted, - originalText, - }: { - text: string; - isDeleted?: boolean; - originalText: string; - }, -): ExcalidrawTextElement => { - return newElementWith(textElement, { - originalText, - isDeleted: isDeleted ?? textElement.isDeleted, - ...refreshTextDimensions(textElement, container, elementsMap, originalText), - }); -}; - export const newFreeDrawElement = ( opts: { type: "freedraw"; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 3630fafd00..debac0840e 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -1,4 +1,8 @@ -import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants"; +import { + BOUND_TEXT_PADDING, + MIN_FONT_SIZE, + SHIFT_LOCKING_ANGLE, +} from "../constants"; import { rescalePoints } from "../points"; import { rotate, centerPoint, rotatePoint } from "../math"; @@ -45,6 +49,9 @@ import { handleBindTextResize, getBoundTextMaxWidth, getApproxMinLineHeight, + wrapText, + measureText, + getMinCharWidth, } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; @@ -84,14 +91,9 @@ export const transformElements = ( shouldRotateWithDiscreteAngle, ); updateBoundElements(element, elementsMap); - } else if ( - isTextElement(element) && - (transformHandleType === "nw" || - transformHandleType === "ne" || - transformHandleType === "sw" || - transformHandleType === "se") - ) { + } else if (isTextElement(element) && transformHandleType) { resizeSingleTextElement( + originalElements, element, elementsMap, transformHandleType, @@ -223,9 +225,10 @@ const measureFontSizeFromWidth = ( }; const resizeSingleTextElement = ( + originalElements: PointerDownState["originalElements"], element: NonDeleted, elementsMap: ElementsMap, - transformHandleType: "nw" | "ne" | "sw" | "se", + transformHandleType: TransformHandleDirection, shouldResizeFromCenter: boolean, pointerX: number, pointerY: number, @@ -245,17 +248,19 @@ const resizeSingleTextElement = ( let scaleX = 0; let scaleY = 0; - 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); + 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 scale = Math.max(scaleX, scaleY); @@ -318,6 +323,102 @@ const resizeSingleTextElement = ( y: nextY, }); } + + 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: Point = [x1, y1]; + const startBottomRight: Point = [x2, y2]; + const startCenter: Point = centerPoint(startTopLeft, startBottomRight); + + const rotatedPointer = rotatePoint( + [pointerX, pointerY], + startCenter, + -stateAtResizeStart.angle, + ); + + const [esx1, , esx2] = getResizedElementAbsoluteCoords( + element, + element.width, + element.height, + true, + ); + + const boundsCurrentWidth = esx2 - esx1; + + const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0]; + const minWidth = + getMinCharWidth(getFontString(element)) + BOUND_TEXT_PADDING * 2; + + 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 text = wrapText( + element.originalText, + getFontString(element), + Math.abs(newWidth), + ); + const metrics = measureText( + text, + getFontString(element), + element.lineHeight, + ); + + const eleNewHeight = metrics.height; + + const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = + getResizedElementAbsoluteCoords( + stateAtResizeStart, + newWidth, + eleNewHeight, + true, + ); + const newBoundsWidth = newBoundsX2 - newBoundsX1; + const newBoundsHeight = newBoundsY2 - newBoundsY1; + + 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 = rotatePoint(newTopLeft, startCenter, angle); + const newCenter: Point = [ + newTopLeft[0] + Math.abs(newBoundsWidth) / 2, + newTopLeft[1] + Math.abs(newBoundsHeight) / 2, + ]; + const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); + newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + + const resizedElement: Partial = { + width: Math.abs(newWidth), + height: Math.abs(metrics.height), + x: newTopLeft[0], + y: newTopLeft[1], + text, + autoResize: false, + }; + + mutateElement(element, resizedElement); + } }; export const resizeSingleElement = ( diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index 3fea7d9607..74ebd8e5d4 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -87,12 +87,8 @@ export const resizeTest = ( elementsMap, ); - // Note that for a text element, when "resized" from the side - // we should make it wrap/unwrap - if ( - element.type !== "text" && - !(isLinearElement(element) && element.points.length <= 2) - ) { + // do not resize from the sides for linear elements with only two points + if (!(isLinearElement(element) && element.points.length <= 2)) { const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const sides = getSelectionBorders( [x1 - SPACING, y1 - SPACING], diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index a81fe9cec2..aabcba3bfa 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -48,7 +48,7 @@ export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, elementsMap: ElementsMap, - informMutation: boolean = true, + informMutation = true, ) => { let maxWidth = undefined; const boundTextUpdates = { @@ -62,21 +62,27 @@ export const redrawTextBoundingBox = ( boundTextUpdates.text = textElement.text; - if (container) { - maxWidth = getBoundTextMaxWidth(container, textElement); + 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, ); - boundTextUpdates.width = metrics.width; + // 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) { diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx index 78849376d6..5691874fb4 100644 --- a/packages/excalidraw/element/textWysiwyg.test.tsx +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -236,6 +236,117 @@ describe("textWysiwyg", () => { }); }); + describe("Test text wrapping", () => { + const { h } = window; + const dimensions = { height: 400, width: 800 }; + + beforeAll(() => { + mockBoundingClientRect(dimensions); + }); + + beforeEach(async () => { + await render(); + // @ts-ignore + h.app.refreshViewportBreakpoints(); + // @ts-ignore + h.app.refreshEditorBreakpoints(); + + h.elements = []; + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should keep width when editing a wrapped text", async () => { + const text = API.createElement({ + type: "text", + text: "Excalidraw\nEditor", + }); + + h.elements = [text]; + + const prevWidth = text.width; + const prevHeight = text.height; + const prevText = text.text; + + // text is wrapped + UI.resize(text, "e", [-20, 0]); + expect(text.width).not.toEqual(prevWidth); + expect(text.height).not.toEqual(prevHeight); + expect(text.text).not.toEqual(prevText); + expect(text.autoResize).toBe(false); + + const wrappedWidth = text.width; + const wrappedHeight = text.height; + const wrappedText = text.text; + + // edit text + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + const editor = await getTextEditor(textEditorSelector); + expect(editor).not.toBe(null); + expect(h.state.editingElement?.id).toBe(text.id); + expect(h.elements.length).toBe(1); + + const nextText = `${wrappedText} is great!`; + updateTextEditor(editor, nextText); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + + expect(h.elements[0].width).toEqual(wrappedWidth); + expect(h.elements[0].height).toBeGreaterThan(wrappedHeight); + + // remove all texts and then add it back editing + updateTextEditor(editor, ""); + await new Promise((cb) => setTimeout(cb, 0)); + updateTextEditor(editor, nextText); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + + expect(h.elements[0].width).toEqual(wrappedWidth); + }); + + it("should restore original text after unwrapping a wrapped text", async () => { + const originalText = "Excalidraw\neditor\nis great!"; + const text = API.createElement({ + type: "text", + text: originalText, + }); + h.elements = [text]; + + // wrap + UI.resize(text, "e", [-40, 0]); + // enter text editing mode + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + const editor = await getTextEditor(textEditorSelector); + editor.blur(); + // restore after unwrapping + UI.resize(text, "e", [40, 0]); + expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); + + // wrap again and add a new line + UI.resize(text, "e", [-30, 0]); + const wrappedText = text.text; + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + updateTextEditor(editor, `${wrappedText}\nA new line!`); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + // remove the newly added line + UI.clickTool("selection"); + mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2); + updateTextEditor(editor, wrappedText); + await new Promise((cb) => setTimeout(cb, 0)); + editor.blur(); + // unwrap + UI.resize(text, "e", [30, 0]); + // expect the text to be restored the same + expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText); + }); + }); + describe("Test container-unbound text", () => { const { h } = window; const dimensions = { height: 400, width: 800 }; @@ -800,26 +911,15 @@ describe("textWysiwyg", () => { mouse.down(); const text = h.elements[1] as ExcalidrawTextElementWithContainer; - let editor = await getTextEditor(textEditorSelector, true); + const editor = await getTextEditor(textEditorSelector, true); await new Promise((r) => setTimeout(r, 0)); updateTextEditor(editor, "Hello World!"); editor.blur(); expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil); - UI.clickTool("text"); - - mouse.clickAt( - rectangle.x + rectangle.width / 2, - rectangle.y + rectangle.height / 2, - ); - mouse.down(); - editor = await getTextEditor(textEditorSelector, true); - editor.select(); fireEvent.click(screen.getByTitle(/code/i)); - await new Promise((r) => setTimeout(r, 0)); - editor.blur(); expect( (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, ).toEqual(FONT_FAMILY.Cascadia); diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 15cedc0012..b0cb497d92 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -79,12 +79,14 @@ export const textWysiwyg = ({ app, }: { id: ExcalidrawElement["id"]; - onChange?: (text: string) => void; - onSubmit: (data: { - text: string; - viaKeyboard: boolean; - originalText: string; - }) => void; + /** + * textWysiwyg only deals with `originalText` + * + * Note: `text`, which can be wrapped and therefore different from `originalText`, + * is derived from `originalText` + */ + onChange?: (nextOriginalText: string) => void; + onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void; getViewportCoords: (x: number, y: number) => [number, number]; element: ExcalidrawTextElement; canvas: HTMLCanvasElement; @@ -129,11 +131,8 @@ export const textWysiwyg = ({ app.scene.getNonDeletedElementsMap(), ); let maxWidth = updatedTextElement.width; - let maxHeight = updatedTextElement.height; let textElementWidth = updatedTextElement.width; - // Set to element height by default since that's - // what is going to be used for unbounded text const textElementHeight = updatedTextElement.height; if (container && updatedTextElement.containerId) { @@ -262,6 +261,7 @@ export const textWysiwyg = ({ if (isTestEnv()) { editable.style.fontFamily = getFontFamilyString(updatedTextElement); } + mutateElement(updatedTextElement, { x: coordX, y: coordY }); } }; @@ -278,7 +278,7 @@ export const textWysiwyg = ({ let whiteSpace = "pre"; let wordBreak = "normal"; - if (isBoundToContainer(element)) { + if (isBoundToContainer(element) || !element.autoResize) { whiteSpace = "pre-wrap"; wordBreak = "break-word"; } @@ -501,14 +501,12 @@ export const textWysiwyg = ({ if (!updateElement) { return; } - let text = editable.value; const container = getContainerElement( updateElement, app.scene.getNonDeletedElementsMap(), ); if (container) { - text = updateElement.text; if (editable.value.trim()) { const boundTextElementId = getBoundTextElementId(container); if (!boundTextElementId || boundTextElementId !== element.id) { @@ -540,9 +538,8 @@ export const textWysiwyg = ({ } onSubmit({ - text, viaKeyboard: submittedViaKeyboard, - originalText: editable.value, + nextOriginalText: editable.value, }); }; diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index a72dcf78a4..0b642b274d 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -9,7 +9,6 @@ import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; import { rotate } from "../math"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; -import { isTextElement } from "."; import { isFrameLikeElement, isLinearElement } from "./typeChecks"; import { DEFAULT_TRANSFORM_HANDLE_SPACING, @@ -65,13 +64,6 @@ export const OMIT_SIDES_FOR_FRAME = { rotation: true, }; -const OMIT_SIDES_FOR_TEXT_ELEMENT = { - e: true, - s: true, - n: true, - w: true, -}; - const OMIT_SIDES_FOR_LINE_SLASH = { e: true, s: true, @@ -290,8 +282,6 @@ export const getTransformHandles = ( omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH; } } - } else if (isTextElement(element)) { - omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; } else if (isFrameLikeElement(element)) { omitSides = { ...omitSides, diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 6b7ac57b4b..700b7ed6c4 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -193,6 +193,13 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; originalText: string; + /** + * If `true` the width will fit the text. If `false`, the text will + * wrap to fit the width. + * + * @default true + */ + autoResize: boolean; /** * Unitless line height (aligned to W3C). To get line height in px, multiply * with font size (using `getLineHeightInPx` helper). diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index eac6a3f669..a5745420f6 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -149,7 +149,8 @@ "zoomToFitViewport": "Zoom to fit in viewport", "zoomToFitSelection": "Zoom to fit selection", "zoomToFit": "Zoom to fit all elements", - "installPWA": "Install Excalidraw locally (PWA)" + "installPWA": "Install Excalidraw locally (PWA)", + "autoResize": "Enable text auto-resizing" }, "library": { "noItems": "No items added yet...", diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 7f860ae6f8..f032e9672f 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -12,6 +12,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "collaborators": Map {}, "contextMenu": { "items": [ + "separator", { "icon":