feat: Support labels for arrow 🔥 (#5723)

* feat: support arrow with text

* render arrow -> clear rect-> render text

* move bound text when linear elements move

* fix centering cursor when linear element rotated

* fix y coord when new line added and container has 3 points

* update text position when 2nd point moved

* support adding label on top of 2nd point when 3 points are present

* change linear element editor shortcut to cmd+enter and fix tests

* scale bound text points when resizing via bounding box

* ohh yeah rotation works :)

* fix coords when updating text properties

* calculate new position after rotation always from original position

* rotate the bound text by same angle as parent

* don't rotate text and make sure dimensions and coords are always calculated from original point

* hardcoding the text width for now

* Move the linear element when bound text hit

* Rotation working yaay

* consider text element angle when editing

* refactor

* update x2 coords if needed when text updated

* simplify

* consider bound text to be part of bounding box when hit

* show bounding box correctly when multiple element selected

* fix typo

* support rotating multiple elements

* support multiple element resizing

* shift bound text to mid point when odd points

* Always render linear element handles inside editor after element rendered so point is visible for bound text

* Delete bound text when point attached to it deleted

* move bound to mid segement mid point when points are even

* shift bound text when points nearby deleted and handle segment deletion

* Resize working :)

* more resize fixes

* don't update cache-its breaking delete points, look for better soln

* update mid point cache for bound elements when updated

* introduce wrapping when resizing

* wrap when resize for 2 pointer linear elements

* support adding text for linear elements with more than 3 points

* export to svg  working :)

* clip from nearest enclosing element with non transparent color if present when exporting and fill with correct color in canvas

* fix snap

* use visible elements

* Make export to svg work with Mask :)

* remove id

* mask canvas linear element area where label is added

* decide the position of bound text during render

* fix coords when editing

* fix multiple resize

* update cache when bound text version changes

* fix masking when rotated

* render text in correct position in preview

* remove unnecessary code

* fix masking when rotating linear element

* fix masking with zoom

* fix mask in preview for export

* fix offsets in export view

* fix coords on svg export

* fix mask when element rotated in svg

* enable double-click to enter text

* fix hint

* Position cursor correctly and text dimensiosn when height of element is negative

* don't allow 2 pointer linear element with bound text width to go beyond min width

* code cleanup

* fix freedraw

* Add padding

* don't show vertical align action for linear element containers

* Add specs for getBoundTextElementPosition

* more specs

* move some utils to linearElementEditor.ts

* remove only :p

* check absoulte coods in test

* Add test to hide vertical align for linear eleemnt with bound text

* improve export preview

* support labels only for arrows

* spec

* fix large texts

* fix tests

* fix zooming

* enter line editor with cmd+double click

* Allow points to move beyond min width/height for 2 pointer arrow with bound text

* fix hint for line editing

* attempt to fix arrow getting deselected

* fix hint and shortcut

* Add padding of 5px when creating bound text and add spec

* Wrap bound text when arrow binding containers moved

* Add spec

* remove

* set boundTextElementVersion to null if not present

* dont use cache when version mismatch

* Add a padding of 5px vertically when creating text

* Add box sizing content box

* Set bound elements when text element created to fix the padding

* fix zooming in editor

* fix zoom in export

* remove globalCompositeOperation and use clearRect instead of fillRect
This commit is contained in:
Aakansha Doshi 2022-12-05 21:03:13 +05:30 committed by GitHub
parent 1933116261
commit 760fd7b3a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1668 additions and 363 deletions

View file

@ -1,20 +1,30 @@
import ReactDOM from "react-dom";
import { ExcalidrawLinearElement } from "../element/types";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElementWithContainer,
FontString,
} from "../element/types";
import ExcalidrawApp from "../excalidraw-app";
import { centerPoint } from "../math";
import { reseed } from "../random";
import * as Renderer from "../renderer/renderScene";
import { Keyboard, Pointer } from "./helpers/ui";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
import { API } from "../tests/helpers/api";
import { Point } from "../types";
import { KEYS } from "../keys";
import { LinearElementEditor } from "../element/linearElementEditor";
import { queryByText } from "@testing-library/react";
import { queryByTestId, queryByText } from "@testing-library/react";
import { resize, rotate } from "./utils";
import { getBoundTextElementPosition, wrapText } from "../element/textElement";
import { getMaxContainerWidth } from "../element/newElement";
import * as textElementUtils from "../element/textElement";
const renderScene = jest.spyOn(Renderer, "renderScene");
const { h } = window;
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
describe("Test Linear Elements", () => {
let container: HTMLElement;
@ -44,23 +54,23 @@ describe("Test Linear Elements", () => {
strokeSharpness: ExcalidrawLinearElement["strokeSharpness"] = "sharp",
roughness: ExcalidrawLinearElement["roughness"] = 0,
) => {
h.elements = [
API.createElement({
x: p1[0],
y: p1[1],
width: p2[0] - p1[0],
height: 0,
type,
roughness,
points: [
[0, 0],
[p2[0] - p1[0], p2[1] - p1[1]],
],
strokeSharpness,
}),
];
const line = API.createElement({
x: p1[0],
y: p1[1],
width: p2[0] - p1[0],
height: 0,
type,
roughness,
points: [
[0, 0],
[p2[0] - p1[0], p2[1] - p1[1]],
],
strokeSharpness,
});
h.elements = [line];
mouse.clickAt(p1[0], p1[1]);
return line;
};
const createThreePointerLinearElement = (
@ -70,23 +80,23 @@ describe("Test Linear Elements", () => {
) => {
//dragging line from midpoint
const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]];
h.elements = [
API.createElement({
x: p1[0],
y: p1[1],
width: p3[0] - p1[0],
height: 0,
type,
roughness,
points: [
[0, 0],
[p3[0], p3[1]],
[p2[0] - p1[0], p2[1] - p1[1]],
],
strokeSharpness,
}),
];
const line = API.createElement({
x: p1[0],
y: p1[1],
width: p3[0] - p1[0],
height: 0,
type,
roughness,
points: [
[0, 0],
[p3[0], p3[1]],
[p2[0] - p1[0], p2[1] - p1[1]],
],
strokeSharpness,
});
h.elements = [line];
mouse.clickAt(p1[0], p1[1]);
return line;
};
const enterLineEditingMode = (
@ -98,7 +108,9 @@ describe("Test Linear Elements", () => {
} else {
mouse.clickAt(p1[0], p1[1]);
}
Keyboard.keyPress(KEYS.ENTER);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
};
@ -216,6 +228,16 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
});
it("should enter line editor when using double clicked with ctrl key", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
describe("Inside editor", () => {
it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
createTwoPointerLinearElement("line");
@ -358,8 +380,8 @@ describe("Test Linear Elements", () => {
let line: ExcalidrawLinearElement;
beforeEach(() => {
createThreePointerLinearElement("line");
line = h.elements[0] as ExcalidrawLinearElement;
line = createThreePointerLinearElement("line");
expect(line.points.length).toEqual(3);
enterLineEditingMode(line);
@ -478,7 +500,7 @@ describe("Test Linear Elements", () => {
// delete 3rd point
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderScene).toHaveBeenCalledTimes(21);
expect(renderScene).toHaveBeenCalledTimes(22);
const newMidPoints = LinearElementEditor.getEditorMidPoints(
line,
@ -503,8 +525,7 @@ describe("Test Linear Elements", () => {
let line: ExcalidrawLinearElement;
beforeEach(() => {
createThreePointerLinearElement("line", "round");
line = h.elements[0] as ExcalidrawLinearElement;
line = createThreePointerLinearElement("line", "round");
expect(line.points.length).toEqual(3);
enterLineEditingMode(line);
@ -667,7 +688,6 @@ describe("Test Linear Elements", () => {
fillStyle: "solid",
}),
];
const origPoints = line.points.map((point) => [...point]);
const dragEndPositionOffset = [100, 100] as const;
API.setSelectedElements([line]);
enterLineEditingMode(line, true);
@ -682,11 +702,457 @@ describe("Test Linear Elements", () => {
0,
],
Array [
${origPoints[1][0] - dragEndPositionOffset[0]},
${origPoints[1][1] - dragEndPositionOffset[1]},
-60,
-100,
],
]
`);
});
});
describe("Test bound text element", () => {
const DEFAULT_TEXT = "Online whiteboard collaboration made easy";
const createBoundTextElement = (
text: string,
container: ExcalidrawLinearElement,
) => {
const textElement = API.createElement({
type: "text",
x: 0,
y: 0,
text: wrapText(text, font, getMaxContainerWidth(container)),
containerId: container.id,
width: 30,
height: 20,
}) as ExcalidrawTextElementWithContainer;
container = {
...container,
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
};
const elements: ExcalidrawElement[] = [];
h.elements.forEach((element) => {
if (element.id === container.id) {
elements.push(container);
} else {
elements.push(element);
}
});
const updatedTextElement = { ...textElement, originalText: text };
h.elements = [...elements, updatedTextElement];
return { textElement: updatedTextElement, container };
};
describe("Test getBoundTextElementPosition", () => {
it("should return correct position for 2 pointer arrow", () => {
createTwoPointerLinearElement("arrow");
const arrow = h.elements[0] as ExcalidrawLinearElement;
const { textElement, container } = createBoundTextElement(
DEFAULT_TEXT,
arrow,
);
const position = LinearElementEditor.getBoundTextElementPosition(
container,
textElement,
);
expect(position).toMatchInlineSnapshot(`
Object {
"x": 25,
"y": 10,
}
`);
});
it("should return correct position for arrow with odd points", () => {
createThreePointerLinearElement("arrow", "round");
const arrow = h.elements[0] as ExcalidrawLinearElement;
const { textElement, container } = createBoundTextElement(
DEFAULT_TEXT,
arrow,
);
const position = LinearElementEditor.getBoundTextElementPosition(
container,
textElement,
);
expect(position).toMatchInlineSnapshot(`
Object {
"x": 75,
"y": 60,
}
`);
});
it("should return correct position for arrow with even points", () => {
createThreePointerLinearElement("arrow", "round");
const arrow = h.elements[0] as ExcalidrawLinearElement;
const { textElement, container } = createBoundTextElement(
DEFAULT_TEXT,
arrow,
);
enterLineEditingMode(container);
// This is the expected midpoint for line with round edge
// hence hardcoding it so if later some bug is introduced
// this will fail and we can fix it
const firstSegmentMidpoint: Point = [
55.9697848965255, 47.442326230998205,
];
// drag line from first segment midpoint
drag(firstSegmentMidpoint, [
firstSegmentMidpoint[0] + delta,
firstSegmentMidpoint[1] + delta,
]);
const position = LinearElementEditor.getBoundTextElementPosition(
container,
textElement,
);
expect(position).toMatchInlineSnapshot(`
Object {
"x": 85.82201843191861,
"y": 75.63461309860818,
}
`);
});
});
it("should bind text to arrow when double clicked", async () => {
createTwoPointerLinearElement("arrow");
const arrow = h.elements[0] as ExcalidrawLinearElement;
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(arrow.id);
mouse.doubleClickAt(arrow.x, arrow.y);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(arrow.id);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: { value: DEFAULT_TEXT },
});
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(arrow.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
});
it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
const arrow = createTwoPointerLinearElement("arrow");
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(arrow.id);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(2);
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(textElement.type).toBe("text");
expect(textElement.containerId).toBe(arrow.id);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, {
target: { value: DEFAULT_TEXT },
});
editor.blur();
expect(arrow.boundElements).toStrictEqual([
{ id: textElement.id, type: "text" },
]);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
});
it("should not bind text to line when double clicked", async () => {
const line = createTwoPointerLinearElement("line");
expect(h.elements.length).toBe(1);
mouse.doubleClickAt(line.x, line.y);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBeNull();
expect(line.boundElements).toBeNull();
});
it("should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", () => {
createThreePointerLinearElement("arrow", "round");
const arrow = h.elements[0] as ExcalidrawLinearElement;
const { textElement, container } = createBoundTextElement(
DEFAULT_TEXT,
arrow,
);
expect(container.angle).toBe(0);
expect(textElement.angle).toBe(0);
expect(getBoundTextElementPosition(arrow, textElement))
.toMatchInlineSnapshot(`
Object {
"x": 75,
"y": 60,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
.toMatchInlineSnapshot(`
Array [
20,
20,
105,
80,
55.45893770831013,
45,
]
`);
rotate(container, -35, 55);
expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`);
expect(textElement.angle).toBe(0);
expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(`
Object {
"x": 21.73926141863671,
"y": 73.31003398390868,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
.toMatchInlineSnapshot(`
Array [
20,
20,
102.41961302274555,
86.49012635273976,
55.45893770831013,
45,
]
`);
});
it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => {
createThreePointerLinearElement("arrow", "round");
const arrow = h.elements[0] as ExcalidrawLinearElement;
const { textElement, container } = createBoundTextElement(
DEFAULT_TEXT,
arrow,
);
expect(container.width).toBe(70);
expect(container.height).toBe(50);
expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(`
Object {
"x": 75,
"y": 60,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
.toMatchInlineSnapshot(`
Array [
20,
20,
105,
80,
55.45893770831013,
45,
]
`);
resize(container, "ne", [300, 200]);
expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(`
Object {
"height": 10,
"width": 367,
}
`);
expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(`
Object {
"x": 386.5,
"y": 70,
}
`);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
.toMatchInlineSnapshot(`
"Online whiteboard
collaboration made easy"
`);
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
.toMatchInlineSnapshot(`
Array [
20,
60,
391.8122896842806,
70,
205.9061448421403,
65,
]
`);
});
it("should resize and position the bound text correctly when 2 pointer linear element resized", () => {
createTwoPointerLinearElement("arrow");
const arrow = h.elements[0] as ExcalidrawLinearElement;
const { textElement, container } = createBoundTextElement(
DEFAULT_TEXT,
arrow,
);
expect(container.width).toBe(40);
expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(`
Object {
"x": 25,
"y": 10,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
const points = LinearElementEditor.getPointsGlobalCoordinates(container);
// Drag from last point
drag(points[1], [points[1][0] + 300, points[1][1]]);
expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(`
Object {
"height": 0,
"width": 340,
}
`);
expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(`
Object {
"x": 189.5,
"y": 20,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made easy"
`);
});
it("should not render vertical align tool when element selected", () => {
createTwoPointerLinearElement("arrow");
const arrow = h.elements[0] as ExcalidrawLinearElement;
createBoundTextElement(DEFAULT_TEXT, arrow);
API.setSelectedElements([arrow]);
expect(queryByTestId(container, "align-top")).toBeNull();
expect(queryByTestId(container, "align-middle")).toBeNull();
expect(queryByTestId(container, "align-bottom")).toBeNull();
});
it("should wrap the bound text when arrow bound container moves", async () => {
const rect = UI.createElement("rectangle", {
x: 400,
width: 200,
height: 500,
});
const arrow = UI.createElement("arrow", {
x: 210,
y: 250,
width: 400,
height: 1,
});
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
editor.blur();
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBe(400);
expect(rect.x).toBe(400);
expect(rect.y).toBe(0);
expect(
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
).toMatchInlineSnapshot(`
"Online whiteboard collaboration
made easy"
`);
const handleBindTextResizeSpy = jest.spyOn(
textElementUtils,
"handleBindTextResize",
);
mouse.select(rect);
mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0);
mouse.upAt(200, 0);
expect(arrow.width).toBe(170);
expect(rect.x).toBe(200);
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
h.elements[1],
false,
);
expect(
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
});
});
});