fix: Always bind to container selected by user (#5880)

* fix: Always bind to container selected by user

* Don't bind to container when using text tool

* adjust z-index for bound text

* fix

* Add spec

* Add test

* Allow double click on transparent container and add spec

* fix spec

* adjust z-index only when binding

* update index

* fix

* add index check

* Update src/scene/Scene.ts

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2022-11-25 15:45:34 +05:30 committed by GitHub
parent 1f117995d9
commit d2181847be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 172 additions and 110 deletions

View File

@ -133,7 +133,6 @@ import {
isInitializedImageElement, isInitializedImageElement,
isLinearElement, isLinearElement,
isLinearElementType, isLinearElementType,
isTextBindableContainer,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
ExcalidrawBindableElement, ExcalidrawBindableElement,
@ -175,7 +174,6 @@ import { renderScene } from "../renderer/renderScene";
import { invalidateShapeForElement } from "../renderer/renderElement"; import { invalidateShapeForElement } from "../renderer/renderElement";
import { import {
calculateScrollCenter, calculateScrollCenter,
getTextBindableContainerAtPosition,
getElementsAtPosition, getElementsAtPosition,
getElementsWithinSelection, getElementsWithinSelection,
getNormalizedZoom, getNormalizedZoom,
@ -255,6 +253,7 @@ import {
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getContainerDims, getContainerDims,
getTextBindableContainerAtPosition,
isValidTextContainer, isValidTextContainer,
} from "../element/textElement"; } from "../element/textElement";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
@ -1990,10 +1989,14 @@ class App extends React.Component<AppProps, AppState> {
isTextElement(selectedElement) || isTextElement(selectedElement) ||
isValidTextContainer(selectedElement) isValidTextContainer(selectedElement)
) { ) {
let container;
if (!isTextElement(selectedElement)) {
container = selectedElement as ExcalidrawTextContainer;
}
this.startTextEditing({ this.startTextEditing({
sceneX: selectedElement.x + selectedElement.width / 2, sceneX: selectedElement.x + selectedElement.width / 2,
sceneY: selectedElement.y + selectedElement.height / 2, sceneY: selectedElement.y + selectedElement.height / 2,
shouldBind: true, container,
}); });
event.preventDefault(); event.preventDefault();
return; return;
@ -2317,7 +2320,6 @@ class App extends React.Component<AppProps, AppState> {
const element = this.getElementAtPosition(x, y, { const element = this.getElementAtPosition(x, y, {
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
if (element && isTextElement(element) && !element.isDeleted) { if (element && isTextElement(element) && !element.isDeleted) {
return element; return element;
} }
@ -2394,29 +2396,31 @@ class App extends React.Component<AppProps, AppState> {
private startTextEditing = ({ private startTextEditing = ({
sceneX, sceneX,
sceneY, sceneY,
shouldBind,
insertAtParentCenter = true, insertAtParentCenter = true,
container,
}: { }: {
/** X position to insert text at */ /** X position to insert text at */
sceneX: number; sceneX: number;
/** Y position to insert text at */ /** Y position to insert text at */
sceneY: number; sceneY: number;
shouldBind: boolean;
/** whether to attempt to insert at element center if applicable */ /** whether to attempt to insert at element center if applicable */
insertAtParentCenter?: boolean; insertAtParentCenter?: boolean;
container?: ExcalidrawTextContainer | null;
}) => { }) => {
let shouldBindToContainer = false;
let parentCenterPosition = let parentCenterPosition =
insertAtParentCenter && insertAtParentCenter &&
this.getTextWysiwygSnappedToCenterPosition( this.getTextWysiwygSnappedToCenterPosition(
sceneX, sceneX,
sceneY, sceneY,
this.state, this.state,
this.canvas, container,
window.devicePixelRatio,
); );
if (container && parentCenterPosition) {
shouldBindToContainer = true;
}
let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null; let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
let container: ExcalidrawTextContainer | null = null;
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
@ -2426,7 +2430,7 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
if (isTextElement(selectedElements[0])) { if (isTextElement(selectedElements[0])) {
existingTextElement = selectedElements[0]; existingTextElement = selectedElements[0];
} else if (isTextBindableContainer(selectedElements[0], false)) { } else if (container) {
existingTextElement = getBoundTextElement(selectedElements[0]); existingTextElement = getBoundTextElement(selectedElements[0]);
} else { } else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
@ -2435,26 +2439,7 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
} }
// bind to container when shouldBind is true or if (!existingTextElement && shouldBindToContainer && container) {
// clicked on center of container
if (
!container &&
!existingTextElement &&
(shouldBind || parentCenterPosition)
) {
container = getTextBindableContainerAtPosition(
this.scene
.getNonDeletedElements()
.filter(
(ele) =>
isTextBindableContainer(ele, false) && !getBoundTextElement(ele),
),
sceneX,
sceneY,
);
}
if (!existingTextElement && container) {
const fontString = { const fontString = {
fontSize: this.state.currentItemFontSize, fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily, fontFamily: this.state.currentItemFontFamily,
@ -2472,12 +2457,10 @@ class App extends React.Component<AppProps, AppState> {
sceneX, sceneX,
sceneY, sceneY,
this.state, this.state,
this.canvas, container,
window.devicePixelRatio,
); );
} }
} }
const element = existingTextElement const element = existingTextElement
? existingTextElement ? existingTextElement
: newTextElement({ : newTextElement({
@ -2504,7 +2487,7 @@ class App extends React.Component<AppProps, AppState> {
verticalAlign: parentCenterPosition verticalAlign: parentCenterPosition
? VERTICAL_ALIGN.MIDDLE ? VERTICAL_ALIGN.MIDDLE
: DEFAULT_VERTICAL_ALIGN, : DEFAULT_VERTICAL_ALIGN,
containerId: container?.id ?? undefined, containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
locked: false, locked: false,
}); });
@ -2512,10 +2495,15 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ editingElement: element }); this.setState({ editingElement: element });
if (!existingTextElement) { if (!existingTextElement) {
this.scene.replaceAllElements([ if (container && shouldBindToContainer) {
...this.scene.getElementsIncludingDeleted(), const containerIndex = this.scene.getElementIndex(container.id);
element, this.scene.insertElementAtIndex(element, containerIndex + 1);
]); } else {
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
element,
]);
}
// case: creating new text not centered to parent element → offset Y // case: creating new text not centered to parent element → offset Y
// so that the text is centered to cursor position // so that the text is centered to cursor position
@ -2603,22 +2591,23 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.canvas); resetCursor(this.canvas);
if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) { if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
const selectedElements = getSelectedElements( const container = getTextBindableContainerAtPosition(
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.state, this.state,
sceneX,
sceneY,
); );
if (selectedElements.length === 1) { if (container) {
const selectedElement = selectedElements[0]; if (hasBoundTextElement(container)) {
if (hasBoundTextElement(selectedElement)) { sceneX = container.x + container.width / 2;
sceneX = selectedElement.x + selectedElement.width / 2; sceneY = container.y + container.height / 2;
sceneY = selectedElement.y + selectedElement.height / 2;
} }
} }
this.startTextEditing({ this.startTextEditing({
sceneX, sceneX,
sceneY, sceneY,
shouldBind: false,
insertAtParentCenter: !event.altKey, insertAtParentCenter: !event.altKey,
container,
}); });
} }
}; };
@ -3911,15 +3900,23 @@ class App extends React.Component<AppProps, AppState> {
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
let container = getTextBindableContainerAtPosition(
this.scene.getNonDeletedElements(),
this.state,
sceneX,
sceneY,
);
if (hasBoundTextElement(element)) { if (hasBoundTextElement(element)) {
container = element as ExcalidrawTextContainer;
sceneX = element.x + element.width / 2; sceneX = element.x + element.width / 2;
sceneY = element.y + element.height / 2; sceneY = element.y + element.height / 2;
} }
this.startTextEditing({ this.startTextEditing({
sceneX, sceneX,
sceneY, sceneY,
shouldBind: false,
insertAtParentCenter: !event.altKey, insertAtParentCenter: !event.altKey,
container,
}); });
resetCursor(this.canvas); resetCursor(this.canvas);
@ -6171,21 +6168,11 @@ class App extends React.Component<AppProps, AppState> {
x: number, x: number,
y: number, y: number,
appState: AppState, appState: AppState,
canvas: HTMLCanvasElement | null, container?: ExcalidrawTextContainer | null,
scale: number,
) { ) {
const elementClickedInside = getTextBindableContainerAtPosition( if (container) {
this.scene const elementCenterX = container.x + container.width / 2;
.getElementsIncludingDeleted() const elementCenterY = container.y + container.height / 2;
.filter((element) => !isTextElement(element)),
x,
y,
);
if (elementClickedInside) {
const elementCenterX =
elementClickedInside.x + elementClickedInside.width / 2;
const elementCenterY =
elementClickedInside.y + elementClickedInside.height / 2;
const distanceToCenter = Math.hypot( const distanceToCenter = Math.hypot(
x - elementCenterX, x - elementCenterX,
y - elementCenterY, y - elementCenterY,

View File

@ -1,6 +1,7 @@
import { getFontString, arrayToMap, isTestEnv } from "../utils"; import { getFontString, arrayToMap, isTestEnv } from "../utils";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontString, FontString,
@ -12,6 +13,10 @@ import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isTextElement } from "."; import { isTextElement } from ".";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement"; import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
import { isTextBindableContainer } from "./typeChecks";
import { getElementAbsoluteCoords } from "../element";
import { AppState } from "../types";
import { getSelectedElements } from "../scene";
import { isImageElement } from "./typeChecks"; import { isImageElement } from "./typeChecks";
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
@ -492,6 +497,32 @@ export const getContainerDims = (element: ExcalidrawElement) => {
return { width: element.width, height: element.height }; return { width: element.width, height: element.height };
}; };
export const getTextBindableContainerAtPosition = (
elements: readonly ExcalidrawElement[],
appState: AppState,
x: number,
y: number,
): ExcalidrawTextContainer | null => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1) {
return selectedElements[0] as ExcalidrawTextContainer;
}
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let index = elements.length - 1; index >= 0; --index) {
if (elements[index].isDeleted) {
continue;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[index];
break;
}
}
return isTextBindableContainer(hitElement, false) ? hitElement : null;
};
export const isValidTextContainer = (element: ExcalidrawElement) => { export const isValidTextContainer = (element: ExcalidrawElement) => {
return ( return (
element.type === "rectangle" || element.type === "rectangle" ||

View File

@ -500,7 +500,7 @@ describe("textWysiwyg", () => {
}); });
}); });
it("should bind text to container when double clicked on center", async () => { it("should bind text to container when double clicked on center of filled container", async () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id); expect(h.elements[0].id).toBe(rectangle.id);
@ -527,6 +527,40 @@ describe("textWysiwyg", () => {
]); ]);
}); });
it("should bind text to container when double clicked on center of transparent container", async () => {
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
backgroundColor: "transparent",
});
h.elements = [rectangle];
mouse.doubleClickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
});
it("should bind text to container when clicked on container and enter pressed", async () => { it("should bind text to container when clicked on container and enter pressed", async () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id); expect(h.elements[0].id).toBe(rectangle.id);
@ -825,9 +859,9 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
// Bind first text // Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
let editor = document.querySelector( const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea", ".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement; ) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@ -837,25 +871,14 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
]); ]);
// Attempt to bind another text mouse.select(rectangle);
UI.clickTool("text"); Keyboard.keyPress(KEYS.ENTER);
mouse.clickAt( expect(h.elements.length).toBe(2);
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
mouse.down();
expect(h.elements.length).toBe(3);
text = h.elements[2] as ExcalidrawTextElementWithContainer;
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Whats up?" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([ expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" }, { id: h.elements[1].id, type: "text" },
]); ]);
expect(text.containerId).toBe(null); expect(text.containerId).toBe(rectangle.id);
}); });
it("should respect text alignment when resizing", async () => { it("should respect text alignment when resizing", async () => {
@ -1045,5 +1068,36 @@ describe("textWysiwyg", () => {
`"Wikipedia is hosted by the Wikimedia Foundation, a non-profit organization that also hosts a range of other projects.Hello this text should get merged with the existing one"`, `"Wikipedia is hosted by the Wikimedia Foundation, a non-profit organization that also hosts a range of other projects.Hello this text should get merged with the existing one"`,
); );
}); });
it("should always bind to selected container and insert it in correct position", async () => {
const rectangle2 = UI.createElement("rectangle", {
x: 5,
y: 10,
width: 120,
height: 100,
});
API.setSelectedElements([rectangle]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe("text");
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle2.boundElements).toBeNull();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
});
}); });
}); });

View File

@ -150,6 +150,24 @@ class Scene {
// (I guess?) // (I guess?)
this.callbacks.clear(); this.callbacks.clear();
} }
insertElementAtIndex(element: ExcalidrawElement, index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
this.replaceAllElements(nextElements);
}
getElementIndex(elementId: string) {
return this.elements.findIndex((element) => element.id === elementId);
}
} }
export default Scene; export default Scene;

View File

@ -1,11 +1,4 @@
import { import { NonDeletedExcalidrawElement } from "../element/types";
ExcalidrawElement,
ExcalidrawTextContainer,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getElementAbsoluteCoords } from "../element";
import { isTextBindableContainer } from "../element/typeChecks";
export const hasBackground = (type: string) => export const hasBackground = (type: string) =>
type === "rectangle" || type === "rectangle" ||
@ -73,23 +66,3 @@ export const getElementsAtPosition = (
(element) => !element.isDeleted && isAtPositionFn(element), (element) => !element.isDeleted && isAtPositionFn(element),
); );
}; };
export const getTextBindableContainerAtPosition = (
elements: readonly ExcalidrawElement[],
x: number,
y: number,
): ExcalidrawTextContainer | null => {
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let index = elements.length - 1; index >= 0; --index) {
if (elements[index].isDeleted) {
continue;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[index];
break;
}
}
return isTextBindableContainer(hitElement, false) ? hitElement : null;
};

View File

@ -14,7 +14,6 @@ export {
canHaveArrowheads, canHaveArrowheads,
canChangeSharpness, canChangeSharpness,
getElementAtPosition, getElementAtPosition,
getTextBindableContainerAtPosition,
hasText, hasText,
getElementsAtPosition, getElementsAtPosition,
} from "./comparisons"; } from "./comparisons";

View File

@ -232,7 +232,7 @@ describe("element locking", () => {
API.setSelectedElements([container]); API.setSelectedElements([container]);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingElement?.id).not.toBe(text.id); expect(h.state.editingElement?.id).not.toBe(text.id);
expect(h.state.editingElement?.id).toBe(h.elements[2].id); expect(h.state.editingElement?.id).toBe(h.elements[1].id);
}); });
it("should ignore locked text under cursor when clicked with text tool", () => { it("should ignore locked text under cursor when clicked with text tool", () => {