feat: text wrapping (#7999)

* resize single elements from the side

* fix lint

* do not resize texts from the sides (for we want to wrap/unwrap)

* omit side handles for frames too

* upgrade types

* enable resizing from the sides for multiple elements as well

* fix lint

* maintain aspect ratio when elements are not of the same angle

* lint

* always resize proportionally for multiple elements

* increase side resizing padding

* code cleanup

* adaptive handles

* do not resize for linear elements with only two points

* prioritize point dragging over edge resizing

* lint

* allow free resizing for multiple elements at degree 0

* always resize from the sides

* reduce hit threshold

* make small multiple elements movable

* lint

* show side handles on touch screen and mobile devices

* differentiate touchscreens

* keep proportional with text in multi-element resizing

* update snapshot

* update multi elements resizing logic

* lint

* reduce side resizing padding

* bound texts do not scale in normal cases

* lint

* test sides for texts

* wrap text

* do not update text size when changing its alignment

* keep text wrapped/unwrapped when editing

* change wrapped size to auto size from context menu

* fix test

* lint

* increase min width for wrapped texts

* wrap wrapped text in container

* unwrap when binding text to container

* rename `wrapped` to `autoResize`

* fix lint

* revert: use `center` align when wrapping text in container

* update snaps

* fix lint

* simplify logic on autoResize

* lint and test

* snapshots

* remove unnecessary code

* snapshots

* fix: defaults not set correctly

* tests for wrapping texts when resized

* tests for text wrapping when edited

* fix autoResize refactor

* include autoResize flag check

* refactor

* feat: rename action label & change contextmenu position

* fix: update version on `autoResize` action

* fix infinite loop when editing text in a container

* simplify

* always maintain `width` if `!autoResize`

* maintain `x` if `!autoResize`

* maintain `y` pos after fontSize change if `!autoResize`

* refactor

* when editing, do not wrap text in textWysiwyg

* simplify text editor

* make test more readable

* comment

* rename action to match file name

* revert function signature change

* only update  in app

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
pull/8021/head
Ryan Di 10 months ago committed by GitHub
parent cc4c51996c
commit 971b4d4ae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,8 +1,8 @@
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
ROUNDNESS, ROUNDNESS,
VERTICAL_ALIGN,
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { isTextElement, newElement } from "../element"; import { isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
@ -142,6 +142,7 @@ export const actionBindText = register({
containerId: container.id, containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER, textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
}); });
mutateElement(container, { mutateElement(container, {
boundElements: (container.boundElements || []).concat({ boundElements: (container.boundElements || []).concat({
@ -296,6 +297,7 @@ export const actionWrapTextInContainer = register({
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null, boundElements: null,
textAlign: TEXT_ALIGN.CENTER, textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
}, },
false, false,
); );

@ -167,7 +167,7 @@ const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement, prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement, nextElement: ExcalidrawTextElement,
) => { ) => {
if (isBoundToContainer(nextElement)) { if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
return nextElement; return nextElement;
} }
return mutateElement( return mutateElement(

@ -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,
};
},
});

@ -134,7 +134,8 @@ export type ActionName =
| "setEmbeddableAsActiveTool" | "setEmbeddableAsActiveTool"
| "createContainerFromText" | "createContainerFromText"
| "wrapTextInContainer" | "wrapTextInContainer"
| "commandPalette"; | "commandPalette"
| "autoResize";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];

@ -114,7 +114,7 @@ import {
newTextElement, newTextElement,
newImageElement, newImageElement,
transformElements, transformElements,
updateTextElement, refreshTextDimensions,
redrawTextBoundingBox, redrawTextBoundingBox,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "../element"; } from "../element";
@ -429,6 +429,7 @@ import {
isPointHittingLinkIcon, isPointHittingLinkIcon,
} from "./hyperlink/helpers"; } from "./hyperlink/helpers";
import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@ -4298,25 +4299,22 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
const updateElement = ( const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
text: string,
originalText: string,
isDeleted: boolean,
) => {
this.scene.replaceAllElements([ this.scene.replaceAllElements([
// Not sure why we include deleted elements as well hence using deleted elements map // Not sure why we include deleted elements as well hence using deleted elements map
...this.scene.getElementsIncludingDeleted().map((_element) => { ...this.scene.getElementsIncludingDeleted().map((_element) => {
if (_element.id === element.id && isTextElement(_element)) { if (_element.id === element.id && isTextElement(_element)) {
return updateTextElement( return newElementWith(_element, {
_element, originalText: nextOriginalText,
getContainerElement(_element, elementsMap), isDeleted: isDeleted ?? _element.isDeleted,
elementsMap, // returns (wrapped) text and new dimensions
{ ...refreshTextDimensions(
text, _element,
isDeleted, getContainerElement(_element, elementsMap),
originalText, elementsMap,
}, nextOriginalText,
); ),
});
} }
return _element; return _element;
}), }),
@ -4339,15 +4337,15 @@ class App extends React.Component<AppProps, AppState> {
viewportY - this.state.offsetTop, viewportY - this.state.offsetTop,
]; ];
}, },
onChange: withBatchedUpdates((text) => { onChange: withBatchedUpdates((nextOriginalText) => {
updateElement(text, text, false); updateElement(nextOriginalText, false);
if (isNonDeletedElement(element)) { if (isNonDeletedElement(element)) {
updateBoundElements(element, elementsMap); updateBoundElements(element, elementsMap);
} }
}), }),
onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => { onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
const isDeleted = !text.trim(); const isDeleted = !nextOriginalText.trim();
updateElement(text, originalText, isDeleted); updateElement(nextOriginalText, isDeleted);
// select the created text element only if submitting via keyboard // select the created text element only if submitting via keyboard
// (when submitting via click it should act as signal to deselect) // (when submitting via click it should act as signal to deselect)
if (!isDeleted && viaKeyboard) { if (!isDeleted && viaKeyboard) {
@ -4392,7 +4390,7 @@ class App extends React.Component<AppProps, AppState> {
// do an initial update to re-initialize element position since we were // 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) // 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() { private deselectElements() {
@ -9631,6 +9629,7 @@ class App extends React.Component<AppProps, AppState> {
} }
return [ return [
CONTEXT_MENU_SEPARATOR,
actionCut, actionCut,
actionCopy, actionCopy,
actionPaste, actionPaste,
@ -9643,6 +9642,7 @@ class App extends React.Component<AppProps, AppState> {
actionPasteStyles, actionPasteStyles,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionGroup, actionGroup,
actionTextAutoResize,
actionUnbindText, actionUnbindText,
actionBindText, actionBindText,
actionWrapTextInContainer, actionWrapTextInContainer,

@ -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`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "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`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "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`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id48", "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`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id37", "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`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id41", "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`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "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`] = ` exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
@ -1194,6 +1201,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
exports[`Test Transform > should transform text element 1`] = ` exports[`Test Transform > should transform text element 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@ -1234,6 +1242,7 @@ exports[`Test Transform > should transform text element 1`] = `
exports[`Test Transform > should transform text element 2`] = ` exports[`Test Transform > should transform text element 2`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": 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`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "B", "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`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "A", "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`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "Alice", "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`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "Bob", "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`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "Bob_Alice", "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`] = ` exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "Bob_B", "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`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id25", "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`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id26", "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`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id27", "containerId": "id27",
@ -2143,6 +2161,7 @@ LABELLED ARROW",
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = ` exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id28", "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`] = ` exports[`Test Transform > should transform to text containers when label provided 7`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id13", "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`] = ` exports[`Test Transform > should transform to text containers when label provided 8`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id14", "containerId": "id14",
@ -2487,6 +2508,7 @@ CONTAINER",
exports[`Test Transform > should transform to text containers when label provided 9`] = ` exports[`Test Transform > should transform to text containers when label provided 9`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id15", "containerId": "id15",
@ -2530,6 +2552,7 @@ CONTAINER",
exports[`Test Transform > should transform to text containers when label provided 10`] = ` exports[`Test Transform > should transform to text containers when label provided 10`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id16", "containerId": "id16",
@ -2571,6 +2594,7 @@ TEXT CONTAINER",
exports[`Test Transform > should transform to text containers when label provided 11`] = ` exports[`Test Transform > should transform to text containers when label provided 11`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id17", "containerId": "id17",
@ -2613,6 +2637,7 @@ CONTAINER",
exports[`Test Transform > should transform to text containers when label provided 12`] = ` exports[`Test Transform > should transform to text containers when label provided 12`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id18", "containerId": "id18",

@ -208,7 +208,7 @@ const restoreElement = (
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,
originalText: element.originalText || text, originalText: element.originalText || text,
autoResize: element.autoResize ?? true,
lineHeight, lineHeight,
}); });

@ -9,7 +9,6 @@ import { isLinearElementType } from "./typeChecks";
export { export {
newElement, newElement,
newTextElement, newTextElement,
updateTextElement,
refreshTextDimensions, refreshTextDimensions,
newLinearElement, newLinearElement,
newImageElement, newImageElement,

@ -240,24 +240,28 @@ export const newTextElement = (
metrics, metrics,
); );
const textElement = newElementWith( const textElementProps: ExcalidrawTextElement = {
{ ..._newElementBase<ExcalidrawTextElement>("text", opts),
..._newElementBase<ExcalidrawTextElement>("text", opts), text,
text, fontSize,
fontSize, fontFamily,
fontFamily, textAlign,
textAlign, verticalAlign,
verticalAlign, x: opts.x - offsets.x,
x: opts.x - offsets.x, y: opts.y - offsets.y,
y: opts.y - offsets.y, width: metrics.width,
width: metrics.width, height: metrics.height,
height: metrics.height, containerId: opts.containerId || null,
containerId: opts.containerId || null, originalText: text,
originalText: text, autoResize: true,
lineHeight, lineHeight,
}, };
const textElement: ExcalidrawTextElement = newElementWith(
textElementProps,
{}, {},
); );
return textElement; return textElement;
}; };
@ -271,18 +275,25 @@ const getAdjustedDimensions = (
width: number; width: number;
height: number; height: number;
} => { } => {
const { width: nextWidth, height: nextHeight } = measureText( let { width: nextWidth, height: nextHeight } = measureText(
nextText, nextText,
getFontString(element), getFontString(element),
element.lineHeight, element.lineHeight,
); );
// wrapped text
if (!element.autoResize) {
nextWidth = element.width;
}
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
let x: number; let x: number;
let y: number; let y: number;
if ( if (
textAlign === "center" && textAlign === "center" &&
verticalAlign === VERTICAL_ALIGN.MIDDLE && verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId !element.containerId &&
element.autoResize
) { ) {
const prevMetrics = measureText( const prevMetrics = measureText(
element.text, element.text,
@ -343,38 +354,19 @@ export const refreshTextDimensions = (
if (textElement.isDeleted) { if (textElement.isDeleted) {
return; return;
} }
if (container) { if (container || !textElement.autoResize) {
text = wrapText( text = wrapText(
text, text,
getFontString(textElement), getFontString(textElement),
getBoundTextMaxWidth(container, textElement), container
? getBoundTextMaxWidth(container, textElement)
: textElement.width,
); );
} }
const dimensions = getAdjustedDimensions(textElement, elementsMap, text); const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
return { text, ...dimensions }; 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 = ( export const newFreeDrawElement = (
opts: { opts: {
type: "freedraw"; type: "freedraw";

@ -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 { rescalePoints } from "../points";
import { rotate, centerPoint, rotatePoint } from "../math"; import { rotate, centerPoint, rotatePoint } from "../math";
@ -45,6 +49,9 @@ import {
handleBindTextResize, handleBindTextResize,
getBoundTextMaxWidth, getBoundTextMaxWidth,
getApproxMinLineHeight, getApproxMinLineHeight,
wrapText,
measureText,
getMinCharWidth,
} from "./textElement"; } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups"; import { isInGroup } from "../groups";
@ -84,14 +91,9 @@ export const transformElements = (
shouldRotateWithDiscreteAngle, shouldRotateWithDiscreteAngle,
); );
updateBoundElements(element, elementsMap); updateBoundElements(element, elementsMap);
} else if ( } else if (isTextElement(element) && transformHandleType) {
isTextElement(element) &&
(transformHandleType === "nw" ||
transformHandleType === "ne" ||
transformHandleType === "sw" ||
transformHandleType === "se")
) {
resizeSingleTextElement( resizeSingleTextElement(
originalElements,
element, element,
elementsMap, elementsMap,
transformHandleType, transformHandleType,
@ -223,9 +225,10 @@ const measureFontSizeFromWidth = (
}; };
const resizeSingleTextElement = ( const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"],
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
transformHandleType: "nw" | "ne" | "sw" | "se", transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
@ -245,17 +248,19 @@ const resizeSingleTextElement = (
let scaleX = 0; let scaleX = 0;
let scaleY = 0; let scaleY = 0;
if (transformHandleType.includes("e")) { if (transformHandleType !== "e" && transformHandleType !== "w") {
scaleX = (rotatedX - x1) / (x2 - x1); if (transformHandleType.includes("e")) {
} scaleX = (rotatedX - x1) / (x2 - x1);
if (transformHandleType.includes("w")) { }
scaleX = (x2 - rotatedX) / (x2 - x1); if (transformHandleType.includes("w")) {
} scaleX = (x2 - rotatedX) / (x2 - x1);
if (transformHandleType.includes("n")) { }
scaleY = (y2 - rotatedY) / (y2 - y1); if (transformHandleType.includes("n")) {
} scaleY = (y2 - rotatedY) / (y2 - y1);
if (transformHandleType.includes("s")) { }
scaleY = (rotatedY - y1) / (y2 - y1); if (transformHandleType.includes("s")) {
scaleY = (rotatedY - y1) / (y2 - y1);
}
} }
const scale = Math.max(scaleX, scaleY); const scale = Math.max(scaleX, scaleY);
@ -318,6 +323,102 @@ const resizeSingleTextElement = (
y: nextY, 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<ExcalidrawTextElement> = {
width: Math.abs(newWidth),
height: Math.abs(metrics.height),
x: newTopLeft[0],
y: newTopLeft[1],
text,
autoResize: false,
};
mutateElement(element, resizedElement);
}
}; };
export const resizeSingleElement = ( export const resizeSingleElement = (

@ -87,12 +87,8 @@ export const resizeTest = (
elementsMap, elementsMap,
); );
// Note that for a text element, when "resized" from the side // do not resize from the sides for linear elements with only two points
// we should make it wrap/unwrap if (!(isLinearElement(element) && element.points.length <= 2)) {
if (
element.type !== "text" &&
!(isLinearElement(element) && element.points.length <= 2)
) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders( const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING], [x1 - SPACING, y1 - SPACING],

@ -48,7 +48,7 @@ export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
elementsMap: ElementsMap, elementsMap: ElementsMap,
informMutation: boolean = true, informMutation = true,
) => { ) => {
let maxWidth = undefined; let maxWidth = undefined;
const boundTextUpdates = { const boundTextUpdates = {
@ -62,21 +62,27 @@ export const redrawTextBoundingBox = (
boundTextUpdates.text = textElement.text; boundTextUpdates.text = textElement.text;
if (container) { if (container || !textElement.autoResize) {
maxWidth = getBoundTextMaxWidth(container, textElement); maxWidth = container
? getBoundTextMaxWidth(container, textElement)
: textElement.width;
boundTextUpdates.text = wrapText( boundTextUpdates.text = wrapText(
textElement.originalText, textElement.originalText,
getFontString(textElement), getFontString(textElement),
maxWidth, maxWidth,
); );
} }
const metrics = measureText( const metrics = measureText(
boundTextUpdates.text, boundTextUpdates.text,
getFontString(textElement), getFontString(textElement),
textElement.lineHeight, 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; boundTextUpdates.height = metrics.height;
if (container) { if (container) {

@ -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(<Excalidraw handleKeyboardGlobally={true} />);
// @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", () => { describe("Test container-unbound text", () => {
const { h } = window; const { h } = window;
const dimensions = { height: 400, width: 800 }; const dimensions = { height: 400, width: 800 };
@ -800,26 +911,15 @@ describe("textWysiwyg", () => {
mouse.down(); mouse.down();
const text = h.elements[1] as ExcalidrawTextElementWithContainer; 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)); await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!"); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil); 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)); fireEvent.click(screen.getByTitle(/code/i));
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily, (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia); ).toEqual(FONT_FAMILY.Cascadia);

@ -79,12 +79,14 @@ export const textWysiwyg = ({
app, app,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
onChange?: (text: string) => void; /**
onSubmit: (data: { * textWysiwyg only deals with `originalText`
text: string; *
viaKeyboard: boolean; * Note: `text`, which can be wrapped and therefore different from `originalText`,
originalText: string; * is derived from `originalText`
}) => void; */
onChange?: (nextOriginalText: string) => void;
onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void;
getViewportCoords: (x: number, y: number) => [number, number]; getViewportCoords: (x: number, y: number) => [number, number];
element: ExcalidrawTextElement; element: ExcalidrawTextElement;
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@ -129,11 +131,8 @@ export const textWysiwyg = ({
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
let maxWidth = updatedTextElement.width; let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height; let maxHeight = updatedTextElement.height;
let textElementWidth = updatedTextElement.width; 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; const textElementHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) { if (container && updatedTextElement.containerId) {
@ -262,6 +261,7 @@ export const textWysiwyg = ({
if (isTestEnv()) { if (isTestEnv()) {
editable.style.fontFamily = getFontFamilyString(updatedTextElement); editable.style.fontFamily = getFontFamilyString(updatedTextElement);
} }
mutateElement(updatedTextElement, { x: coordX, y: coordY }); mutateElement(updatedTextElement, { x: coordX, y: coordY });
} }
}; };
@ -278,7 +278,7 @@ export const textWysiwyg = ({
let whiteSpace = "pre"; let whiteSpace = "pre";
let wordBreak = "normal"; let wordBreak = "normal";
if (isBoundToContainer(element)) { if (isBoundToContainer(element) || !element.autoResize) {
whiteSpace = "pre-wrap"; whiteSpace = "pre-wrap";
wordBreak = "break-word"; wordBreak = "break-word";
} }
@ -501,14 +501,12 @@ export const textWysiwyg = ({
if (!updateElement) { if (!updateElement) {
return; return;
} }
let text = editable.value;
const container = getContainerElement( const container = getContainerElement(
updateElement, updateElement,
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
); );
if (container) { if (container) {
text = updateElement.text;
if (editable.value.trim()) { if (editable.value.trim()) {
const boundTextElementId = getBoundTextElementId(container); const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId || boundTextElementId !== element.id) { if (!boundTextElementId || boundTextElementId !== element.id) {
@ -540,9 +538,8 @@ export const textWysiwyg = ({
} }
onSubmit({ onSubmit({
text,
viaKeyboard: submittedViaKeyboard, viaKeyboard: submittedViaKeyboard,
originalText: editable.value, nextOriginalText: editable.value,
}); });
}; };

@ -9,7 +9,6 @@ import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from ".";
import { isFrameLikeElement, isLinearElement } from "./typeChecks"; import { isFrameLikeElement, isLinearElement } from "./typeChecks";
import { import {
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
@ -65,13 +64,6 @@ export const OMIT_SIDES_FOR_FRAME = {
rotation: true, rotation: true,
}; };
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
e: true,
s: true,
n: true,
w: true,
};
const OMIT_SIDES_FOR_LINE_SLASH = { const OMIT_SIDES_FOR_LINE_SLASH = {
e: true, e: true,
s: true, s: true,
@ -290,8 +282,6 @@ export const getTransformHandles = (
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH; omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
} }
} }
} else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameLikeElement(element)) { } else if (isFrameLikeElement(element)) {
omitSides = { omitSides = {
...omitSides, ...omitSides,

@ -193,6 +193,13 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null; containerId: ExcalidrawGenericElement["id"] | null;
originalText: string; 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 * Unitless line height (aligned to W3C). To get line height in px, multiply
* with font size (using `getLineHeightInPx` helper). * with font size (using `getLineHeightInPx` helper).

@ -149,7 +149,8 @@
"zoomToFitViewport": "Zoom to fit in viewport", "zoomToFitViewport": "Zoom to fit in viewport",
"zoomToFitSelection": "Zoom to fit selection", "zoomToFitSelection": "Zoom to fit selection",
"zoomToFit": "Zoom to fit all elements", "zoomToFit": "Zoom to fit all elements",
"installPWA": "Install Excalidraw locally (PWA)" "installPWA": "Install Excalidraw locally (PWA)",
"autoResize": "Enable text auto-resizing"
}, },
"library": { "library": {
"noItems": "No items added yet...", "noItems": "No items added yet...",

@ -12,6 +12,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@ -326,6 +327,16 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",
@ -4414,6 +4425,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@ -4728,6 +4740,16 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",
@ -5514,6 +5536,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@ -5828,6 +5851,16 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",
@ -7321,6 +7354,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@ -7635,6 +7669,16 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",
@ -8188,6 +8232,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
"separator",
{ {
"icon": <svg "icon": <svg
aria-hidden="true" aria-hidden="true"
@ -8502,6 +8547,16 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element", "category": "element",
}, },
}, },
{
"icon": null,
"label": "labels.autoResize",
"name": "autoResize",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
{ {
"label": "labels.unbindText", "label": "labels.unbindText",
"name": "unbindText", "name": "unbindText",

@ -2584,6 +2584,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the container is added through the history > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the container is added through the history > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@ -2624,6 +2625,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the container is added through the history > [end of test] element 2 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the container is added through the history > [end of test] element 2 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id138", "containerId": "id138",
@ -2873,6 +2875,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the text is added through history > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the text is added through history > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id141", "containerId": "id141",
@ -2913,6 +2916,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the text is added through history > [end of test] element 2 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should preserve latest remotely added binding and unbind previous one when the text is added through history > [end of test] element 2 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id141", "containerId": "id141",
@ -3147,6 +3151,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the container got bound to a different text in the meantime > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the container got bound to a different text in the meantime > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id128", "containerId": "id128",
@ -3187,6 +3192,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the container got bound to a different text in the meantime > [end of test] element 2 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the container got bound to a different text in the meantime > [end of test] element 2 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@ -3463,6 +3469,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the text got bound to a different container in the meantime > [end of test] element 2 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and the text got bound to a different container in the meantime > [end of test] element 2 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id133", "containerId": "id133",
@ -3703,6 +3710,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and there no conflicting updates in the meantime > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind bindings when both are updated through the history and there no conflicting updates in the meantime > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@ -3934,6 +3942,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind remotely added bound text when it's container is added through the history > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind remotely added bound text when it's container is added through the history > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id134", "containerId": "id134",
@ -4184,6 +4193,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind remotely added container when it's bound text is added through the history > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should rebind remotely added container when it's bound text is added through the history > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id136", "containerId": "id136",
@ -4244,6 +4254,7 @@ History {
"id137" => Delta { "id137" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id136", "containerId": "id136",
@ -4447,6 +4458,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should redraw bound text to match container dimensions when the bound text is updated through the history > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should redraw bound text to match container dimensions when the bound text is updated through the history > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id150", "containerId": "id150",
@ -4669,6 +4681,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should redraw remotely added bound text when it's container is updated through the history > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should redraw remotely added bound text when it's container is updated through the history > [end of test] element 1 1`] = `
{ {
"angle": 90, "angle": 90,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id148", "containerId": "id148",
@ -4886,6 +4899,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should unbind remotely deleted bound text from container when the container is added through the history > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should unbind remotely deleted bound text from container when the container is added through the history > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id144", "containerId": "id144",
@ -5111,6 +5125,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should unbind remotely deleted container from bound text when the text is added through the history > [end of test] element 1 1`] = ` exports[`history > multiplayer undo/redo > conflicts in bound text elements and their containers > should unbind remotely deleted container from bound text when the text is added through the history > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@ -13502,6 +13517,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] element 1 1`] = ` exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on deletion and rebind on undo > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id50", "containerId": "id50",
@ -13761,6 +13777,7 @@ History {
"id51" => Delta { "id51" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@ -14182,6 +14199,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] element 1 1`] = ` exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind arrow from non deleted bindable elements on undo and rebind on redo > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id44", "containerId": "id44",
@ -14365,6 +14383,7 @@ History {
"id45" => Delta { "id45" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@ -14786,6 +14805,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] element 1 1`] = ` exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id56", "containerId": "id56",
@ -14969,6 +14989,7 @@ History {
"id57" => Delta { "id57" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@ -15388,6 +15409,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] element 1 1`] = ` exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangle from arrow on deletion and rebind on undo > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id62", "containerId": "id62",
@ -15641,6 +15663,7 @@ History {
"id63" => Delta { "id63" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,
@ -16086,6 +16109,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] element 1 1`] = ` exports[`history > singleplayer undo/redo > should support bidirectional bindings > should unbind rectangles from arrow on deletion and rebind on undo > [end of test] element 1 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id69", "containerId": "id69",
@ -16354,6 +16378,7 @@ History {
"id70" => Delta { "id70" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": null, "containerId": null,

@ -302,6 +302,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
exports[`restoreElements > should restore text element correctly passing value for each attribute 1`] = ` exports[`restoreElements > should restore text element correctly passing value for each attribute 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [], "boundElements": [],
"containerId": null, "containerId": null,
@ -344,6 +345,7 @@ exports[`restoreElements > should restore text element correctly passing value f
exports[`restoreElements > should restore text element correctly with unknown font family, null text and undefined alignment 1`] = ` exports[`restoreElements > should restore text element correctly with unknown font family, null text and undefined alignment 1`] = `
{ {
"angle": 0, "angle": 0,
"autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [], "boundElements": [],
"containerId": null, "containerId": null,

@ -972,10 +972,10 @@ describe("Test Linear Elements", () => {
]); ]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
"Online whiteboard "Online whiteboard
collaboration made collaboration made
easy" easy"
`); `);
}); });
it("should bind text to arrow when clicked on arrow and enter pressed", async () => { it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
@ -1006,10 +1006,10 @@ describe("Test Linear Elements", () => {
]); ]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
"Online whiteboard "Online whiteboard
collaboration made collaboration made
easy" easy"
`); `);
}); });
it("should not bind text to line when double clicked", async () => { it("should not bind text to line when double clicked", async () => {

@ -426,6 +426,112 @@ describe("text element", () => {
expect(text.fontSize).toBe(fontSize); expect(text.fontSize).toBe(fontSize);
}); });
}); });
// text can be resized from sides
it("can be resized from e", async () => {
const text = UI.createElement("text");
await UI.editText(text, "Excalidraw\nEditor");
const width = text.width;
const height = text.height;
UI.resize(text, "e", [30, 0]);
expect(text.width).toBe(width + 30);
expect(text.height).toBe(height);
UI.resize(text, "e", [-30, 0]);
expect(text.width).toBe(width);
expect(text.height).toBe(height);
});
it("can be resized from w", async () => {
const text = UI.createElement("text");
await UI.editText(text, "Excalidraw\nEditor");
const width = text.width;
const height = text.height;
UI.resize(text, "w", [-50, 0]);
expect(text.width).toBe(width + 50);
expect(text.height).toBe(height);
UI.resize(text, "w", [50, 0]);
expect(text.width).toBe(width);
expect(text.height).toBe(height);
});
it("wraps when width is narrower than texts inside", async () => {
const text = UI.createElement("text");
await UI.editText(text, "Excalidraw\nEditor");
const prevWidth = text.width;
const prevHeight = text.height;
const prevText = text.text;
UI.resize(text, "w", [50, 0]);
expect(text.width).toBe(prevWidth - 50);
expect(text.height).toBeGreaterThan(prevHeight);
expect(text.text).not.toEqual(prevText);
expect(text.autoResize).toBe(false);
UI.resize(text, "w", [-50, 0]);
expect(text.width).toBe(prevWidth);
expect(text.height).toEqual(prevHeight);
expect(text.text).toEqual(prevText);
expect(text.autoResize).toBe(false);
UI.resize(text, "e", [-20, 0]);
expect(text.width).toBe(prevWidth - 20);
expect(text.height).toBeGreaterThan(prevHeight);
expect(text.text).not.toEqual(prevText);
expect(text.autoResize).toBe(false);
UI.resize(text, "e", [20, 0]);
expect(text.width).toBe(prevWidth);
expect(text.height).toEqual(prevHeight);
expect(text.text).toEqual(prevText);
expect(text.autoResize).toBe(false);
});
it("keeps properties when wrapped", async () => {
const text = UI.createElement("text");
await UI.editText(text, "Excalidraw\nEditor");
const alignment = text.textAlign;
const fontSize = text.fontSize;
const fontFamily = text.fontFamily;
UI.resize(text, "e", [-60, 0]);
expect(text.textAlign).toBe(alignment);
expect(text.fontSize).toBe(fontSize);
expect(text.fontFamily).toBe(fontFamily);
expect(text.autoResize).toBe(false);
UI.resize(text, "e", [60, 0]);
expect(text.textAlign).toBe(alignment);
expect(text.fontSize).toBe(fontSize);
expect(text.fontFamily).toBe(fontFamily);
expect(text.autoResize).toBe(false);
});
it("has a minimum width when wrapped", async () => {
const text = UI.createElement("text");
await UI.editText(text, "Excalidraw\nEditor");
const width = text.width;
UI.resize(text, "e", [-width, 0]);
expect(text.width).not.toEqual(0);
UI.resize(text, "e", [width - text.width, 0]);
expect(text.width).toEqual(width);
expect(text.autoResize).toBe(false);
UI.resize(text, "w", [width, 0]);
expect(text.width).not.toEqual(0);
UI.resize(text, "w", [text.width - width, 0]);
expect(text.width).toEqual(width);
expect(text.autoResize).toBe(false);
});
}); });
describe("image element", () => { describe("image element", () => {

Loading…
Cancel
Save