diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx
index e75e86959..ae73d6a9a 100644
--- a/src/actions/actionProperties.tsx
+++ b/src/actions/actionProperties.tsx
@@ -816,16 +816,19 @@ export const actionChangeVerticalAlign = register({
value: VERTICAL_ALIGN.TOP,
text: t("labels.alignTop"),
icon: ,
+ testId: "align-top",
},
{
value: VERTICAL_ALIGN.MIDDLE,
text: t("labels.centerVertically"),
icon: ,
+ testId: "align-middle",
},
{
value: VERTICAL_ALIGN.BOTTOM,
text: t("labels.alignBottom"),
icon: ,
+ testId: "align-bottom",
},
]}
value={getFormValue(elements, appState, (element) => {
diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx
index 75233d181..5ee6e3409 100644
--- a/src/components/Actions.tsx
+++ b/src/components/Actions.tsx
@@ -25,11 +25,12 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
-import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
+import { hasBoundTextElement } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import "./Actions.scss";
import { Tooltip } from "./Tooltip";
+import { shouldAllowVerticalAlign } from "../element/textElement";
export const SelectedShapeActions = ({
appState,
@@ -125,10 +126,8 @@ export const SelectedShapeActions = ({
>
)}
- {targetElements.some(
- (element) =>
- hasBoundTextElement(element) || isBoundToContainer(element),
- ) && renderAction("changeVerticalAlign")}
+ {shouldAllowVerticalAlign(targetElements) &&
+ renderAction("changeVerticalAlign")}
{(canHaveArrowheads(appState.activeTool.type) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}>
diff --git a/src/components/App.tsx b/src/components/App.tsx
index f865079af..ae9431282 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -126,6 +126,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
import {
hasBoundTextElement,
+ isArrowElement,
isBindingElement,
isBindingElementType,
isBoundToContainer,
@@ -254,6 +255,7 @@ import {
getApproxMinLineHeight,
getApproxMinLineWidth,
getBoundTextElement,
+ getContainerCenter,
getContainerDims,
getTextBindableContainerAtPosition,
isValidTextContainer,
@@ -2049,23 +2051,23 @@ class App extends React.Component {
this.scene.getNonDeletedElements(),
this.state,
);
-
if (selectedElements.length === 1) {
const selectedElement = selectedElements[0];
-
- if (isLinearElement(selectedElement)) {
- if (
- !this.state.editingLinearElement ||
- this.state.editingLinearElement.elementId !==
- selectedElements[0].id
- ) {
- this.history.resumeRecording();
- this.setState({
- editingLinearElement: new LinearElementEditor(
- selectedElement,
- this.scene,
- ),
- });
+ if (event[KEYS.CTRL_OR_CMD]) {
+ if (isLinearElement(selectedElement)) {
+ if (
+ !this.state.editingLinearElement ||
+ this.state.editingLinearElement.elementId !==
+ selectedElements[0].id
+ ) {
+ this.history.resumeRecording();
+ this.setState({
+ editingLinearElement: new LinearElementEditor(
+ selectedElement,
+ this.scene,
+ ),
+ });
+ }
}
} else if (
isTextElement(selectedElement) ||
@@ -2075,9 +2077,12 @@ class App extends React.Component {
if (!isTextElement(selectedElement)) {
container = selectedElement as ExcalidrawTextContainer;
}
+ const midPoint = getContainerCenter(selectedElement, this.state);
+ const sceneX = midPoint.x;
+ const sceneY = midPoint.y;
this.startTextEditing({
- sceneX: selectedElement.x + selectedElement.width / 2,
- sceneY: selectedElement.y + selectedElement.height / 2,
+ sceneX,
+ sceneY,
container,
});
event.preventDefault();
@@ -2521,7 +2526,12 @@ class App extends React.Component {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
- if (!existingTextElement && shouldBindToContainer && container) {
+ if (
+ !existingTextElement &&
+ shouldBindToContainer &&
+ container &&
+ !isArrowElement(container)
+ ) {
const fontString = {
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
@@ -2574,6 +2584,14 @@ class App extends React.Component {
locked: false,
});
+ if (!existingTextElement && shouldBindToContainer && container) {
+ mutateElement(container, {
+ boundElements: (container.boundElements || []).concat({
+ type: "text",
+ id: element.id,
+ }),
+ });
+ }
this.setState({ editingElement: element });
if (!existingTextElement) {
@@ -2625,8 +2643,9 @@ class App extends React.Component {
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (
- !this.state.editingLinearElement ||
- this.state.editingLinearElement.elementId !== selectedElements[0].id
+ event[KEYS.CTRL_OR_CMD] &&
+ (!this.state.editingLinearElement ||
+ this.state.editingLinearElement.elementId !== selectedElements[0].id)
) {
this.history.resumeRecording();
this.setState({
@@ -2635,8 +2654,13 @@ class App extends React.Component {
this.scene,
),
});
+ return;
+ } else if (
+ this.state.editingLinearElement &&
+ this.state.editingLinearElement.elementId === selectedElements[0].id
+ ) {
+ return;
}
- return;
}
resetCursor(this.canvas);
@@ -2680,9 +2704,11 @@ class App extends React.Component {
sceneY,
);
if (container) {
- if (hasBoundTextElement(container)) {
- sceneX = container.x + container.width / 2;
- sceneY = container.y + container.height / 2;
+ if (isArrowElement(container) || hasBoundTextElement(container)) {
+ const midPoint = getContainerCenter(container, this.state);
+
+ sceneX = midPoint.x;
+ sceneY = midPoint.y;
}
}
this.startTextEditing({
@@ -2783,6 +2809,7 @@ class App extends React.Component {
event: React.PointerEvent,
) => {
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
+
if (gesture.pointers.has(event.pointerId)) {
gesture.pointers.set(event.pointerId, {
x: event.clientX,
@@ -3091,15 +3118,18 @@ class App extends React.Component {
);
} else if (
// if using cmd/ctrl, we're not dragging
- !event[KEYS.CTRL_OR_CMD] &&
- (hitElement ||
- this.isHittingCommonBoundingBoxOfSelectedElements(
- scenePointer,
- selectedElements,
- )) &&
- !hitElement?.locked
+ !event[KEYS.CTRL_OR_CMD]
) {
- setCursor(this.canvas, CURSOR_TYPE.MOVE);
+ if (
+ (hitElement ||
+ this.isHittingCommonBoundingBoxOfSelectedElements(
+ scenePointer,
+ selectedElements,
+ )) &&
+ !hitElement?.locked
+ ) {
+ setCursor(this.canvas, CURSOR_TYPE.MOVE);
+ }
} else {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
}
@@ -3209,6 +3239,8 @@ class App extends React.Component {
linearElementEditor.elementId,
);
+ const boundTextElement = getBoundTextElement(element);
+
if (!element) {
return;
}
@@ -3249,6 +3281,11 @@ class App extends React.Component {
)
) {
setCursor(this.canvas, CURSOR_TYPE.MOVE);
+ } else if (
+ boundTextElement &&
+ hitTest(boundTextElement, this.state, scenePointerX, scenePointerY)
+ ) {
+ setCursor(this.canvas, CURSOR_TYPE.MOVE);
}
if (
@@ -6305,8 +6342,14 @@ class App extends React.Component {
container?: ExcalidrawTextContainer | null,
) {
if (container) {
- const elementCenterX = container.x + container.width / 2;
- const elementCenterY = container.y + container.height / 2;
+ let elementCenterX = container.x + container.width / 2;
+ let elementCenterY = container.y + container.height / 2;
+
+ const elementCenter = getContainerCenter(container, appState);
+ if (elementCenter) {
+ elementCenterX = elementCenter.x;
+ elementCenterY = elementCenter.y;
+ }
const distanceToCenter = Math.hypot(
x - elementCenterX,
y - elementCenterY,
diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx
index 9a23fad07..5e20cf447 100644
--- a/src/components/HelpDialog.tsx
+++ b/src/components/HelpDialog.tsx
@@ -157,7 +157,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
/>
@@ -361,6 +362,10 @@ export const updateBoundElements = (
endBinding,
changedElement as ExcalidrawBindableElement,
);
+ const boundText = getBoundTextElement(element);
+ if (boundText) {
+ handleBindTextResize(element, false);
+ }
});
};
diff --git a/src/element/bounds.ts b/src/element/bounds.ts
index 793f4c50f..9de28795a 100644
--- a/src/element/bounds.ts
+++ b/src/element/bounds.ts
@@ -4,6 +4,7 @@ import {
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
+ ExcalidrawTextElementWithContainer,
} from "./types";
import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough";
@@ -13,8 +14,15 @@ import {
getShapeForElement,
generateRoughOptions,
} from "../renderer/renderElement";
-import { isFreeDrawElement, isLinearElement } from "./typeChecks";
+import {
+ isArrowElement,
+ isFreeDrawElement,
+ isLinearElement,
+ isTextElement,
+} from "./typeChecks";
import { rescalePoints } from "../points";
+import { getBoundTextElement, getContainerElement } from "./textElement";
+import { LinearElementEditor } from "./linearElementEditor";
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [number, number, number, number];
@@ -24,17 +32,39 @@ type MaybeQuadraticSolution = [number | null, number | null] | false;
// This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = (
element: ExcalidrawElement,
-): Bounds => {
+ includeBoundText: boolean = false,
+): [number, number, number, number, number, number] => {
if (isFreeDrawElement(element)) {
return getFreeDrawElementAbsoluteCoords(element);
} else if (isLinearElement(element)) {
- return getLinearElementAbsoluteCoords(element);
+ return LinearElementEditor.getElementAbsoluteCoords(
+ element,
+ includeBoundText,
+ );
+ } else if (isTextElement(element)) {
+ const container = getContainerElement(element);
+ if (isArrowElement(container)) {
+ const coords = LinearElementEditor.getBoundTextElementPosition(
+ container,
+ element as ExcalidrawTextElementWithContainer,
+ );
+ return [
+ coords.x,
+ coords.y,
+ coords.x + element.width,
+ coords.y + element.height,
+ coords.x + element.width / 2,
+ coords.y + element.height / 2,
+ ];
+ }
}
return [
element.x,
element.y,
element.x + element.width,
element.y + element.height,
+ element.x + element.width / 2,
+ element.y + element.height / 2,
];
};
@@ -159,7 +189,7 @@ const getCubicBezierCurveBound = (
return [minX, minY, maxX, maxY];
};
-const getMinMaxXYFromCurvePathOps = (
+export const getMinMaxXYFromCurvePathOps = (
ops: Op[],
transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => {
@@ -230,59 +260,13 @@ const getBoundsFromPoints = (
const getFreeDrawElementAbsoluteCoords = (
element: ExcalidrawFreeDrawElement,
-): [number, number, number, number] => {
+): [number, number, number, number, number, number] => {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
-
- return [
- minX + element.x,
- minY + element.y,
- maxX + element.x,
- maxY + element.y,
- ];
-};
-
-const getLinearElementAbsoluteCoords = (
- element: ExcalidrawLinearElement,
-): [number, number, number, number] => {
- let coords: [number, number, number, number];
-
- if (element.points.length < 2 || !getShapeForElement(element)) {
- // XXX this is just a poor estimate and not very useful
- const { minX, minY, maxX, maxY } = element.points.reduce(
- (limits, [x, y]) => {
- limits.minY = Math.min(limits.minY, y);
- limits.minX = Math.min(limits.minX, x);
-
- limits.maxX = Math.max(limits.maxX, x);
- limits.maxY = Math.max(limits.maxY, y);
-
- return limits;
- },
- { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
- );
- coords = [
- minX + element.x,
- minY + element.y,
- maxX + element.x,
- maxY + element.y,
- ];
- } else {
- const shape = getShapeForElement(element)!;
-
- // first element is always the curve
- const ops = getCurvePathOps(shape[0]);
-
- const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
-
- coords = [
- minX + element.x,
- minY + element.y,
- maxX + element.x,
- maxY + element.y,
- ];
- }
-
- return coords;
+ const x1 = minX + element.x;
+ const y1 = minY + element.y;
+ const x2 = maxX + element.x;
+ const y2 = maxY + element.y;
+ return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
};
export const getArrowheadPoints = (
@@ -420,7 +404,23 @@ const getLinearElementRotatedBounds = (
cy,
element.angle,
);
- return [x, y, x, y];
+
+ let coords: [number, number, number, number] = [x, y, x, y];
+ const boundTextElement = getBoundTextElement(element);
+ if (boundTextElement) {
+ const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
+ element,
+ [x, y, x, y],
+ boundTextElement,
+ );
+ coords = [
+ coordsWithBoundText[0],
+ coordsWithBoundText[1],
+ coordsWithBoundText[2],
+ coordsWithBoundText[3],
+ ];
+ }
+ return coords;
}
// first element is always the curve
@@ -429,8 +429,28 @@ const getLinearElementRotatedBounds = (
const ops = getCurvePathOps(shape);
const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle);
-
- return getMinMaxXYFromCurvePathOps(ops, transformXY);
+ const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
+ let coords: [number, number, number, number] = [
+ res[0],
+ res[1],
+ res[2],
+ res[3],
+ ];
+ const boundTextElement = getBoundTextElement(element);
+ if (boundTextElement) {
+ const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
+ element,
+ coords,
+ boundTextElement,
+ );
+ coords = [
+ coordsWithBoundText[0],
+ coordsWithBoundText[1],
+ coordsWithBoundText[2],
+ coordsWithBoundText[3],
+ ];
+ }
+ return coords;
};
// We could cache this stuff
@@ -439,9 +459,7 @@ export const getElementBounds = (
): [number, number, number, number] => {
let bounds: [number, number, number, number];
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
- const cx = (x1 + x2) / 2;
- const cy = (y1 + y2) / 2;
+ const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
diff --git a/src/element/collision.ts b/src/element/collision.ts
index e47e5e554..e300f821d 100644
--- a/src/element/collision.ts
+++ b/src/element/collision.ts
@@ -36,6 +36,7 @@ import { hasBoundTextElement, isImageElement } from "./typeChecks";
import { isTextElement } from ".";
import { isTransparent } from "../utils";
import { shouldShowBoundingBox } from "./transformHandles";
+import { getBoundTextElement } from "./textElement";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
@@ -72,6 +73,13 @@ export const hitTest = (
return isPointHittingElementBoundingBox(element, point, threshold);
}
+ const boundTextElement = getBoundTextElement(element);
+ if (boundTextElement) {
+ const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
+ if (isHittingBoundTextElement) {
+ return true;
+ }
+ }
return isHittingElementNotConsideringBoundingBox(element, appState, point);
};
@@ -83,6 +91,13 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
): boolean => {
const threshold = 10 / appState.zoom.value;
+ // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
+ // eg for linear elements text can be outside the element bounding box
+ const boundTextElement = getBoundTextElement(element);
+ if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
+ return false;
+ }
+
return (
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
isPointHittingElementBoundingBox(element, [x, y], threshold)
@@ -95,7 +110,6 @@ export const isHittingElementNotConsideringBoundingBox = (
point: Point,
): boolean => {
const threshold = 10 / appState.zoom.value;
-
const check = isTextElement(element)
? isStrictlyInside
: isElementDraggableFromInside(element)
@@ -382,6 +396,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
if (!getShapeForElement(element)) {
return false;
}
+
const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
args.element,
args.point,
@@ -434,8 +449,9 @@ const pointRelativeToElement = (
pointTuple: Point,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCoords = getElementAbsoluteCoords(element);
- const center = coordsCenter(elementCoords);
+ const center = coordsCenter([x1, y1, x2, y2]);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point);
@@ -466,8 +482,8 @@ export const pointInAbsoluteCoords = (
const relativizationToElementCenter = (
element: ExcalidrawElement,
): GA.Transform => {
- const elementCoords = getElementAbsoluteCoords(element);
- const center = coordsCenter(elementCoords);
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ const center = coordsCenter([x1, y1, x2, y2]);
// GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle);
const translate = GA.reverse(
@@ -524,8 +540,8 @@ export const determineFocusPoint = (
adjecentPoint: Point,
): Point => {
if (focus === 0) {
- const elementCoords = getElementAbsoluteCoords(element);
- const center = coordsCenter(elementCoords);
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ const center = coordsCenter([x1, y1, x2, y2]);
return GAPoint.toTuple(center);
}
const relateToCenter = relativizationToElementCenter(element);
diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts
index f0d0a2153..4e53b034e 100644
--- a/src/element/linearElementEditor.ts
+++ b/src/element/linearElementEditor.ts
@@ -4,6 +4,7 @@ import {
ExcalidrawElement,
PointBinding,
ExcalidrawBindableElement,
+ ExcalidrawTextElementWithContainer,
} from "./types";
import {
distance2d,
@@ -19,7 +20,11 @@ import {
arePointsEqual,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
-import { getElementPointsCoords } from "./bounds";
+import {
+ getCurvePathOps,
+ getElementPointsCoords,
+ getMinMaxXYFromCurvePathOps,
+} from "./bounds";
import { Point, AppState, PointerCoords } from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
@@ -33,6 +38,8 @@ import {
import { tupleToCoors } from "../utils";
import { isBindingElement } from "./typeChecks";
import { shouldRotateWithDiscreteAngle } from "../keys";
+import { getBoundTextElement, handleBindTextResize } from "./textElement";
+import { getShapeForElement } from "../renderer/renderElement";
import { DRAGGING_THRESHOLD } from "../constants";
const editorMidPointsCache: {
@@ -40,7 +47,6 @@ const editorMidPointsCache: {
points: (Point | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
-
export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId";
@@ -257,6 +263,11 @@ export class LinearElementEditor {
};
}),
);
+
+ const boundTextElement = getBoundTextElement(element);
+ if (boundTextElement) {
+ handleBindTextResize(element, false);
+ }
}
// suggest bindings for first and last point if selected
@@ -388,8 +399,14 @@ export class LinearElementEditor {
element: NonDeleted,
appState: AppState,
): typeof editorMidPointsCache["points"] => {
- // Since its not needed outside editor unless 2 pointer lines
- if (!appState.editingLinearElement && element.points.length > 2) {
+ const boundText = getBoundTextElement(element);
+
+ // Since its not needed outside editor unless 2 pointer lines or bound text
+ if (
+ !appState.editingLinearElement &&
+ element.points.length > 2 &&
+ !boundText
+ ) {
return [];
}
if (
@@ -661,7 +678,6 @@ export class LinearElementEditor {
scenePointer.x,
scenePointer.y,
);
-
// if we clicked on a point, set the element as hitElement otherwise
// it would get deselected if the point is outside the hitbox area
if (clickedPointIndex >= 0 || segmentMidpoint) {
@@ -1055,7 +1071,6 @@ export class LinearElementEditor {
const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
-
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
@@ -1223,7 +1238,6 @@ export class LinearElementEditor {
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
-
mutateElement(element, {
...otherUpdates,
points: nextPoints,
@@ -1258,6 +1272,207 @@ export class LinearElementEditor {
return rotatePoint([width, height], [0, 0], -element.angle);
}
+
+ static getBoundTextElementPosition = (
+ element: ExcalidrawLinearElement,
+ boundTextElement: ExcalidrawTextElementWithContainer,
+ ): { x: number; y: number } => {
+ const points = LinearElementEditor.getPointsGlobalCoordinates(element);
+ if (points.length < 2) {
+ mutateElement(boundTextElement, { isDeleted: true });
+ }
+ let x = 0;
+ let y = 0;
+ if (element.points.length % 2 === 1) {
+ const index = Math.floor(element.points.length / 2);
+ const midPoint = LinearElementEditor.getPointGlobalCoordinates(
+ element,
+ element.points[index],
+ );
+ x = midPoint[0] - boundTextElement.width / 2;
+ y = midPoint[1] - boundTextElement.height / 2;
+ } else {
+ const index = element.points.length / 2 - 1;
+
+ let midSegmentMidpoint = editorMidPointsCache.points[index];
+ if (element.points.length === 2) {
+ midSegmentMidpoint = centerPoint(points[0], points[1]);
+ }
+ if (
+ !midSegmentMidpoint ||
+ editorMidPointsCache.version !== element.version
+ ) {
+ midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
+ element,
+ points[index],
+ points[index + 1],
+ index + 1,
+ );
+ }
+ x = midSegmentMidpoint[0] - boundTextElement.width / 2;
+ y = midSegmentMidpoint[1] - boundTextElement.height / 2;
+ }
+ return { x, y };
+ };
+
+ static getMinMaxXYWithBoundText = (
+ element: ExcalidrawLinearElement,
+ elementBounds: [number, number, number, number],
+ boundTextElement: ExcalidrawTextElementWithContainer,
+ ): [number, number, number, number, number, number] => {
+ let [x1, y1, x2, y2] = elementBounds;
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ const { x: boundTextX1, y: boundTextY1 } =
+ LinearElementEditor.getBoundTextElementPosition(
+ element,
+ boundTextElement,
+ );
+ const boundTextX2 = boundTextX1 + boundTextElement.width;
+ const boundTextY2 = boundTextY1 + boundTextElement.height;
+
+ const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
+ const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
+
+ const counterRotateBoundTextTopLeft = rotatePoint(
+ [boundTextX1, boundTextY1],
+
+ [cx, cy],
+
+ -element.angle,
+ );
+ const counterRotateBoundTextTopRight = rotatePoint(
+ [boundTextX2, boundTextY1],
+
+ [cx, cy],
+
+ -element.angle,
+ );
+ const counterRotateBoundTextBottomLeft = rotatePoint(
+ [boundTextX1, boundTextY2],
+
+ [cx, cy],
+
+ -element.angle,
+ );
+ const counterRotateBoundTextBottomRight = rotatePoint(
+ [boundTextX2, boundTextY2],
+
+ [cx, cy],
+
+ -element.angle,
+ );
+
+ if (
+ topLeftRotatedPoint[0] < topRightRotatedPoint[0] &&
+ topLeftRotatedPoint[1] >= topRightRotatedPoint[1]
+ ) {
+ x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]);
+ x2 = Math.max(
+ x2,
+ Math.max(
+ counterRotateBoundTextTopRight[0],
+ counterRotateBoundTextBottomRight[0],
+ ),
+ );
+ y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]);
+
+ y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]);
+ } else if (
+ topLeftRotatedPoint[0] >= topRightRotatedPoint[0] &&
+ topLeftRotatedPoint[1] > topRightRotatedPoint[1]
+ ) {
+ x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]);
+ x2 = Math.max(
+ x2,
+ Math.max(
+ counterRotateBoundTextTopLeft[0],
+ counterRotateBoundTextTopRight[0],
+ ),
+ );
+ y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]);
+
+ y2 = Math.max(y2, counterRotateBoundTextTopRight[1]);
+ } else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) {
+ x1 = Math.min(x1, counterRotateBoundTextTopRight[0]);
+ x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]);
+ y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]);
+
+ y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]);
+ } else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) {
+ x1 = Math.min(
+ x1,
+ Math.min(
+ counterRotateBoundTextTopRight[0],
+ counterRotateBoundTextTopLeft[0],
+ ),
+ );
+
+ x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]);
+ y1 = Math.min(y1, counterRotateBoundTextTopRight[1]);
+ y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]);
+ }
+
+ return [x1, y1, x2, y2, cx, cy];
+ };
+
+ static getElementAbsoluteCoords = (
+ element: ExcalidrawLinearElement,
+ includeBoundText: boolean = false,
+ ): [number, number, number, number, number, number] => {
+ let coords: [number, number, number, number, number, number];
+ let x1;
+ let y1;
+ let x2;
+ let y2;
+ if (element.points.length < 2 || !getShapeForElement(element)) {
+ // XXX this is just a poor estimate and not very useful
+ const { minX, minY, maxX, maxY } = element.points.reduce(
+ (limits, [x, y]) => {
+ limits.minY = Math.min(limits.minY, y);
+ limits.minX = Math.min(limits.minX, x);
+
+ limits.maxX = Math.max(limits.maxX, x);
+ limits.maxY = Math.max(limits.maxY, y);
+
+ return limits;
+ },
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+ );
+ x1 = minX + element.x;
+ y1 = minY + element.y;
+ x2 = maxX + element.x;
+ y2 = maxY + element.y;
+ } else {
+ const shape = getShapeForElement(element)!;
+
+ // first element is always the curve
+ const ops = getCurvePathOps(shape[0]);
+
+ const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
+ x1 = minX + element.x;
+ y1 = minY + element.y;
+ x2 = maxX + element.x;
+ y2 = maxY + element.y;
+ }
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ coords = [x1, y1, x2, y2, cx, cy];
+
+ if (!includeBoundText) {
+ return coords;
+ }
+ const boundTextElement = getBoundTextElement(element);
+ if (boundTextElement) {
+ coords = LinearElementEditor.getMinMaxXYWithBoundText(
+ element,
+ [x1, y1, x2, y2],
+ boundTextElement,
+ );
+ }
+
+ return coords;
+ };
}
const normalizeSelectedPoints = (
diff --git a/src/element/newElement.ts b/src/element/newElement.ts
index f35a0d0f5..2c0418008 100644
--- a/src/element/newElement.ts
+++ b/src/element/newElement.ts
@@ -11,7 +11,7 @@ import {
Arrowhead,
ExcalidrawFreeDrawElement,
FontFamilyValues,
- ExcalidrawRectangleElement,
+ ExcalidrawTextContainer,
} from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
@@ -22,6 +22,8 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
+ getBoundTextElement,
+ getBoundTextElementOffset,
getContainerDims,
getContainerElement,
measureText,
@@ -29,6 +31,7 @@ import {
wrapText,
} from "./textElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
+import { isArrowElement } from "./typeChecks";
type ElementConstructorOpts = MarkOptional<
Omit,
@@ -131,7 +134,7 @@ export const newTextElement = (
fontFamily: FontFamilyValues;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
- containerId?: ExcalidrawRectangleElement["id"];
+ containerId?: ExcalidrawTextContainer["id"];
} & ElementConstructorOpts,
): NonDeleted => {
const text = normalizeText(opts.text);
@@ -231,16 +234,21 @@ const getAdjustedDimensions = (
// make sure container dimensions are set properly when
// text editor overflows beyond viewport dimensions
if (container) {
+ const boundTextElementPadding = getBoundTextElementOffset(element);
+
const containerDims = getContainerDims(container);
let height = containerDims.height;
let width = containerDims.width;
- if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
- height = nextHeight + BOUND_TEXT_PADDING * 2;
+ if (nextHeight > height - boundTextElementPadding * 2) {
+ height = nextHeight + boundTextElementPadding * 2;
}
- if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
- width = nextWidth + BOUND_TEXT_PADDING * 2;
+ if (nextWidth > width - boundTextElementPadding * 2) {
+ width = nextWidth + boundTextElementPadding * 2;
}
- if (height !== containerDims.height || width !== containerDims.width) {
+ if (
+ !isArrowElement(container) &&
+ (height !== containerDims.height || width !== containerDims.width)
+ ) {
mutateElement(container, { height, width });
}
}
@@ -270,11 +278,35 @@ export const refreshTextDimensions = (
};
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
- return getContainerDims(container).width - BOUND_TEXT_PADDING * 2;
+ const width = getContainerDims(container).width;
+ if (isArrowElement(container)) {
+ const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
+ if (containerWidth <= 0) {
+ const boundText = getBoundTextElement(container);
+ if (boundText) {
+ return boundText.width;
+ }
+ return BOUND_TEXT_PADDING * 8 * 2;
+ }
+ return containerWidth;
+ }
+ return width - BOUND_TEXT_PADDING * 2;
};
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
- return getContainerDims(container).height - BOUND_TEXT_PADDING * 2;
+ const height = getContainerDims(container).height;
+ if (isArrowElement(container)) {
+ const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
+ if (containerHeight <= 0) {
+ const boundText = getBoundTextElement(container);
+ if (boundText) {
+ return boundText.height;
+ }
+ return BOUND_TEXT_PADDING * 8 * 2;
+ }
+ return height;
+ }
+ return height - BOUND_TEXT_PADDING * 2;
};
export const updateTextElement = (
diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts
index 8ab611069..46e1c0d1a 100644
--- a/src/element/resizeElements.ts
+++ b/src/element/resizeElements.ts
@@ -1,4 +1,4 @@
-import { BOUND_TEXT_PADDING, SHIFT_LOCKING_ANGLE } from "../constants";
+import { SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import {
@@ -12,6 +12,8 @@ import {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
NonDeleted,
+ ExcalidrawElement,
+ ExcalidrawTextElementWithContainer,
} from "./types";
import {
getElementAbsoluteCoords,
@@ -20,6 +22,7 @@ import {
getCommonBoundingBox,
} from "./bounds";
import {
+ isArrowElement,
isBoundToContainer,
isFreeDrawElement,
isLinearElement,
@@ -40,6 +43,7 @@ import {
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
+ getBoundTextElementOffset,
getContainerElement,
handleBindTextResize,
measureText,
@@ -75,6 +79,7 @@ export const transformElements = (
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
+ pointerDownState.originalElements,
);
updateBoundElements(element);
} else if (
@@ -142,6 +147,7 @@ const rotateSingleElement = (
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
+ originalElements: Map>,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
@@ -152,11 +158,17 @@ const rotateSingleElement = (
angle -= angle % SHIFT_LOCKING_ANGLE;
}
angle = normalizeAngle(angle);
- mutateElement(element, { angle });
const boundTextElementId = getBoundTextElementId(element);
+
+ mutateElement(element, { angle });
if (boundTextElementId) {
- const textElement = Scene.getScene(element)!.getElement(boundTextElementId);
- mutateElement(textElement!, { angle });
+ const textElement = Scene.getScene(element)!.getElement(
+ boundTextElementId,
+ ) as ExcalidrawTextElementWithContainer;
+
+ if (!isArrowElement(element)) {
+ mutateElement(textElement, { angle });
+ }
}
};
@@ -412,10 +424,12 @@ export const resizeSingleElement = (
};
}
if (shouldMaintainAspectRatio) {
+ const boundTextElementPadding =
+ getBoundTextElementOffset(boundTextElement);
const nextFont = measureFontSizeFromWH(
boundTextElement,
- eleNewWidth - BOUND_TEXT_PADDING * 2,
- eleNewHeight - BOUND_TEXT_PADDING * 2,
+ eleNewWidth - boundTextElementPadding * 2,
+ eleNewHeight - boundTextElementPadding * 2,
);
if (nextFont === null) {
return;
@@ -504,24 +518,36 @@ export const resizeSingleElement = (
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
// Readjust points for linear elements
- const rescaledPoints = rescalePointsInElement(
- stateAtResizeStart,
- eleNewWidth,
- eleNewHeight,
- true,
- );
+ let rescaledElementPointsY;
+ let rescaledPoints;
+
+ if (isLinearElement(element) || isFreeDrawElement(element)) {
+ rescaledElementPointsY = rescalePoints(
+ 1,
+ eleNewHeight,
+ (stateAtResizeStart as ExcalidrawLinearElement).points,
+ true,
+ );
+
+ rescaledPoints = rescalePoints(
+ 0,
+ eleNewWidth,
+ rescaledElementPointsY,
+ true,
+ );
+ }
+
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
-
const resizedElement = {
width: Math.abs(eleNewWidth),
height: Math.abs(eleNewHeight),
x: newOrigin[0],
y: newOrigin[1],
- ...rescaledPoints,
+ points: rescaledPoints,
};
if ("scale" in element && "scale" in stateAtResizeStart) {
@@ -545,6 +571,7 @@ export const resizeSingleElement = (
updateBoundElements(element, {
newSize: { width: resizedElement.width, height: resizedElement.height },
});
+
mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
@@ -667,7 +694,7 @@ const resizeMultipleElements = (
const boundTextElement = getBoundTextElement(element.latest);
if (boundTextElement || isTextElement(element.orig)) {
- const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
+ const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
const textMeasurements = measureFontSizeFromWH(
boundTextElement ?? (element.orig as ExcalidrawTextElement),
width - optionalPadding,
@@ -697,6 +724,7 @@ const resizeMultipleElements = (
if (boundTextElement && boundTextUpdates) {
mutateElement(boundTextElement, boundTextUpdates);
+
handleBindTextResize(element.latest, transformHandleType);
}
});
@@ -717,7 +745,7 @@ const rotateMultipleElements = (
centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
}
- elements.forEach((element, index) => {
+ elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
@@ -737,13 +765,16 @@ const rotateMultipleElements = (
});
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
- const textElement =
- Scene.getScene(element)!.getElement(boundTextElementId)!;
- mutateElement(textElement, {
- x: textElement.x + (rotatedCX - cx),
- y: textElement.y + (rotatedCY - cy),
- angle: normalizeAngle(centerAngle + origAngle),
- });
+ const textElement = Scene.getScene(element)!.getElement(
+ boundTextElementId,
+ ) as ExcalidrawTextElementWithContainer;
+ if (!isArrowElement(element)) {
+ mutateElement(textElement, {
+ x: textElement.x + (rotatedCX - cx),
+ y: textElement.y + (rotatedCY - cy),
+ angle: normalizeAngle(centerAngle + origAngle),
+ });
+ }
}
});
};
diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts
index dbdab5a44..a3447208f 100644
--- a/src/element/resizeTest.ts
+++ b/src/element/resizeTest.ts
@@ -94,7 +94,7 @@ export const getTransformHandleTypeFromCoords = (
pointerType: PointerType,
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
- [x1, y1, x2, y2],
+ [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
zoom,
pointerType,
diff --git a/src/element/textElement.ts b/src/element/textElement.ts
index 142dda0dc..4f39d3612 100644
--- a/src/element/textElement.ts
+++ b/src/element/textElement.ts
@@ -13,11 +13,17 @@ import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene";
import { isTextElement } from ".";
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
+import {
+ isBoundToContainer,
+ isImageElement,
+ isArrowElement,
+} from "./typeChecks";
+import { LinearElementEditor } from "./linearElementEditor";
+import { AppState } from "../types";
import { isTextBindableContainer } from "./typeChecks";
import { getElementAbsoluteCoords } from "../element";
-import { AppState } from "../types";
import { getSelectedElements } from "../scene";
-import { isImageElement } from "./typeChecks";
+import { isHittingElementNotConsideringBoundingBox } from "./collision";
export const normalizeText = (text: string) => {
return (
@@ -52,36 +58,47 @@ export const redrawTextBoundingBox = (
let coordX = textElement.x;
// Resize container and vertically center align the text
if (container) {
- const containerDims = getContainerDims(container);
- let nextHeight = containerDims.height;
- if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
- coordY = container.y + BOUND_TEXT_PADDING;
- } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
- coordY =
- container.y +
- containerDims.height -
- metrics.height -
- BOUND_TEXT_PADDING;
- } else {
- coordY = container.y + containerDims.height / 2 - metrics.height / 2;
- if (metrics.height > getMaxContainerHeight(container)) {
- nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
- coordY = container.y + nextHeight / 2 - metrics.height / 2;
+ if (!isArrowElement(container)) {
+ const containerDims = getContainerDims(container);
+ let nextHeight = containerDims.height;
+ const boundTextElementPadding = getBoundTextElementOffset(textElement);
+ if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
+ coordY = container.y + boundTextElementPadding;
+ } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
+ coordY =
+ container.y +
+ containerDims.height -
+ metrics.height -
+ boundTextElementPadding;
+ } else {
+ coordY = container.y + containerDims.height / 2 - metrics.height / 2;
+ if (metrics.height > getMaxContainerHeight(container)) {
+ nextHeight = metrics.height + boundTextElementPadding * 2;
+ coordY = container.y + nextHeight / 2 - metrics.height / 2;
+ }
+ }
+ if (textElement.textAlign === TEXT_ALIGN.LEFT) {
+ coordX = container.x + boundTextElementPadding;
+ } else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
+ coordX =
+ container.x +
+ containerDims.width -
+ metrics.width -
+ boundTextElementPadding;
+ } else {
+ coordX = container.x + containerDims.width / 2 - metrics.width / 2;
}
- }
- if (textElement.textAlign === TEXT_ALIGN.LEFT) {
- coordX = container.x + BOUND_TEXT_PADDING;
- } else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
- coordX =
- container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING;
+ mutateElement(container, { height: nextHeight });
} else {
- coordX = container.x + container.width / 2 - metrics.width / 2;
+ const centerX = textElement.x + textElement.width / 2;
+ const centerY = textElement.y + textElement.height / 2;
+ const diffWidth = metrics.width - textElement.width;
+ const diffHeight = metrics.height - textElement.height;
+ coordY = centerY - (textElement.height + diffHeight) / 2;
+ coordX = centerX - (textElement.width + diffWidth) / 2;
}
-
- mutateElement(container, { height: nextHeight });
}
-
mutateElement(textElement, {
width: metrics.width,
height: metrics.height,
@@ -129,84 +146,113 @@ export const bindTextToShapeAfterDuplication = (
};
export const handleBindTextResize = (
- element: NonDeletedExcalidrawElement,
+ container: NonDeletedExcalidrawElement,
transformHandleType: MaybeTransformHandleType,
) => {
- const boundTextElementId = getBoundTextElementId(element);
- if (boundTextElementId) {
- const textElement = Scene.getScene(element)!.getElement(
+ const boundTextElementId = getBoundTextElementId(container);
+ if (!boundTextElementId) {
+ return;
+ }
+ let textElement = Scene.getScene(container)!.getElement(
+ boundTextElementId,
+ ) as ExcalidrawTextElement;
+ if (textElement && textElement.text) {
+ if (!container) {
+ return;
+ }
+
+ textElement = Scene.getScene(container)!.getElement(
boundTextElementId,
) as ExcalidrawTextElement;
- if (textElement && textElement.text) {
- if (!element) {
- return;
- }
- let text = textElement.text;
- let nextHeight = textElement.height;
- let nextWidth = textElement.width;
- let containerHeight = element.height;
- let nextBaseLine = textElement.baseline;
- if (transformHandleType !== "n" && transformHandleType !== "s") {
- if (text) {
- text = wrapText(
- textElement.originalText,
- getFontString(textElement),
- getMaxContainerWidth(element),
- );
- }
-
- const dimensions = measureText(
- text,
+ let text = textElement.text;
+ let nextHeight = textElement.height;
+ let nextWidth = textElement.width;
+ const containerDims = getContainerDims(container);
+ const maxWidth = getMaxContainerWidth(container);
+ const maxHeight = getMaxContainerHeight(container);
+ let containerHeight = containerDims.height;
+ let nextBaseLine = textElement.baseline;
+ if (transformHandleType !== "n" && transformHandleType !== "s") {
+ if (text) {
+ text = wrapText(
+ textElement.originalText,
getFontString(textElement),
- element.width,
+ maxWidth,
);
- nextHeight = dimensions.height;
- nextWidth = dimensions.width;
- nextBaseLine = dimensions.baseline;
- }
- // increase height in case text element height exceeds
- if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
- containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
- const diff = containerHeight - element.height;
- // fix the y coord when resizing from ne/nw/n
- const updatedY =
- transformHandleType === "ne" ||
- transformHandleType === "nw" ||
- transformHandleType === "n"
- ? element.y - diff
- : element.y;
- mutateElement(element, {
- height: containerHeight,
- y: updatedY,
- });
- }
-
- let updatedY;
- if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
- updatedY = element.y + BOUND_TEXT_PADDING;
- } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
- updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
- } else {
- updatedY = element.y + element.height / 2 - nextHeight / 2;
}
- const updatedX =
- textElement.textAlign === TEXT_ALIGN.LEFT
- ? element.x + BOUND_TEXT_PADDING
- : textElement.textAlign === TEXT_ALIGN.RIGHT
- ? element.x + element.width - nextWidth - BOUND_TEXT_PADDING
- : element.x + element.width / 2 - nextWidth / 2;
- mutateElement(textElement, {
+ const dimensions = measureText(
text,
- width: nextWidth,
- height: nextHeight,
- x: updatedX,
+ getFontString(textElement),
+ maxWidth,
+ );
+ nextHeight = dimensions.height;
+ nextWidth = dimensions.width;
+ nextBaseLine = dimensions.baseline;
+ }
+ // increase height in case text element height exceeds
+ if (nextHeight > maxHeight) {
+ containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
+ const diff = containerHeight - containerDims.height;
+ // fix the y coord when resizing from ne/nw/n
+ const updatedY =
+ !isArrowElement(container) &&
+ (transformHandleType === "ne" ||
+ transformHandleType === "nw" ||
+ transformHandleType === "n")
+ ? container.y - diff
+ : container.y;
+ mutateElement(container, {
+ height: containerHeight,
y: updatedY,
- baseline: nextBaseLine,
});
}
+
+ mutateElement(textElement, {
+ text,
+ width: nextWidth,
+ height: nextHeight,
+
+ baseline: nextBaseLine,
+ });
+ if (!isArrowElement(container)) {
+ updateBoundTextPosition(
+ container,
+ textElement as ExcalidrawTextElementWithContainer,
+ );
+ }
}
};
+const updateBoundTextPosition = (
+ container: ExcalidrawElement,
+ boundTextElement: ExcalidrawTextElementWithContainer,
+) => {
+ const containerDims = getContainerDims(container);
+ const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
+ let y;
+ if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
+ y = container.y + boundTextElementPadding;
+ } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
+ y =
+ container.y +
+ containerDims.height -
+ boundTextElement.height -
+ boundTextElementPadding;
+ } else {
+ y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
+ }
+ const x =
+ boundTextElement.textAlign === TEXT_ALIGN.LEFT
+ ? container.x + boundTextElementPadding
+ : boundTextElement.textAlign === TEXT_ALIGN.RIGHT
+ ? container.x +
+ containerDims.width -
+ boundTextElement.width -
+ boundTextElementPadding
+ : container.x + containerDims.width / 2 - boundTextElement.width / 2;
+
+ mutateElement(boundTextElement, { x, y });
+};
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (
text: string,
@@ -411,6 +457,7 @@ export const charWidth = (() => {
})();
export const getApproxMinLineWidth = (font: FontString) => {
const maxCharWidth = getMaxCharWidth(font);
+
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
@@ -491,7 +538,9 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
export const getContainerElement = (
element:
- | (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
+ | (ExcalidrawElement & {
+ containerId: ExcalidrawElement["id"] | null;
+ })
| null,
) => {
if (!element) {
@@ -504,9 +553,106 @@ export const getContainerElement = (
};
export const getContainerDims = (element: ExcalidrawElement) => {
+ const MIN_WIDTH = 300;
+ if (isArrowElement(element)) {
+ const width = Math.max(element.width, MIN_WIDTH);
+ const height = element.height;
+ return { width, height };
+ }
return { width: element.width, height: element.height };
};
+export const getContainerCenter = (
+ container: ExcalidrawElement,
+ appState: AppState,
+) => {
+ if (!isArrowElement(container)) {
+ return {
+ x: container.x + container.width / 2,
+ y: container.y + container.height / 2,
+ };
+ }
+ const points = LinearElementEditor.getPointsGlobalCoordinates(container);
+ if (points.length % 2 === 1) {
+ const index = Math.floor(container.points.length / 2);
+ const midPoint = LinearElementEditor.getPointGlobalCoordinates(
+ container,
+ container.points[index],
+ );
+ return { x: midPoint[0], y: midPoint[1] };
+ }
+ const index = container.points.length / 2 - 1;
+ let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
+ container,
+ appState,
+ )[index];
+ if (!midSegmentMidpoint) {
+ midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
+ container,
+ points[index],
+ points[index + 1],
+ index + 1,
+ );
+ }
+ return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
+};
+
+export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
+ const container = getContainerElement(textElement);
+ if (!container || isArrowElement(container)) {
+ return textElement.angle;
+ }
+ return container.angle;
+};
+
+export const getBoundTextElementOffset = (
+ boundTextElement: ExcalidrawTextElement | null,
+) => {
+ const container = getContainerElement(boundTextElement);
+ if (!container) {
+ return 0;
+ }
+ if (isArrowElement(container)) {
+ return BOUND_TEXT_PADDING * 8;
+ }
+ return BOUND_TEXT_PADDING;
+};
+
+export const getBoundTextElementPosition = (
+ container: ExcalidrawElement,
+ boundTextElement: ExcalidrawTextElementWithContainer,
+) => {
+ if (isArrowElement(container)) {
+ return LinearElementEditor.getBoundTextElementPosition(
+ container,
+ boundTextElement,
+ );
+ }
+};
+
+export const shouldAllowVerticalAlign = (
+ selectedElements: NonDeletedExcalidrawElement[],
+) => {
+ return selectedElements.some((element) => {
+ const hasBoundContainer = isBoundToContainer(element);
+ if (hasBoundContainer) {
+ const container = getContainerElement(element);
+ if (isTextElement(element) && isArrowElement(container)) {
+ return false;
+ }
+ return true;
+ }
+ const boundTextElement = getBoundTextElement(element);
+ if (boundTextElement) {
+ if (isArrowElement(element)) {
+ return false;
+ }
+ return true;
+ }
+ return false;
+ });
+};
+
export const getTextBindableContainerAtPosition = (
elements: readonly ExcalidrawElement[],
appState: AppState,
@@ -515,7 +661,9 @@ export const getTextBindableContainerAtPosition = (
): ExcalidrawTextContainer | null => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1) {
- return selectedElements[0] as ExcalidrawTextContainer;
+ return isTextBindableContainer(selectedElements[0], false)
+ ? selectedElements[0]
+ : null;
}
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
@@ -524,7 +672,16 @@ export const getTextBindableContainerAtPosition = (
continue;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
- if (x1 < x && x < x2 && y1 < y && y < y2) {
+ if (
+ isArrowElement(elements[index]) &&
+ isHittingElementNotConsideringBoundingBox(elements[index], appState, [
+ x,
+ y,
+ ])
+ ) {
+ hitElement = elements[index];
+ break;
+ } else if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[index];
break;
}
@@ -538,6 +695,7 @@ export const isValidTextContainer = (element: ExcalidrawElement) => {
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond" ||
- isImageElement(element)
+ isImageElement(element) ||
+ isArrowElement(element)
);
};
diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx
index 72aeb3fd4..a3929cf31 100644
--- a/src/element/textWysiwyg.test.tsx
+++ b/src/element/textWysiwyg.test.tsx
@@ -513,6 +513,9 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
@@ -586,20 +589,19 @@ describe("textWysiwyg", () => {
});
it("shouldn't bind to non-text-bindable containers", async () => {
- const line = API.createElement({
- type: "line",
+ const freedraw = API.createElement({
+ type: "freedraw",
width: 100,
height: 0,
- points: [
- [0, 0],
- [100, 0],
- ],
});
- h.elements = [line];
+ h.elements = [freedraw];
UI.clickTool("text");
- mouse.clickAt(line.x + line.width / 2, line.y + line.height / 2);
+ mouse.clickAt(
+ freedraw.x + freedraw.width / 2,
+ freedraw.y + freedraw.height / 2,
+ );
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
@@ -613,20 +615,22 @@ describe("textWysiwyg", () => {
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
editor.dispatchEvent(new Event("input"));
- expect(line.boundElements).toBe(null);
+ expect(freedraw.boundElements).toBe(null);
expect(h.elements[1].type).toBe("text");
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
});
- it("shouldn't create text element when pressing 'Enter' key on non text bindable container", async () => {
- h.elements = [];
- const freeDraw = UI.createElement("freedraw", {
- width: 100,
- height: 50,
+ ["freedraw", "line"].forEach((type: any) => {
+ it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
+ h.elements = [];
+ const elemnet = UI.createElement(type, {
+ width: 100,
+ height: 50,
+ });
+ API.setSelectedElements([elemnet]);
+ Keyboard.keyPress(KEYS.ENTER);
+ expect(h.elements.length).toBe(1);
});
- API.setSelectedElements([freeDraw]);
- Keyboard.keyPress(KEYS.ENTER);
- expect(h.elements.length).toBe(1);
});
it("should'nt bind text to container when not double clicked on center", async () => {
@@ -1206,7 +1210,7 @@ describe("textWysiwyg", () => {
fireEvent.change(editor, { target: { value: " " } });
editor.blur();
- expect(rectangle.boundElements).toBeNull();
+ expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1].isDeleted).toBe(true);
});
});
diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx
index da43d2784..ffa75d31e 100644
--- a/src/element/textWysiwyg.tsx
+++ b/src/element/textWysiwyg.tsx
@@ -6,11 +6,16 @@ import {
isTestEnv,
} from "../utils";
import Scene from "../scene/Scene";
-import { isBoundToContainer, isTextElement } from "./typeChecks";
-import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
+import {
+ isArrowElement,
+ isBoundToContainer,
+ isTextElement,
+} from "./typeChecks";
+import { CLASSES, VERTICAL_ALIGN } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
+ ExcalidrawTextElementWithContainer,
ExcalidrawTextElement,
} from "./types";
import { AppState } from "../types";
@@ -18,8 +23,10 @@ import { mutateElement } from "./mutateElement";
import {
getApproxLineHeight,
getBoundTextElementId,
+ getBoundTextElementOffset,
getContainerDims,
getContainerElement,
+ getTextElementAngle,
measureText,
normalizeText,
wrapText,
@@ -30,7 +37,8 @@ import {
} from "../actions/actionProperties";
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
import App from "../components/App";
-import { getMaxContainerWidth } from "./newElement";
+import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
+import { LinearElementEditor } from "./linearElementEditor";
import { parseClipboard } from "../clipboard";
const getTransform = (
@@ -108,7 +116,7 @@ export const textWysiwyg = ({
getFontString(updatedTextElement),
);
if (updatedTextElement && isTextElement(updatedTextElement)) {
- const coordX = updatedTextElement.x;
+ let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y;
const container = getContainerElement(updatedTextElement);
let maxWidth = updatedTextElement.width;
@@ -119,6 +127,15 @@ export const textWysiwyg = ({
// what is going to be used for unbounded text
let height = updatedTextElement.height;
if (container && updatedTextElement.containerId) {
+ if (isArrowElement(container)) {
+ const boundTextCoords =
+ LinearElementEditor.getBoundTextElementPosition(
+ container,
+ updatedTextElement as ExcalidrawTextElementWithContainer,
+ );
+ coordX = boundTextCoords.x;
+ coordY = boundTextCoords.y;
+ }
const propertiesUpdated = textPropertiesUpdated(
updatedTextElement,
editable,
@@ -138,16 +155,19 @@ export const textWysiwyg = ({
if (!originalContainerHeight) {
originalContainerHeight = containerDims.height;
}
- maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
- maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2;
+ maxWidth = getMaxContainerWidth(container);
+ maxHeight = getMaxContainerHeight(container);
+
// autogrow container height if text exceeds
- if (height > maxHeight) {
+
+ if (!isArrowElement(container) && height > maxHeight) {
const diff = Math.min(height - maxHeight, approxLineHeight);
mutateElement(container, { height: containerDims.height + diff });
return;
} else if (
// autoshrink container height until original container height
// is reached when text is removed
+ !isArrowElement(container) &&
containerDims.height > originalContainerHeight &&
height < maxHeight
) {
@@ -159,11 +179,16 @@ export const textWysiwyg = ({
else {
// vertically center align the text
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
- coordY = container.y + containerDims.height / 2 - height / 2;
+ if (!isArrowElement(container)) {
+ coordY = container.y + containerDims.height / 2 - height / 2;
+ }
}
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
coordY =
- container.y + containerDims.height - height - BOUND_TEXT_PADDING;
+ container.y +
+ containerDims.height -
+ height -
+ getBoundTextElementOffset(updatedTextElement);
}
}
}
@@ -197,7 +222,7 @@ export const textWysiwyg = ({
// Make sure text editor height doesn't go beyond viewport
const editorMaxHeight =
(appState.height - viewportY) / appState.zoom.value;
- const angle = container ? container.angle : updatedTextElement.angle;
+
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
@@ -209,7 +234,7 @@ export const textWysiwyg = ({
transform: getTransform(
width,
height,
- angle,
+ getTextElementAngle(updatedTextElement),
appState,
maxWidth,
editorMaxHeight,
@@ -246,6 +271,8 @@ export const textWysiwyg = ({
whiteSpace = "pre-wrap";
wordBreak = "break-word";
}
+ const isContainerArrow = isArrowElement(getContainerElement(element));
+ const background = isContainerArrow ? "#fff" : "transparent";
Object.assign(editable.style, {
position: "absolute",
display: "inline-block",
@@ -256,7 +283,7 @@ export const textWysiwyg = ({
border: 0,
outline: 0,
resize: "none",
- background: "transparent",
+ background,
overflow: "hidden",
// must be specified because in dark mode canvas creates a stacking context
zIndex: "var(--zIndex-wysiwyg)",
@@ -264,6 +291,7 @@ export const textWysiwyg = ({
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
whiteSpace,
overflowWrap: "break-word",
+ boxSizing: "content-box",
});
updateWysiwygStyle();
diff --git a/src/element/transformHandles.ts b/src/element/transformHandles.ts
index 210a2eab6..823ba5c72 100644
--- a/src/element/transformHandles.ts
+++ b/src/element/transformHandles.ts
@@ -4,7 +4,7 @@ import {
PointerType,
} from "./types";
-import { getElementAbsoluteCoords, Bounds } from "./bounds";
+import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import { AppState, Zoom } from "../types";
import { isTextElement } from ".";
@@ -81,7 +81,7 @@ const generateTransformHandle = (
};
export const getTransformHandlesFromCoords = (
- [x1, y1, x2, y2]: Bounds,
+ [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
angle: number,
zoom: Zoom,
pointerType: PointerType,
@@ -97,8 +97,6 @@ export const getTransformHandlesFromCoords = (
const width = x2 - x1;
const height = y2 - y1;
- const cx = (x1 + x2) / 2;
- const cy = (y1 + y2) / 2;
const dashedLineMargin = margin / zoom.value;
const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value);
@@ -256,7 +254,7 @@ export const getTransformHandles = (
? DEFAULT_SPACING + 8
: DEFAULT_SPACING;
return getTransformHandlesFromCoords(
- getElementAbsoluteCoords(element),
+ getElementAbsoluteCoords(element, true),
element.angle,
zoom,
pointerType,
diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts
index 053b31ccb..57c751594 100644
--- a/src/element/typeChecks.ts
+++ b/src/element/typeChecks.ts
@@ -60,6 +60,12 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type);
};
+export const isArrowElement = (
+ element?: ExcalidrawElement | null,
+): element is ExcalidrawLinearElement => {
+ return element != null && element.type === "arrow";
+};
+
export const isLinearElementType = (
elementType: AppState["activeTool"]["type"],
): boolean => {
@@ -110,7 +116,8 @@ export const isTextBindableContainer = (
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
- element.type === "image")
+ element.type === "image" ||
+ isArrowElement(element))
);
};
diff --git a/src/element/types.ts b/src/element/types.ts
index 8f8109650..de60a1b54 100644
--- a/src/element/types.ts
+++ b/src/element/types.ts
@@ -141,7 +141,8 @@ export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
- | ExcalidrawImageElement;
+ | ExcalidrawImageElement
+ | ExcalidrawArrowEleement;
export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawTextContainer["id"];
@@ -166,6 +167,11 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null;
}>;
+export type ExcalidrawArrowEleement = ExcalidrawLinearElement &
+ Readonly<{
+ type: "arrow";
+ }>;
+
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
diff --git a/src/locales/en.json b/src/locales/en.json
index 9fedaa3f9..54ac904da 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -237,7 +237,7 @@
"resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
"resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
"rotate": "You can constrain angles by holding SHIFT while rotating",
- "lineEditor_info": "Double-click or press Enter to edit points",
+ "lineEditor_info": "Hold CtrlOrCmd and Double-click or press CtrlOrCmd + Enter to edit points",
"lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move",
"lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points",
"placeImage": "Click to place the image, or click and drag to set its size manually",
diff --git a/src/points.ts b/src/points.ts
index 641a332de..84aea9277 100644
--- a/src/points.ts
+++ b/src/points.ts
@@ -51,6 +51,5 @@ export const rescalePoints = (
return currentDimension === dimension ? value + translation : value;
}) as [number, number],
);
-
return nextPoints;
};
diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts
index a951f66da..60b6f793f 100644
--- a/src/renderer/renderElement.ts
+++ b/src/renderer/renderElement.ts
@@ -6,12 +6,14 @@ import {
NonDeletedExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
+ ExcalidrawTextElementWithContainer,
} from "../element/types";
import {
isTextElement,
isLinearElement,
isFreeDrawElement,
isInitializedImageElement,
+ isArrowElement,
} from "../element/typeChecks";
import {
getDiamondPoints,
@@ -37,7 +39,13 @@ import {
VERTICAL_ALIGN,
} from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand";
-import { getApproxLineHeight } from "../element/textElement";
+import {
+ getApproxLineHeight,
+ getBoundTextElement,
+ getBoundTextElementOffset,
+ getContainerElement,
+} from "../element/textElement";
+import { LinearElementEditor } from "../element/linearElementEditor";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
@@ -80,6 +88,7 @@ export interface ExcalidrawElementWithCanvas {
canvasZoom: Zoom["value"];
canvasOffsetX: number;
canvasOffsetY: number;
+ boundTextElementVersion: number | null;
}
const generateElementCanvas = (
@@ -148,6 +157,7 @@ const generateElementCanvas = (
canvasZoom: zoom.value,
canvasOffsetX,
canvasOffsetY,
+ boundTextElementVersion: getBoundTextElement(element)?.version || null,
};
};
@@ -272,7 +282,7 @@ const drawElementOnCanvas = (
: element.height / lines.length;
let verticalOffset = element.height - element.baseline;
if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
- verticalOffset = BOUND_TEXT_PADDING;
+ verticalOffset = getBoundTextElementOffset(element);
}
const horizontalOffset =
@@ -656,11 +666,13 @@ const generateElementWithCanvas = (
prevElementWithCanvas &&
prevElementWithCanvas.canvasZoom !== zoom.value &&
!renderConfig?.shouldCacheIgnoreZoom;
+ const boundTextElementVersion = getBoundTextElement(element)?.version || null;
if (
!prevElementWithCanvas ||
shouldRegenerateBecauseZoom ||
- prevElementWithCanvas.theme !== renderConfig.theme
+ prevElementWithCanvas.theme !== renderConfig.theme ||
+ prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion
) {
const elementWithCanvas = generateElementCanvas(
element,
@@ -683,6 +695,7 @@ const drawElementFromCanvas = (
) => {
const element = elementWithCanvas.element;
const padding = getCanvasPadding(element);
+ const zoom = elementWithCanvas.canvasZoom;
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
// Free draw elements will otherwise "shuffle" as the min x and y change
@@ -712,18 +725,93 @@ const drawElementFromCanvas = (
(1 / window.devicePixelRatio) * scaleXFactor,
(1 / window.devicePixelRatio) * scaleYFactor,
);
- context.translate(cx * scaleXFactor, cy * scaleYFactor);
- context.rotate(element.angle * scaleXFactor * scaleYFactor);
+ const boundTextElement = getBoundTextElement(element);
+
+ if (isArrowElement(element) && boundTextElement) {
+ const tempCanvas = document.createElement("canvas");
+ const tempCanvasContext = tempCanvas.getContext("2d")!;
+
+ // Take max dimensions of arrow canvas so that when canvas is rotated
+ // the arrow doesn't get clipped
+ const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
+ tempCanvas.width =
+ maxDim * window.devicePixelRatio * zoom +
+ padding * elementWithCanvas.canvasZoom * 10;
+ tempCanvas.height =
+ maxDim * window.devicePixelRatio * zoom +
+ padding * elementWithCanvas.canvasZoom * 10;
+ const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2;
+ const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2;
+
+ tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2);
+ tempCanvasContext.rotate(element.angle);
+
+ tempCanvasContext.drawImage(
+ elementWithCanvas.canvas!,
+ -elementWithCanvas.canvas.width / 2,
+ -elementWithCanvas.canvas.height / 2,
+ elementWithCanvas.canvas.width,
+ elementWithCanvas.canvas.height,
+ );
- context.drawImage(
- elementWithCanvas.canvas!,
- (-(x2 - x1) / 2) * window.devicePixelRatio -
- (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
- (-(y2 - y1) / 2) * window.devicePixelRatio -
- (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
- elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
- elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
- );
+ const [, , , , boundTextCx, boundTextCy] =
+ getElementAbsoluteCoords(boundTextElement);
+
+ tempCanvasContext.rotate(-element.angle);
+
+ // Shift the canvas to the center of the bound text element
+ const shiftX =
+ tempCanvas.width / 2 -
+ (boundTextCx - x1) * window.devicePixelRatio * zoom -
+ offsetX -
+ padding * zoom;
+
+ const shiftY =
+ tempCanvas.height / 2 -
+ (boundTextCy - y1) * window.devicePixelRatio * zoom -
+ offsetY -
+ padding * zoom;
+ tempCanvasContext.translate(-shiftX, -shiftY);
+
+ // Clear the bound text area
+ tempCanvasContext.clearRect(
+ -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
+ window.devicePixelRatio *
+ zoom,
+ -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
+ window.devicePixelRatio *
+ zoom,
+ (boundTextElement.width + BOUND_TEXT_PADDING * 2) *
+ window.devicePixelRatio *
+ zoom,
+ (boundTextElement.height + BOUND_TEXT_PADDING * 2) *
+ window.devicePixelRatio *
+ zoom,
+ );
+
+ context.translate(cx * scaleXFactor, cy * scaleYFactor);
+ context.drawImage(
+ tempCanvas,
+ (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
+ (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
+ tempCanvas.width / zoom,
+ tempCanvas.height / zoom,
+ );
+ } else {
+ context.translate(cx * scaleXFactor, cy * scaleYFactor);
+
+ context.rotate(element.angle * scaleXFactor * scaleYFactor);
+
+ context.drawImage(
+ elementWithCanvas.canvas!,
+ (-(x2 - x1) / 2) * window.devicePixelRatio -
+ (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
+ (-(y2 - y1) / 2) * window.devicePixelRatio -
+ (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
+ elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
+ elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
+ );
+ }
context.restore();
// Clear the nested element we appended to the DOM
@@ -734,6 +822,7 @@ export const renderElement = (
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: RenderConfig,
+ appState: AppState,
) => {
const generator = rc.generator;
switch (element.type) {
@@ -796,21 +885,94 @@ export const renderElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2 + renderConfig.scrollX;
const cy = (y1 + y2) / 2 + renderConfig.scrollY;
- const shiftX = (x2 - x1) / 2 - (element.x - x1);
- const shiftY = (y2 - y1) / 2 - (element.y - y1);
+ let shiftX = (x2 - x1) / 2 - (element.x - x1);
+ let shiftY = (y2 - y1) / 2 - (element.y - y1);
+ if (isTextElement(element)) {
+ const container = getContainerElement(element);
+ if (isArrowElement(container)) {
+ const boundTextCoords =
+ LinearElementEditor.getBoundTextElementPosition(
+ container,
+ element as ExcalidrawTextElementWithContainer,
+ );
+ shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1);
+ shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1);
+ }
+ }
context.save();
context.translate(cx, cy);
- context.rotate(element.angle);
if (element.type === "image") {
context.scale(element.scale[0], element.scale[1]);
}
- context.translate(-shiftX, -shiftY);
if (shouldResetImageFilter(element, renderConfig)) {
context.filter = "none";
}
+ const boundTextElement = getBoundTextElement(element);
+
+ if (isArrowElement(element) && boundTextElement) {
+ const tempCanvas = document.createElement("canvas");
+
+ const tempCanvasContext = tempCanvas.getContext("2d")!;
+
+ // Take max dimensions of arrow canvas so that when canvas is rotated
+ // the arrow doesn't get clipped
+ const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
+ const padding = getCanvasPadding(element);
+ tempCanvas.width =
+ maxDim * appState.exportScale + padding * 10 * appState.exportScale;
+ tempCanvas.height =
+ maxDim * appState.exportScale + padding * 10 * appState.exportScale;
+
+ tempCanvasContext.translate(
+ tempCanvas.width / 2,
+ tempCanvas.height / 2,
+ );
+ tempCanvasContext.scale(appState.exportScale, appState.exportScale);
+
+ // Shift the canvas to left most point of the arrow
+ shiftX = element.width / 2 - (element.x - x1);
+ shiftY = element.height / 2 - (element.y - y1);
+
+ tempCanvasContext.rotate(element.angle);
+ const tempRc = rough.canvas(tempCanvas);
+
+ tempCanvasContext.translate(-shiftX, -shiftY);
+
+ drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
+
+ tempCanvasContext.translate(shiftX, shiftY);
+
+ tempCanvasContext.rotate(-element.angle);
+
+ // Shift the canvas to center of bound text
+ const [, , , , boundTextCx, boundTextCy] =
+ getElementAbsoluteCoords(boundTextElement);
+ const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
+ const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
+ tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
+
+ // Clear the bound text area
+ tempCanvasContext.clearRect(
+ -boundTextElement.width / 2,
+ -boundTextElement.height / 2,
+ boundTextElement.width,
+ boundTextElement.height,
+ );
+ context.scale(1 / appState.exportScale, 1 / appState.exportScale);
+ context.drawImage(
+ tempCanvas,
+ -tempCanvas.width / 2,
+ -tempCanvas.height / 2,
+ tempCanvas.width,
+ tempCanvas.height,
+ );
+ } else {
+ context.rotate(element.angle);
+ context.translate(-shiftX, -shiftY);
+ drawElementOnCanvas(element, rc, context, renderConfig);
+ }
- drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
// not exporting → optimized rendering (cache & render from element
// canvases)
@@ -851,13 +1013,28 @@ export const renderElementToSvg = (
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
- offsetX?: number,
- offsetY?: number,
+ offsetX: number,
+ offsetY: number,
exportWithDarkMode?: boolean,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
- const cx = (x2 - x1) / 2 - (element.x - x1);
- const cy = (y2 - y1) / 2 - (element.y - y1);
+ let cx = (x2 - x1) / 2 - (element.x - x1);
+ let cy = (y2 - y1) / 2 - (element.y - y1);
+ if (isTextElement(element)) {
+ const container = getContainerElement(element);
+ if (isArrowElement(container)) {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(container);
+
+ const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
+ container,
+ element as ExcalidrawTextElementWithContainer,
+ );
+ cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
+ cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
+ offsetX = offsetX + boundTextCoords.x - element.x;
+ offsetY = offsetY + boundTextCoords.y - element.y;
+ }
+ }
const degree = (180 * element.angle) / Math.PI;
const generator = rsvg.generator;
@@ -904,8 +1081,54 @@ export const renderElementToSvg = (
}
case "line":
case "arrow": {
+ const boundText = getBoundTextElement(element);
+ const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
+ if (boundText) {
+ maskPath.setAttribute("id", `mask-${element.id}`);
+ const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
+ SVG_NS,
+ "rect",
+ );
+ offsetX = offsetX || 0;
+ offsetY = offsetY || 0;
+ maskRectVisible.setAttribute("x", "0");
+ maskRectVisible.setAttribute("y", "0");
+ maskRectVisible.setAttribute("fill", "#fff");
+ maskRectVisible.setAttribute(
+ "width",
+ `${element.width + 100 + offsetX}`,
+ );
+ maskRectVisible.setAttribute(
+ "height",
+ `${element.height + 100 + offsetY}`,
+ );
+
+ maskPath.appendChild(maskRectVisible);
+ const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
+ SVG_NS,
+ "rect",
+ );
+ const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
+ element,
+ boundText,
+ );
+
+ const maskX = offsetX + boundTextCoords.x - element.x;
+ const maskY = offsetY + boundTextCoords.y - element.y;
+
+ maskRectInvisible.setAttribute("x", maskX.toString());
+ maskRectInvisible.setAttribute("y", maskY.toString());
+ maskRectInvisible.setAttribute("fill", "#000");
+ maskRectInvisible.setAttribute("width", `${boundText.width}`);
+ maskRectInvisible.setAttribute("height", `${boundText.height}`);
+ maskRectInvisible.setAttribute("opacity", "1");
+ maskPath.appendChild(maskRectInvisible);
+ }
generateElementShape(element, generator);
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
+ if (boundText) {
+ group.setAttribute("mask", `url(#mask-${element.id})`);
+ }
const opacity = element.opacity / 100;
group.setAttribute("stroke-linecap", "round");
@@ -935,6 +1158,7 @@ export const renderElementToSvg = (
group.appendChild(node);
});
root.appendChild(group);
+ root.append(maskPath);
break;
}
case "freedraw": {
@@ -1033,6 +1257,7 @@ export const renderElementToSvg = (
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
+
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts
index 7c089ed53..c8b64b47b 100644
--- a/src/renderer/renderScene.ts
+++ b/src/renderer/renderScene.ts
@@ -348,7 +348,6 @@ export const _renderScene = ({
context.setTransform(1, 0, 0, 1, 0, 0);
context.save();
context.scale(scale, scale);
-
// When doing calculations based on canvas width we should used normalized one
const normalizedCanvasWidth = canvas.width / scale;
const normalizedCanvasHeight = canvas.height / scale;
@@ -410,7 +409,7 @@ export const _renderScene = ({
undefined;
visibleElements.forEach((element) => {
try {
- renderElement(element, rc, context, renderConfig);
+ renderElement(element, rc, context, renderConfig, appState);
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
// ShapeCache returns empty hence making sure that we get the
// correct element from visible elements
@@ -440,7 +439,13 @@ export const _renderScene = ({
// Paint selection element
if (appState.selectionElement) {
try {
- renderElement(appState.selectionElement, rc, context, renderConfig);
+ renderElement(
+ appState.selectionElement,
+ rc,
+ context,
+ renderConfig,
+ appState,
+ );
} catch (error: any) {
console.error(error);
}
@@ -453,6 +458,22 @@ export const _renderScene = ({
renderBindingHighlight(context, renderConfig, suggestedBinding!);
});
}
+ const locallySelectedElements = getSelectedElements(elements, appState);
+
+ // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
+ // ShapeCache returns empty hence making sure that we get the
+ // correct element from visible elements
+ if (
+ locallySelectedElements.length === 1 &&
+ appState.editingLinearElement?.elementId === locallySelectedElements[0].id
+ ) {
+ renderLinearPointHandles(
+ context,
+ appState,
+ renderConfig,
+ locallySelectedElements[0] as NonDeleted,
+ );
+ }
if (
appState.selectedLinearElement &&
@@ -466,7 +487,6 @@ export const _renderScene = ({
!appState.multiElement &&
!appState.editingLinearElement
) {
- const locallySelectedElements = getSelectedElements(elements, appState);
const showBoundingBox = shouldShowBoundingBox(
locallySelectedElements,
appState,
@@ -515,8 +535,8 @@ export const _renderScene = ({
}
if (selectionColors.length) {
- const [elementX1, elementY1, elementX2, elementY2] =
- getElementAbsoluteCoords(element);
+ const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
+ getElementAbsoluteCoords(element, true);
acc.push({
angle: element.angle,
elementX1,
@@ -525,10 +545,12 @@ export const _renderScene = ({
elementY2,
selectionColors,
dashed: !!renderConfig.remoteSelectedElementIds[element.id],
+ cx,
+ cy,
});
}
return acc;
- }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean }[]);
+ }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean; cx: number; cy: number }[]);
const addSelectionForGroupId = (groupId: GroupId) => {
const groupElements = getElementsInGroup(elements, groupId);
@@ -540,8 +562,10 @@ export const _renderScene = ({
elementX2,
elementY1,
elementY2,
- selectionColors: [selectionColor],
+ selectionColors: [oc.black],
dashed: true,
+ cx: elementX1 + (elementX2 - elementX1) / 2,
+ cy: elementY1 + (elementY2 - elementY1) / 2,
});
};
@@ -600,7 +624,7 @@ export const _renderScene = ({
context.lineWidth = lineWidth;
context.setLineDash(initialLineDash);
const transformHandles = getTransformHandlesFromCoords(
- [x1, y1, x2, y2],
+ [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
renderConfig.zoom,
"mouse",
@@ -861,6 +885,8 @@ const renderSelectionBorder = (
elementY2: number;
selectionColors: string[];
dashed?: boolean;
+ cx: number;
+ cy: number;
},
padding = DEFAULT_SPACING * 2,
) => {
@@ -871,6 +897,8 @@ const renderSelectionBorder = (
elementX2,
elementY2,
selectionColors,
+ cx,
+ cy,
dashed,
} = elementProperties;
const elementWidth = elementX2 - elementX1;
@@ -900,8 +928,8 @@ const renderSelectionBorder = (
elementY1 - linePadding,
elementWidth + linePadding * 2,
elementHeight + linePadding * 2,
- elementX1 + elementWidth / 2,
- elementY1 + elementHeight / 2,
+ cx,
+ cy,
angle,
);
}
@@ -1117,7 +1145,7 @@ export const renderSceneToSvg = (
return;
}
// render elements
- elements.forEach((element) => {
+ elements.forEach((element, index) => {
if (!element.isDeleted) {
try {
renderElementToSvg(
diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts
index 14d9a8e2c..3e0de1536 100644
--- a/src/tests/helpers/api.ts
+++ b/src/tests/helpers/api.ts
@@ -109,6 +109,9 @@ export class API {
fileId?: T extends "image" ? string : never;
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
+ endBinding?: T extends "arrow"
+ ? ExcalidrawLinearElement["endBinding"]
+ : never;
}): T extends "arrow" | "line"
? ExcalidrawLinearElement
: T extends "freedraw"
diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx
index e46311e40..9e7fa5679 100644
--- a/src/tests/linearElementEditor.test.tsx
+++ b/src/tests/linearElementEditor.test.tsx
@@ -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"
+ `);
+ });
+ });
});
diff --git a/src/tests/utils.ts b/src/tests/utils.ts
index 4e533d56e..2c91c3fce 100644
--- a/src/tests/utils.ts
+++ b/src/tests/utils.ts
@@ -27,3 +27,22 @@ export const resize = (
mouse.up();
});
};
+
+export const rotate = (
+ element: ExcalidrawElement,
+ deltaX: number,
+ deltaY: number,
+ keyboardModifiers: KeyboardModifiers = {},
+) => {
+ mouse.select(element);
+ const handle = getTransformHandles(element, h.state.zoom, "mouse").rotation!;
+ const clientX = handle[0] + handle[2] / 2;
+ const clientY = handle[1] + handle[3] / 2;
+
+ Keyboard.withModifierKeys(keyboardModifiers, () => {
+ mouse.reset();
+ mouse.down(clientX, clientY);
+ mouse.move(clientX + deltaX, clientY + deltaY);
+ mouse.up();
+ });
+};
diff --git a/src/utils.ts b/src/utils.ts
index 9a92560dc..aef6a7d57 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -327,13 +327,12 @@ export const getShortcutKey = (shortcut: string): string => {
.replace(/\bAlt\b/i, "Alt")
.replace(/\bShift\b/i, "Shift")
.replace(/\b(Enter|Return)\b/i, "Enter");
-
if (isDarwin) {
return shortcut
- .replace(/\bCtrlOrCmd\b/i, "Cmd")
+ .replace(/\bCtrlOrCmd\b/gi, "Cmd")
.replace(/\bAlt\b/i, "Option");
}
- return shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl");
+ return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
};
export const viewportCoordsToSceneCoords = (