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>
This commit is contained in:
Ryan Di 2024-05-15 21:04:53 +08:00 committed by GitHub
parent cc4c51996c
commit 971b4d4ae6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 596 additions and 143 deletions

View file

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

View file

@ -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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": 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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": 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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": 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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id136",
@ -4244,6 +4254,7 @@ History {
"id137" => Delta {
"deleted": {
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 90,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": 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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id50",
@ -13761,6 +13777,7 @@ History {
"id51" => Delta {
"deleted": {
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": 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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id44",
@ -14365,6 +14383,7 @@ History {
"id45" => Delta {
"deleted": {
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": 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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id56",
@ -14969,6 +14989,7 @@ History {
"id57" => Delta {
"deleted": {
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": 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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id62",
@ -15641,6 +15663,7 @@ History {
"id63" => Delta {
"deleted": {
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": 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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id69",
@ -16354,6 +16378,7 @@ History {
"id70" => Delta {
"deleted": {
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": null,

View file

@ -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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": [],
"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`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": [],
"containerId": null,

View file

@ -972,10 +972,10 @@ describe("Test Linear Elements", () => {
]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
"Online whiteboard
collaboration made
easy"
`);
});
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)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
"Online whiteboard
collaboration made
easy"
`);
});
it("should not bind text to line when double clicked", async () => {

View file

@ -426,6 +426,112 @@ describe("text element", () => {
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", () => {