mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-07-31 07:57:08 -04:00
feat(editor): bind text to hovered arrow endpoint (#11777)
This commit is contained in:
288
packages/element/src/arrowEndpointText.ts
Normal file
288
packages/element/src/arrowEndpointText.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Text bound to an arrow endpoint — creating a text element that reads as a
|
||||
* label for the tip of an arrow, placed and anchored so the arrow itself never
|
||||
* moves.
|
||||
*
|
||||
* The App-side interaction (hover affordance, hit gating, creation/drag entry
|
||||
* points) lives in `App.arrowText.ts`; the generic binding bookkeeping these
|
||||
* helpers rely on stays in `binding.ts`.
|
||||
*/
|
||||
import { TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
pointDistance,
|
||||
pointFromVector,
|
||||
pointsEqual,
|
||||
vector,
|
||||
vectorDot,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getBindingGap, normalizeFixedPoint } from "./binding";
|
||||
import {
|
||||
HEADING_DOWN,
|
||||
HEADING_LEFT,
|
||||
HEADING_RIGHT,
|
||||
HEADING_UP,
|
||||
compareHeading,
|
||||
} from "./heading";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getTextAnchorRatios } from "./newElement";
|
||||
import { isArrowElement, isElbowArrow } from "./typeChecks";
|
||||
|
||||
import type { Heading } from "./heading";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawTextElement,
|
||||
FixedPoint,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "./types";
|
||||
|
||||
export type ArrowEndpoint = {
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>;
|
||||
startOrEnd: "start" | "end";
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds a free (unbound) arrow endpoint under the cursor, front-most first.
|
||||
* Used by the text tool to offer creating a text label anchored to the tip of
|
||||
* an arrow.
|
||||
*/
|
||||
export const getUnboundArrowEndpointAtPoint = (
|
||||
scenePointer: GlobalPoint,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
): ArrowEndpoint | null => {
|
||||
// front to back, so the top-most arrow wins when endpoints overlap
|
||||
for (let index = elements.length - 1; index >= 0; index--) {
|
||||
const element = elements[index];
|
||||
|
||||
if (
|
||||
!isArrowElement(element) ||
|
||||
element.locked ||
|
||||
element.points.length < 2
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// prefer the end (arrowhead) over the start when both are within range
|
||||
for (const startOrEnd of ["end", "start"] as const) {
|
||||
if (element[startOrEnd === "start" ? "startBinding" : "endBinding"]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// a zero-length tail has no direction to place a text along, so
|
||||
// `getTextBindingForArrowEndpoint` would refuse the endpoint — don't
|
||||
// offer it at all rather than highlight something a click won't honor
|
||||
if (
|
||||
pointsEqual(
|
||||
element.points[
|
||||
startOrEnd === "start" ? 0 : element.points.length - 1
|
||||
],
|
||||
element.points[
|
||||
startOrEnd === "start" ? 1 : element.points.length - 2
|
||||
],
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
startOrEnd === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// same reach as grabbing a point handle in the linear editor
|
||||
// (`LinearElementEditor.getPointIndexUnderCursor`), which can't be
|
||||
// reused here: it reports whichever point is nearest, with no way to
|
||||
// skip an endpoint that is already bound and fall through to the other
|
||||
if (
|
||||
pointDistance(scenePointer, point) * zoom.value <
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
) {
|
||||
return { arrow: element, startOrEnd };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes how a text element should be created so that it reads as a label
|
||||
* for the given arrow endpoint.
|
||||
*
|
||||
* The arrow is treated as fixed: rather than routing the arrow to the text, we
|
||||
* pick the side of the text the arrow should attach to (the one it already
|
||||
* points at), and place the text so that side's midpoint lands on the existing
|
||||
* endpoint. The alignment returned pins that same midpoint while the text is
|
||||
* typed, so the arrow doesn't swing around during editing.
|
||||
*/
|
||||
export const getTextBindingForArrowEndpoint = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
/**
|
||||
* stroke width the text will be created with — the binding gap is derived
|
||||
* from the *bind target*, so using the arrow's would offset the anchor by
|
||||
* half the difference between the two
|
||||
*/
|
||||
targetStrokeWidth: number,
|
||||
): {
|
||||
/** the text-local ratio the arrow binds to (a side midpoint) */
|
||||
fixedPoint: FixedPoint;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
/** scene position the text's bound side midpoint should sit at */
|
||||
anchor: GlobalPoint;
|
||||
} | null => {
|
||||
const endpoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
const neighbor = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 1 : -2,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (pointsEqual(endpoint, neighbor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The direction the arrow travels as it reaches this endpoint, snapped to
|
||||
// the dominant axis — i.e. the direction the text should extend away from
|
||||
// the tip.
|
||||
//
|
||||
// Deliberately not `vectorToHeading`: its tie-break resolves an exact 45°
|
||||
// vector to a direction the arrow isn't travelling in at all (a perfectly
|
||||
// down-right vector yields UP), which would put the text on the wrong side.
|
||||
// Here a tie picks the horizontal axis, always with the correct sign.
|
||||
const direction = vectorFromPoint(endpoint, neighbor);
|
||||
const heading: Heading =
|
||||
Math.abs(direction[0]) >= Math.abs(direction[1])
|
||||
? direction[0] >= 0
|
||||
? HEADING_RIGHT
|
||||
: HEADING_LEFT
|
||||
: direction[1] >= 0
|
||||
? HEADING_DOWN
|
||||
: HEADING_UP;
|
||||
|
||||
// Keep the tip off the text's bounding box by the usual binding gap so the
|
||||
// arrowhead doesn't collide with the glyphs. Must match the gap
|
||||
// `updateBoundPoint` will resolve the binding with, or the first text update
|
||||
// shifts the arrow. Elbow arrows terminate on the fixed point itself rather
|
||||
// than gap-outside the outline, so offsetting there would just shift them.
|
||||
const gap = isElbowArrow(arrow)
|
||||
? 0
|
||||
: getBindingGap({ strokeWidth: targetStrokeWidth }, arrow);
|
||||
|
||||
// How far back along the arrow the bound side has to sit for the tip to
|
||||
// stay exactly where it is.
|
||||
//
|
||||
// `updateBoundPoint` resolves an orbit binding by intersecting the arrow's
|
||||
// own line with the target's outline grown by `gap`; it does not step along
|
||||
// the bound side's normal. Offsetting the anchor perpendicular to the side
|
||||
// would therefore leave the tip `gap * tan(angle)` off to one side on a
|
||||
// diagonal arrow. Walking back along the arrow instead keeps the anchor both
|
||||
// `gap` clear of the tip and collinear with the arrow, so the intersection
|
||||
// lands back on the tip itself.
|
||||
//
|
||||
// `heading` is the dominant axis of the arrow direction, so it is never more
|
||||
// than 45° away from it and the divisor stays >= cos(45°).
|
||||
const awayFromTip = vectorNormalize(vectorFromPoint(neighbor, endpoint));
|
||||
const alongHeading = Math.abs(
|
||||
vectorDot(awayFromTip, vector(heading[0], heading[1])),
|
||||
);
|
||||
const anchor = pointFromVector(
|
||||
vectorScale(awayFromTip, -gap / alongHeading),
|
||||
endpoint,
|
||||
);
|
||||
|
||||
if (compareHeading(heading, HEADING_RIGHT)) {
|
||||
// text sits to the right of the tip -> bind its left side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([0, 0.5]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.LEFT as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
if (compareHeading(heading, HEADING_LEFT)) {
|
||||
// text sits to the left of the tip -> bind its right side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([1, 0.5]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.RIGHT as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
if (compareHeading(heading, HEADING_DOWN)) {
|
||||
// text sits below the tip -> bind its top side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([0.5, 0]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.CENTER as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.TOP as VerticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
// text sits above the tip -> bind its bottom side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([0.5, 1]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.CENTER as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.BOTTOM as VerticalAlign,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an arrow endpoint is bound to this text — i.e. the text serves as an
|
||||
* arrow-endpoint label. Checked on the arrows' own bindings: the text's
|
||||
* `boundElements` only says an arrow relates to it, while the binding on the
|
||||
* arrow is the authoritative side of the relationship.
|
||||
*/
|
||||
export const isEndpointBoundText = (
|
||||
text: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean =>
|
||||
!!text.boundElements?.some(({ id, type }) => {
|
||||
if (type !== "arrow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const arrow = elementsMap.get(id);
|
||||
|
||||
return (
|
||||
isArrowElement(arrow) &&
|
||||
(arrow.startBinding?.elementId === text.id ||
|
||||
arrow.endBinding?.elementId === text.id)
|
||||
);
|
||||
});
|
||||
|
||||
/** the anchor a text bound to an arrow endpoint grows away from */
|
||||
export const getEndpointBoundTextDragAnchor = (
|
||||
newElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
const anchorRatio = getTextAnchorRatios(newElement).x;
|
||||
|
||||
return {
|
||||
anchorRatio,
|
||||
anchorX: newElement.x + newElement.width * anchorRatio,
|
||||
};
|
||||
};
|
||||
@@ -123,7 +123,9 @@ export const FOCUS_POINT_SIZE = 10 / 1.5;
|
||||
const MIN_BINDABLE_SIZE = 1;
|
||||
|
||||
export const getBindingGap = (
|
||||
bindTarget: ExcalidrawBindableElement,
|
||||
// only the stroke width is needed, so the gap can also be computed for a
|
||||
// bind target that doesn't exist yet (see `getTextBindingForArrowEndpoint`)
|
||||
bindTarget: Pick<ExcalidrawBindableElement, "strokeWidth">,
|
||||
opts: Pick<ExcalidrawArrowElement, "elbowed">,
|
||||
): number => {
|
||||
return (
|
||||
@@ -1098,6 +1100,32 @@ export const bindOrUnbindBindingElements = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a binding onto the arrow and records the arrow on the bind target,
|
||||
* keeping the two sides of the relationship in step.
|
||||
*/
|
||||
const applyBinding = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
binding: FixedPointBinding,
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
): void => {
|
||||
scene.mutateElement(arrow, {
|
||||
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
|
||||
});
|
||||
|
||||
const boundElementsMap = arrayToMap(bindableElement.boundElements || []);
|
||||
if (!boundElementsMap.has(arrow.id)) {
|
||||
scene.mutateElement(bindableElement, {
|
||||
boundElements: (bindableElement.boundElements || []).concat({
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const bindBindingElement = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
hoveredElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
@@ -1139,19 +1167,7 @@ export const bindBindingElement = (
|
||||
};
|
||||
}
|
||||
|
||||
scene.mutateElement(arrow, {
|
||||
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
|
||||
});
|
||||
|
||||
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
|
||||
if (!boundElementsMap.has(arrow.id)) {
|
||||
scene.mutateElement(hoveredElement, {
|
||||
boundElements: (hoveredElement.boundElements || []).concat({
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
}),
|
||||
});
|
||||
}
|
||||
applyBinding(arrow, hoveredElement, binding, startOrEnd, scene);
|
||||
};
|
||||
|
||||
export const unbindBindingElement = (
|
||||
@@ -3154,3 +3170,29 @@ export const getBindingSideMidPoint = (
|
||||
const getMidPoint = (p1: GlobalPoint, p2: GlobalPoint): GlobalPoint => {
|
||||
return pointFrom((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Binds an arrow endpoint to an explicit fixed point on a bindable element,
|
||||
* bypassing the "derive the ratio from where the endpoint currently is"
|
||||
* strategy used when dragging. Needed when the binding target is placed to fit
|
||||
* the arrow rather than the other way round.
|
||||
*/
|
||||
export const bindBindingElementToFixedPoint = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
fixedPoint: FixedPoint,
|
||||
scene: Scene,
|
||||
): void => {
|
||||
applyBinding(
|
||||
arrow,
|
||||
bindableElement,
|
||||
{
|
||||
elementId: bindableElement.id,
|
||||
fixedPoint: normalizeFixedPoint(fixedPoint),
|
||||
mode: "orbit",
|
||||
},
|
||||
startOrEnd,
|
||||
scene,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
import type { ExcalidrawElement, ExcalidrawTextElement } from "./types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
@@ -228,6 +228,72 @@ export const getDragOffsetXY = (
|
||||
return [x - x1, y - y1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sizes a text element as it is dragged out.
|
||||
*
|
||||
* A dragged text pins one point and grows away from it; `anchorRatio` says
|
||||
* where along the box that point sits — 0 for its left edge, 1 for its right,
|
||||
* 0.5 for its centre.
|
||||
*
|
||||
* A free text pins the point the drag started from and takes the ratio from
|
||||
* the drag direction, so it can be pulled either way. A text bound to an arrow
|
||||
* endpoint instead pins whatever the binding placed it against and takes the
|
||||
* ratio from its alignment — which is also what keeps it from growing back
|
||||
* over the arrow, since dragging that way makes no progress rather than
|
||||
* flipping the box around.
|
||||
*/
|
||||
export const dragNewTextElement = ({
|
||||
newElement,
|
||||
anchorX,
|
||||
anchorRatio,
|
||||
pointerX,
|
||||
nextY,
|
||||
zoom,
|
||||
scene,
|
||||
informMutation = true,
|
||||
}: {
|
||||
newElement: ExcalidrawTextElement;
|
||||
anchorX: number;
|
||||
/** 0 = anchored by its left edge, 1 = by its right, 0.5 = by its centre */
|
||||
anchorRatio: number;
|
||||
pointerX: number;
|
||||
/** free text re-tops itself to the drag origin; a bound one must not move */
|
||||
nextY?: number;
|
||||
zoom: NormalizedZoomValue;
|
||||
scene: Scene;
|
||||
informMutation?: boolean;
|
||||
}) => {
|
||||
const offset = pointerX - anchorX;
|
||||
|
||||
// how far the pointer has travelled away from the anchor along the direction
|
||||
// the box may grow — negative once it heads back the other way
|
||||
const reach =
|
||||
anchorRatio === 0 ? offset : anchorRatio === 1 ? -offset : Math.abs(offset);
|
||||
|
||||
const width = Math.max(
|
||||
// a centred box grows on both sides, so it widens at twice the reach
|
||||
anchorRatio === 0.5 ? reach * 2 : reach,
|
||||
getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: newElement.fontSize,
|
||||
fontFamily: newElement.fontFamily,
|
||||
}),
|
||||
newElement.lineHeight,
|
||||
),
|
||||
);
|
||||
|
||||
scene.mutateElement(
|
||||
newElement,
|
||||
{
|
||||
x: anchorX - width * anchorRatio,
|
||||
...(nextY === undefined ? {} : { y: nextY }),
|
||||
width,
|
||||
...(reach > TEXT_AUTOWRAP_THRESHOLD / zoom ? { autoResize: false } : {}),
|
||||
},
|
||||
{ informMutation, isDragging: false },
|
||||
);
|
||||
};
|
||||
|
||||
export const dragNewElement = ({
|
||||
newElement,
|
||||
elementType,
|
||||
@@ -293,6 +359,22 @@ export const dragNewElement = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
// a text is only ever sized horizontally — its height follows the wrapped
|
||||
// content — so it grows away from the point the drag started at
|
||||
dragNewTextElement({
|
||||
newElement,
|
||||
anchorX: originX + (originOffset?.x ?? 0),
|
||||
anchorRatio: shouldResizeFromCenter ? 0.5 : x < originX ? 1 : 0,
|
||||
pointerX: x,
|
||||
nextY: originY + (originOffset?.y ?? 0),
|
||||
zoom,
|
||||
scene,
|
||||
informMutation,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let newX = x < originX ? originX - width : originX;
|
||||
let newY = y < originY ? originY - height : originY;
|
||||
|
||||
@@ -303,31 +385,6 @@ export const dragNewElement = ({
|
||||
newY = originY - height / 2;
|
||||
}
|
||||
|
||||
let textAutoResize = null;
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
height = newElement.height;
|
||||
const minWidth = getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: newElement.fontSize,
|
||||
fontFamily: newElement.fontFamily,
|
||||
}),
|
||||
newElement.lineHeight,
|
||||
);
|
||||
width = Math.max(width, minWidth);
|
||||
|
||||
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
|
||||
textAutoResize = {
|
||||
autoResize: false,
|
||||
};
|
||||
}
|
||||
|
||||
newY = originY;
|
||||
if (shouldResizeFromCenter) {
|
||||
newX = originX - width / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
let imageInitialDimension = null;
|
||||
if (isImageElement(newElement)) {
|
||||
@@ -344,7 +401,6 @@ export const dragNewElement = ({
|
||||
y: newY + (originOffset?.y ?? 0),
|
||||
width,
|
||||
height,
|
||||
...textAutoResize,
|
||||
...imageInitialDimension,
|
||||
},
|
||||
{ informMutation, isDragging: false },
|
||||
|
||||
@@ -57,6 +57,7 @@ export const getNonDeletedElements = <T extends ExcalidrawElement>(
|
||||
): readonly NonDeleted<T>[] => elements.filter(isNonDeletedElement);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./arrowEndpointText";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
export * from "./collision";
|
||||
|
||||
@@ -1859,6 +1859,32 @@ export class LinearElementEditor {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The point a bound text label is centered on — the middle of the linear
|
||||
* element, whether that's an actual point or the midpoint of the middle
|
||||
* segment.
|
||||
*/
|
||||
static getBoundTextElementCenter = (
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint => {
|
||||
if (element.points.length % 2 === 1) {
|
||||
const index = Math.floor(element.points.length / 2);
|
||||
return LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
const index = element.points.length / 2 - 1;
|
||||
return LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
static getBoundTextElementPosition = (
|
||||
element: ExcalidrawLinearElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
@@ -1871,29 +1897,16 @@ export class LinearElementEditor {
|
||||
if (points.length < 2) {
|
||||
mutateElement(boundTextElement, elementsMap, { 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],
|
||||
elementsMap,
|
||||
);
|
||||
x = midPoint[0] - boundTextElement.width / 2;
|
||||
y = midPoint[1] - boundTextElement.height / 2;
|
||||
} else {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||
}
|
||||
return { x, y };
|
||||
const center = LinearElementEditor.getBoundTextElementCenter(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return {
|
||||
x: center[0] - boundTextElement.width / 2,
|
||||
y: center[1] - boundTextElement.height / 2,
|
||||
};
|
||||
};
|
||||
|
||||
static getMinMaxXYWithBoundText = (
|
||||
|
||||
@@ -218,6 +218,25 @@ export const newMagicFrameElement = (
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* The point of the text box its alignment pins, as ratios of width/height.
|
||||
*
|
||||
* This is the point that stays put as the text grows — see the sides passed to
|
||||
* `adjustXYWithRotation` in `getAdjustedDimensions`.
|
||||
*/
|
||||
export const getTextAnchorRatios = (opts: {
|
||||
textAlign: ExcalidrawTextElement["textAlign"];
|
||||
verticalAlign: ExcalidrawTextElement["verticalAlign"];
|
||||
}) => ({
|
||||
x: opts.textAlign === "center" ? 0.5 : opts.textAlign === "right" ? 1 : 0,
|
||||
y:
|
||||
opts.verticalAlign === VERTICAL_ALIGN.MIDDLE
|
||||
? 0.5
|
||||
: opts.verticalAlign === VERTICAL_ALIGN.BOTTOM
|
||||
? 1
|
||||
: 0,
|
||||
});
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
@@ -229,14 +248,11 @@ const getTextElementPositionOffsets = (
|
||||
height: number;
|
||||
},
|
||||
) => {
|
||||
const ratios = getTextAnchorRatios(opts);
|
||||
|
||||
return {
|
||||
x:
|
||||
opts.textAlign === "center"
|
||||
? metrics.width / 2
|
||||
: opts.textAlign === "right"
|
||||
? metrics.width
|
||||
: 0,
|
||||
y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
|
||||
x: metrics.width * ratios.x,
|
||||
y: metrics.height * ratios.y,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -350,9 +366,18 @@ const getAdjustedDimensions = (
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
|
||||
// grow away from the edge(s) the alignment anchors the text to, so that
|
||||
// the anchor stays put as the text is edited. `verticalAlign` has no
|
||||
// visual effect on unbound text, but standalone text bound to an arrow
|
||||
// endpoint uses it to pin the side the arrow attaches to.
|
||||
[x, y] = adjustXYWithRotation(
|
||||
{
|
||||
s: true,
|
||||
n:
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
|
||||
verticalAlign === VERTICAL_ALIGN.BOTTOM,
|
||||
s:
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
|
||||
verticalAlign === VERTICAL_ALIGN.TOP,
|
||||
e: textAlign === "center" || textAlign === "left",
|
||||
w: textAlign === "center" || textAlign === "right",
|
||||
},
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
|
||||
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
@@ -333,9 +331,12 @@ export const getContainerElement = <
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The point a text bound to this container centers on — and, for arrows, the
|
||||
* point the text tool snaps a new label to.
|
||||
*/
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!isArrowElement(container)) {
|
||||
@@ -344,33 +345,13 @@ export const getContainerCenter = (
|
||||
y: container.y + container.height / 2,
|
||||
};
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
|
||||
const center = LinearElementEditor.getBoundTextElementCenter(
|
||||
container,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length % 2 === 1) {
|
||||
const index = Math.floor(container.points.length / 2);
|
||||
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
container,
|
||||
container.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
return { x: midPoint[0], y: midPoint[1] };
|
||||
}
|
||||
const index = container.points.length / 2 - 1;
|
||||
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
|
||||
container,
|
||||
elementsMap,
|
||||
appState,
|
||||
)[index];
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
|
||||
return { x: center[0], y: center[1] };
|
||||
};
|
||||
|
||||
export const getContainerCoords = (container: ExcalidrawElement) => {
|
||||
|
||||
@@ -103,6 +103,7 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -121,6 +122,7 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
|
||||
@@ -193,6 +193,7 @@ export const actionFinalize = register<FormData>({
|
||||
},
|
||||
selectionElement: null,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
newElement: null,
|
||||
multiElement: null,
|
||||
},
|
||||
@@ -398,6 +399,7 @@ export const actionFinalize = register<FormData>({
|
||||
multiElement: null,
|
||||
editingTextElement: null,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
selectedElementIds: isDrawShapeTool
|
||||
? {}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { getFontString } from "@excalidraw/common";
|
||||
|
||||
import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
|
||||
import {
|
||||
getTextAnchorRatios,
|
||||
isExcalidrawElement,
|
||||
isNonDeletedElement,
|
||||
newElementWith,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
import { measureText } from "@excalidraw/element";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
@@ -26,7 +32,7 @@ export const actionTextAutoResize = register({
|
||||
!selectedElements[0].autoResize
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, targetElement) => {
|
||||
perform: (elements, appState, targetElement, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
const targetTextElement =
|
||||
@@ -34,25 +40,48 @@ export const actionTextAutoResize = register({
|
||||
? targetElement
|
||||
: (selectedElements[0] as ExcalidrawElement | undefined);
|
||||
|
||||
const target = elements.find(
|
||||
(element) => element.id === targetTextElement?.id,
|
||||
);
|
||||
|
||||
if (!target || !isTextElement(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const metrics = measureText(
|
||||
target.originalText,
|
||||
getFontString(target),
|
||||
target.lineHeight,
|
||||
);
|
||||
|
||||
// unwrapping resizes the box, so keep the point the text's alignment pins
|
||||
// — otherwise a right-aligned or centred text slides sideways, and one
|
||||
// bound to an arrow endpoint drags the arrow with it
|
||||
const anchor = getTextAnchorRatios(target);
|
||||
|
||||
const resized = newElementWith(target, {
|
||||
autoResize: true,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
x: target.x + (target.width - metrics.width) * anchor.x,
|
||||
y: target.y + (target.height - metrics.height) * anchor.y,
|
||||
text: target.originalText,
|
||||
});
|
||||
|
||||
// the anchor only pins one point of the box, so any other arrow bound to
|
||||
// this text still has to be re-routed to its new geometry. The scene holds
|
||||
// the pre-resize text, hence handing the new one over explicitly.
|
||||
if (isNonDeletedElement(resized)) {
|
||||
updateBoundElements(resized, app.scene, {
|
||||
changedElements: new Map([[resized.id, resized]]),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
appState,
|
||||
elements: elements.map((element) => {
|
||||
if (element.id === targetTextElement?.id && isTextElement(element)) {
|
||||
const metrics = measureText(
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
autoResize: true,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
text: element.originalText,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
elements: elements.map((element) =>
|
||||
element.id === resized.id ? resized : element,
|
||||
),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -103,6 +103,7 @@ export const getDefaultAppState = (): Omit<
|
||||
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||
},
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
frameToHighlight: null,
|
||||
editingFrame: null,
|
||||
@@ -240,6 +241,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
stats: { browser: true, export: false, server: false },
|
||||
suggestedBinding: { browser: false, export: false, server: false },
|
||||
hoveredArrowTextAnchor: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
frameToHighlight: { browser: false, export: false, server: false },
|
||||
editingFrame: { browser: false, export: false, server: false },
|
||||
|
||||
237
packages/excalidraw/components/App.arrowText.ts
Normal file
237
packages/excalidraw/components/App.arrowText.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
bindBindingElementToFixedPoint,
|
||||
dragNewTextElement,
|
||||
getBoundTextElement,
|
||||
getEndpointBoundTextDragAnchor,
|
||||
getTextBindingForArrowEndpoint,
|
||||
getUnboundArrowEndpointAtPoint,
|
||||
isArrowElement,
|
||||
isBindingEnabled,
|
||||
isEndpointBoundText,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { ArrowEndpoint } from "@excalidraw/element";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
FixedPoint,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type App from "./App";
|
||||
import type { AppState } from "../types";
|
||||
|
||||
/**
|
||||
* The text tool's interaction with arrows: the hover affordance showing where
|
||||
* a click would attach text to an arrow — a free endpoint (binds the arrow to
|
||||
* a new text element positioned against that endpoint) or the arrow's midpoint
|
||||
* (adds a label bound to the arrow) — and the endpoint-bound flavor of
|
||||
* drag-sizing a new text.
|
||||
*
|
||||
* The scene-level logic lives in `@excalidraw/element`'s `arrowEndpointText.ts`.
|
||||
*/
|
||||
export class AppArrowText {
|
||||
constructor(private app: App) {}
|
||||
|
||||
/**
|
||||
* With the text tool active, an arrow under the cursor is a target for
|
||||
* attaching text. Returns where the text would land (if anywhere), and keeps
|
||||
* `appState.hoveredArrowTextAnchor` — which drives the highlight — in sync.
|
||||
*/
|
||||
updateHoveredAnchor = (scenePointer: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): AppState["hoveredArrowTextAnchor"] => {
|
||||
const hovered =
|
||||
this.app.state.activeTool.type === "text" &&
|
||||
!this.app.state.editingTextElement &&
|
||||
!this.app.state.newElement
|
||||
? this.getAnchorAtPosition(scenePointer.x, scenePointer.y)
|
||||
: null;
|
||||
|
||||
const previous = this.app.state.hoveredArrowTextAnchor;
|
||||
|
||||
if (
|
||||
previous?.elementId !== hovered?.elementId ||
|
||||
previous?.anchor !== hovered?.anchor
|
||||
) {
|
||||
this.app.setState({ hoveredArrowTextAnchor: hovered });
|
||||
}
|
||||
|
||||
return hovered;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-evaluates the hovered anchor at the last known pointer position — for
|
||||
* events that change what a click would do without the pointer moving,
|
||||
* i.e. the ctrl/cmd binding toggle. (Events that invalidate the anchor
|
||||
* wholesale — tool switches, finalize, deselect — clear it directly
|
||||
* instead.)
|
||||
*/
|
||||
refresh = (): void => {
|
||||
if (this.app.lastPointerMoveCoords) {
|
||||
this.updateHoveredAnchor(this.app.lastPointerMoveCoords);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A free arrow endpoint a new text could be bound to. Binding an endpoint is
|
||||
* an arrow binding, so it follows the binding toggle (ctrl/cmd) like every
|
||||
* other one — holding it makes the text tool drop plain text instead.
|
||||
*/
|
||||
getBindableEndpointAtPosition(x: number, y: number): ArrowEndpoint | null {
|
||||
if (!isBindingEnabled(this.app.state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = getUnboundArrowEndpointAtPoint(
|
||||
pointFrom(x, y),
|
||||
this.app.scene.getNonDeletedElements(),
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
this.app.state.zoom,
|
||||
);
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The text tool edits before it creates: when a text element is the
|
||||
// top-most hit at this position, a click edits that text, so a nearby
|
||||
// endpoint must not be offered over it.
|
||||
if (this.app.getTextElementAtPosition(x, y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The endpoint scan only knows about arrows, so it happily reaches through
|
||||
// whatever is drawn on top of them. An element stacked above the arrow that
|
||||
// the pointer actually hits owns the click — the text tool should label
|
||||
// that element rather than bind the endpoint hidden behind it.
|
||||
const hitElement = this.app.getElementAtPosition(x, y, {
|
||||
includeLockedElements: true,
|
||||
});
|
||||
|
||||
if (
|
||||
hitElement &&
|
||||
hitElement.id !== endpoint.arrow.id &&
|
||||
this.app.scene.getElementIndex(hitElement.id) >
|
||||
this.app.scene.getElementIndex(endpoint.arrow.id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors what `handleTextOnPointerDown` would do at this position, so the
|
||||
* highlight can't promise something the click won't deliver.
|
||||
*/
|
||||
private getAnchorAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
): AppState["hoveredArrowTextAnchor"] {
|
||||
const endpoint = this.getBindableEndpointAtPosition(x, y);
|
||||
|
||||
if (endpoint) {
|
||||
return { elementId: endpoint.arrow.id, anchor: endpoint.startOrEnd };
|
||||
}
|
||||
|
||||
const container = this.app.getTextBindableContainerAtPosition(x, y);
|
||||
|
||||
// Only arrows get a midpoint label anchor worth pointing at; other
|
||||
// containers center the text in themselves, which needs no affordance.
|
||||
// An arrow that already has a label is edited in place, not re-anchored.
|
||||
if (
|
||||
!isArrowElement(container) ||
|
||||
getBoundTextElement(container, this.app.scene.getNonDeletedElementsMap())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `getTextBindableContainerAtPosition` resolves an arrow anywhere in its
|
||||
// bounding box, but a click only becomes a *label* when it also snaps to
|
||||
// the arrow's center — off-center clicks drop a free-floating text
|
||||
// instead. Gate on the same check so the highlight can't promise a label
|
||||
// the click won't deliver.
|
||||
const snappedToCenter = this.app.getTextWysiwygSnappedToCenterPosition(
|
||||
x,
|
||||
y,
|
||||
this.app.state,
|
||||
container,
|
||||
);
|
||||
|
||||
return snappedToCenter
|
||||
? { elementId: container.id, anchor: "label" }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a text should be created to read as a label for this endpoint — the
|
||||
* side midpoint to bind, the alignment that pins it, and the scene position
|
||||
* it must sit at. `targetStrokeWidth` is the caller's to provide so it can
|
||||
* guarantee it matches the stroke width the text is then created with — the
|
||||
* binding gap derives from it (see `getTextBindingForArrowEndpoint`).
|
||||
*/
|
||||
getTextBinding(
|
||||
{ arrow, startOrEnd }: ArrowEndpoint,
|
||||
targetStrokeWidth: number,
|
||||
) {
|
||||
return getTextBindingForArrowEndpoint(
|
||||
arrow,
|
||||
startOrEnd,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
targetStrokeWidth,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the arrow endpoint to the created text, at the side midpoint the
|
||||
* placement resolved (`getTextBinding`'s `fixedPoint`).
|
||||
*/
|
||||
bindText(
|
||||
{ arrow, startOrEnd }: ArrowEndpoint,
|
||||
text: NonDeleted<ExcalidrawTextElement>,
|
||||
fixedPoint: FixedPoint,
|
||||
): void {
|
||||
bindBindingElementToFixedPoint(
|
||||
arrow,
|
||||
text,
|
||||
startOrEnd,
|
||||
fixedPoint,
|
||||
this.app.scene,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A text bound to an arrow endpoint can't be positioned by the drag — the
|
||||
* binding already placed it — so only its width is dragged out. Returns
|
||||
* whether it owned the drag.
|
||||
*/
|
||||
maybeDragNewText(
|
||||
newElement: ExcalidrawElement,
|
||||
pointerCoords: { x: number; y: number },
|
||||
): boolean {
|
||||
if (
|
||||
!isTextElement(newElement) ||
|
||||
!isEndpointBoundText(
|
||||
newElement,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dragNewTextElement({
|
||||
newElement,
|
||||
...getEndpointBoundTextDragAnchor(newElement),
|
||||
pointerX: pointerCoords.x,
|
||||
zoom: this.app.state.zoom.value,
|
||||
scene: this.app.scene,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,8 @@ import type {
|
||||
ExcalidrawBindableElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { ArrowEndpoint } from "@excalidraw/element";
|
||||
|
||||
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
@@ -430,6 +432,7 @@ import ConvertElementTypePopup, {
|
||||
} from "./ConvertElementTypePopup";
|
||||
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { AppArrowText } from "./App.arrowText";
|
||||
import { AppCursor } from "./App.cursor";
|
||||
import { AppDrawShape } from "./App.drawshape";
|
||||
import { AppFlowchart } from "./App.flowchart";
|
||||
@@ -681,6 +684,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
public flowchart: AppFlowchart = new AppFlowchart(this);
|
||||
public cursor: AppCursor = new AppCursor(this);
|
||||
public arrowText: AppArrowText = new AppArrowText(this);
|
||||
public viewport: AppViewport = new AppViewport(this, {
|
||||
getContainer: () => this.excalidrawContainerRef.current,
|
||||
getStylesPanelMode: () => this.stylesPanelMode,
|
||||
@@ -5575,6 +5579,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
});
|
||||
|
||||
// the toggle changes what a text-tool click at the current position
|
||||
// would do, with no pointermove to refresh the affordance
|
||||
this.arrowText.refresh();
|
||||
|
||||
maybeHandleArrowPointlikeDrag({ app: this, event });
|
||||
}
|
||||
|
||||
@@ -5677,7 +5685,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
const midPoint = getContainerCenter(
|
||||
selectedElement,
|
||||
this.state,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const sceneX = midPoint.x;
|
||||
@@ -5856,6 +5863,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
flushSync(() => {
|
||||
this.setState({ isBindingEnabled: preferenceEnabled });
|
||||
});
|
||||
|
||||
this.arrowText.refresh();
|
||||
}
|
||||
|
||||
maybeHandleArrowPointlikeDrag({ app: this, event });
|
||||
@@ -6003,6 +6012,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? prevState.selectedLinearElement
|
||||
: null,
|
||||
frameToHighlight: null,
|
||||
// only the text tool offers arrow-endpoint binding, and the highlight
|
||||
// is refreshed on pointermove — don't leave a stale one behind
|
||||
hoveredArrowTextAnchor: null,
|
||||
} as const;
|
||||
|
||||
if (nextActiveTool.type === "freedraw") {
|
||||
@@ -6353,7 +6365,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return selectedElement;
|
||||
}
|
||||
|
||||
private getTextElementAtPosition(
|
||||
getTextElementAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
): NonDeleted<ExcalidrawTextElement> | null {
|
||||
@@ -6419,7 +6431,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
// NOTE: Hot path for hit testing, so avoid unnecessary computations
|
||||
private getElementAtPosition(
|
||||
getElementAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
opts?: (
|
||||
@@ -6596,7 +6608,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
|
||||
private getTextBindableContainerAtPosition(x: number, y: number) {
|
||||
getTextBindableContainerAtPosition(x: number, y: number) {
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
if (selectedElements.length === 1) {
|
||||
@@ -6640,6 +6652,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return isTextBindableContainer(hitElement, false) ? hitElement : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a text element's content is still being authored.
|
||||
*
|
||||
* Creating a text reverts the tool to selection during pointerdown, so the
|
||||
* pointerup that follows looks like an ordinary canvas click and would
|
||||
* capture the still-empty element as a history entry of its own. Undo would
|
||||
* then rewind only the typing, restoring an invisible, zero-content element
|
||||
* (and, for an endpoint label, leaving the arrow bound to it) rather than
|
||||
* removing it. The editor's own submit captures the finished text instead,
|
||||
* so the whole create-and-type lands in a single entry.
|
||||
*/
|
||||
private isEditingTextContent() {
|
||||
return (
|
||||
!!this.state.editingTextElement || isTextElement(this.state.newElement)
|
||||
);
|
||||
}
|
||||
|
||||
private startTextEditing = ({
|
||||
sceneX,
|
||||
sceneY,
|
||||
@@ -6647,6 +6676,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
container,
|
||||
autoEdit = true,
|
||||
initialCaretSceneCoords,
|
||||
arrowEndpoint,
|
||||
}: {
|
||||
/** X position to insert text at */
|
||||
sceneX: number;
|
||||
@@ -6657,9 +6687,35 @@ class App extends React.Component<AppProps, AppState> {
|
||||
container?: ExcalidrawTextContainer | null;
|
||||
autoEdit?: boolean;
|
||||
initialCaretSceneCoords?: { x: number; y: number };
|
||||
/**
|
||||
* creates the text as a label for this arrow endpoint: the binding then
|
||||
* dictates the text's position and alignment, overriding (sceneX, sceneY)
|
||||
*/
|
||||
arrowEndpoint?: ArrowEndpoint | null;
|
||||
}) => {
|
||||
let shouldBindToContainer = false;
|
||||
|
||||
// Resolved here rather than by the caller so that the stroke width the
|
||||
// binding gap derives from (see `getBindingGap`) is, by construction, the
|
||||
// one the text is created with below.
|
||||
const arrowEndpointBinding =
|
||||
arrowEndpoint &&
|
||||
this.arrowText.getTextBinding(
|
||||
arrowEndpoint,
|
||||
this.getCurrentItemStrokeWidth("text"),
|
||||
);
|
||||
|
||||
if (arrowEndpointBinding) {
|
||||
// an arrow endpoint is not a text container — the text is a sibling the
|
||||
// arrow binds to, not a label inside it
|
||||
container = null;
|
||||
insertAtParentCenter = false;
|
||||
// the scene position of the text's bound side midpoint, not a caret
|
||||
// position
|
||||
sceneX = arrowEndpointBinding.anchor[0];
|
||||
sceneY = arrowEndpointBinding.anchor[1];
|
||||
}
|
||||
|
||||
let parentCenterPosition =
|
||||
insertAtParentCenter &&
|
||||
this.getTextWysiwygSnappedToCenterPosition(
|
||||
@@ -6677,9 +6733,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
shouldBindToContainer = true;
|
||||
}
|
||||
}
|
||||
const existingTextElement =
|
||||
this.getSelectedTextElement(container) ||
|
||||
this.getTextElementAtPosition(sceneX, sceneY);
|
||||
// The endpoint flow always creates a fresh text: the lookups below would
|
||||
// otherwise adopt a currently selected text (wherever it sits on canvas)
|
||||
// or one that happens to lie around the anchor — even a container-bound
|
||||
// one — and bind the arrow to that instead.
|
||||
const existingTextElement = arrowEndpointBinding
|
||||
? null
|
||||
: this.getSelectedTextElement(container) ||
|
||||
this.getTextElementAtPosition(sceneX, sceneY);
|
||||
|
||||
const fontFamily =
|
||||
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
|
||||
@@ -6723,7 +6784,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY);
|
||||
|
||||
const newTextElementPosition = parentCenterPosition
|
||||
const newTextElementPosition = arrowEndpointBinding
|
||||
? // the anchor is dictated by the arrow, so neither the grid nor the
|
||||
// caret-centering fudge may nudge it
|
||||
{ x: sceneX, y: sceneY }
|
||||
: parentCenterPosition
|
||||
? {
|
||||
x: parentCenterPosition.elementCenterX,
|
||||
y: parentCenterPosition.elementCenterY,
|
||||
@@ -6771,12 +6836,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
text: "",
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign: parentCenterPosition
|
||||
? "center"
|
||||
: this.state.currentItemTextAlign,
|
||||
verticalAlign: parentCenterPosition
|
||||
? VERTICAL_ALIGN.MIDDLE
|
||||
: DEFAULT_VERTICAL_ALIGN,
|
||||
textAlign:
|
||||
arrowEndpointBinding?.textAlign ??
|
||||
(parentCenterPosition ? "center" : this.state.currentItemTextAlign),
|
||||
verticalAlign:
|
||||
arrowEndpointBinding?.verticalAlign ??
|
||||
(parentCenterPosition
|
||||
? VERTICAL_ALIGN.MIDDLE
|
||||
: DEFAULT_VERTICAL_ALIGN),
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
groupIds: container?.groupIds ?? [],
|
||||
lineHeight,
|
||||
@@ -6810,6 +6877,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (arrowEndpoint && arrowEndpointBinding) {
|
||||
this.arrowText.bindText(
|
||||
arrowEndpoint,
|
||||
element,
|
||||
arrowEndpointBinding.fixedPoint,
|
||||
);
|
||||
}
|
||||
|
||||
if (autoEdit || existingTextElement || container) {
|
||||
this.handleTextWysiwyg(element, {
|
||||
isExistingElement: !!existingTextElement,
|
||||
@@ -7131,7 +7206,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
) {
|
||||
const midPoint = getContainerCenter(
|
||||
container,
|
||||
this.state,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
@@ -7996,6 +8070,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
hitElement = hitElementMightBeLocked;
|
||||
}
|
||||
|
||||
const hoveredArrowTextAnchor =
|
||||
this.arrowText.updateHoveredAnchor(scenePointer);
|
||||
|
||||
if (
|
||||
!this.handleIframeLikeElementHover({
|
||||
hitElement,
|
||||
@@ -8023,7 +8100,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({ showHyperlinkPopup: "info" });
|
||||
} else if (this.state.activeTool.type === "text") {
|
||||
this.cursor.set(
|
||||
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
|
||||
hoveredArrowTextAnchor
|
||||
? CURSOR_TYPE.POINTER
|
||||
: isTextElement(hitElement)
|
||||
? CURSOR_TYPE.TEXT
|
||||
: CURSOR_TYPE.CROSSHAIR,
|
||||
);
|
||||
} else if (
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -9662,26 +9743,49 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let sceneX = pointerDownState.origin.x;
|
||||
let sceneY = pointerDownState.origin.y;
|
||||
|
||||
const element = this.getElementAtPosition(sceneX, sceneY, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
// the click transitions into text editing either way, consuming (or
|
||||
// bypassing) whatever anchor was highlighted — don't leave it lingering
|
||||
// under the editor, which outlives the hover when the tool is locked
|
||||
this.setState({ hoveredArrowTextAnchor: null });
|
||||
|
||||
// FIXME
|
||||
let container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
|
||||
|
||||
if (hasBoundTextElement(element)) {
|
||||
container = element as NonDeleted<ExcalidrawTextContainer>;
|
||||
sceneX = element.x + element.width / 2;
|
||||
sceneY = element.y + element.height / 2;
|
||||
}
|
||||
this.startTextEditing({
|
||||
// a free arrow endpoint takes precedence over adding a label *to* the
|
||||
// arrow — it's the smaller, more deliberate target
|
||||
const arrowEndpoint = this.arrowText.getBindableEndpointAtPosition(
|
||||
sceneX,
|
||||
sceneY,
|
||||
insertAtParentCenter: !event.altKey,
|
||||
container,
|
||||
autoEdit: false,
|
||||
initialCaretSceneCoords: { x: sceneX, y: sceneY },
|
||||
});
|
||||
);
|
||||
|
||||
if (arrowEndpoint) {
|
||||
this.startTextEditing({
|
||||
sceneX,
|
||||
sceneY,
|
||||
// the binding fixes the position, but the width is still the user's
|
||||
// to drag out (see `getEndpointBoundTextDragAnchor`)
|
||||
autoEdit: false,
|
||||
arrowEndpoint,
|
||||
});
|
||||
} else {
|
||||
const element = this.getElementAtPosition(sceneX, sceneY, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
// FIXME
|
||||
let container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
|
||||
|
||||
if (hasBoundTextElement(element)) {
|
||||
container = element as NonDeleted<ExcalidrawTextContainer>;
|
||||
sceneX = element.x + element.width / 2;
|
||||
sceneY = element.y + element.height / 2;
|
||||
}
|
||||
this.startTextEditing({
|
||||
sceneX,
|
||||
sceneY,
|
||||
insertAtParentCenter: !event.altKey,
|
||||
container,
|
||||
autoEdit: false,
|
||||
initialCaretSceneCoords: { x: sceneX, y: sceneY },
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.isToolLocked()) {
|
||||
this.setState(
|
||||
@@ -11401,7 +11505,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
|
||||
this.store.scheduleCapture();
|
||||
if (!this.isEditingTextContent()) {
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
|
||||
if (hitLockedElement?.locked) {
|
||||
this.setState({
|
||||
@@ -12269,12 +12375,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
activeTool.type !== "selection" ||
|
||||
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) ||
|
||||
!isShallowEqual(
|
||||
this.state.previousSelectedElementIds,
|
||||
this.state.selectedElementIds,
|
||||
)
|
||||
!this.isEditingTextContent() &&
|
||||
(activeTool.type !== "selection" ||
|
||||
isSomeElementSelected(
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.state,
|
||||
) ||
|
||||
!isShallowEqual(
|
||||
this.state.previousSelectedElementIds,
|
||||
this.state.selectedElementIds,
|
||||
))
|
||||
) {
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
@@ -13141,6 +13251,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.arrowText.maybeDragNewText(newElement, pointerCoords)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
@@ -13649,7 +13763,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
|
||||
private getTextWysiwygSnappedToCenterPosition(
|
||||
getTextWysiwygSnappedToCenterPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
appState: AppState,
|
||||
@@ -13661,7 +13775,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const elementCenter = getContainerCenter(
|
||||
container,
|
||||
appState,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (elementCenter) {
|
||||
|
||||
@@ -258,6 +258,7 @@ const getRelevantAppStateProps = (
|
||||
isMidpointSnappingEnabled: appState.isMidpointSnappingEnabled,
|
||||
gridModeEnabled: appState.gridModeEnabled,
|
||||
suggestedBinding: appState.suggestedBinding,
|
||||
hoveredArrowTextAnchor: appState.hoveredArrowTextAnchor,
|
||||
isRotating: appState.isRotating,
|
||||
elementsToHighlight: appState.elementsToHighlight,
|
||||
collaborators: appState.collaborators, // Necessary for collab. sessions
|
||||
|
||||
@@ -121,12 +121,7 @@ const renderElbowArrowMidPointHighlight = (
|
||||
|
||||
invariant(segmentMidPointHoveredCoords, "midPointCoords is null");
|
||||
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
|
||||
highlightPoint(segmentMidPointHoveredCoords, context, appState);
|
||||
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderLinearElementPointHighlight = (
|
||||
@@ -156,18 +151,18 @@ const renderLinearElementPointHighlight = (
|
||||
hoverPointIndex,
|
||||
elementsMap,
|
||||
);
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
|
||||
highlightPoint(point, context, appState);
|
||||
context.restore();
|
||||
};
|
||||
|
||||
/** draws the point marker in scene coordinates */
|
||||
const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
|
||||
point: Point,
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
|
||||
context.fillStyle = "rgba(105, 101, 219, 0.4)";
|
||||
|
||||
fillCircle(
|
||||
@@ -177,21 +172,45 @@ const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
|
||||
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value,
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
const renderFocusPointHighlight = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
focusPoint: GlobalPoint,
|
||||
) => {
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
|
||||
highlightPoint(focusPoint, context, appState);
|
||||
|
||||
context.restore();
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks where on the hovered arrow the text tool would attach text — a free
|
||||
* endpoint, or the midpoint the arrow's label would center on.
|
||||
*
|
||||
* Purely presentational: `AppArrowText` maintains the anchor at every event
|
||||
* that can change it (pointermove, the ctrl/cmd binding toggle, pointerdown,
|
||||
* tool switches, finalize). The element lookup below only guards against the
|
||||
* arrow vanishing through channels no local event covers, e.g. a collaborator
|
||||
* deleting it.
|
||||
*/
|
||||
const renderHoveredArrowTextAnchor = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const { elementId, anchor } = appState.hoveredArrowTextAnchor!;
|
||||
|
||||
const element = elementsMap.get(elementId);
|
||||
|
||||
if (!element || !isArrowElement(element) || element.isDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point =
|
||||
anchor === "label"
|
||||
? LinearElementEditor.getBoundTextElementCenter(element, elementsMap)
|
||||
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
anchor === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
highlightPoint(point, context, appState);
|
||||
};
|
||||
|
||||
const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
@@ -1319,7 +1338,7 @@ const renderFocusPointIndicator = ({
|
||||
linearState?.hoveredFocusPointBinding === type &&
|
||||
!linearState.draggedFocusPointBinding
|
||||
) {
|
||||
renderFocusPointHighlight(context, appState, focusPoint);
|
||||
highlightPoint(focusPoint, context, appState);
|
||||
}
|
||||
|
||||
// render focus point
|
||||
@@ -1684,6 +1703,10 @@ const _renderInteractiveScene = ({
|
||||
};
|
||||
}
|
||||
|
||||
if (appState.hoveredArrowTextAnchor) {
|
||||
renderHoveredArrowTextAnchor(context, appState, allElementsMap);
|
||||
}
|
||||
|
||||
if (appState.frameToHighlight) {
|
||||
renderFrameHighlight(
|
||||
context,
|
||||
|
||||
@@ -931,6 +931,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -1132,6 +1133,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -1348,6 +1350,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -1681,6 +1684,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2014,6 +2018,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2230,6 +2235,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2473,6 +2479,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2773,6 +2780,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3147,6 +3155,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3669,6 +3678,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3994,6 +4004,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -4319,6 +4330,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -5606,6 +5618,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -6825,6 +6838,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -7784,6 +7798,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -8786,6 +8801,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9779,6 +9795,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
|
||||
@@ -57,6 +57,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -692,6 +693,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -1255,6 +1257,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -1617,6 +1620,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -1981,6 +1985,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2246,6 +2251,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2701,6 +2707,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3006,6 +3013,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3327,6 +3335,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3623,6 +3632,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3911,6 +3921,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -4148,6 +4159,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -4407,6 +4419,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -4680,6 +4693,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -4911,6 +4925,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -5142,6 +5157,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -5391,6 +5407,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -5649,6 +5666,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -5909,6 +5927,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -6240,6 +6259,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -6669,6 +6689,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -7045,6 +7066,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -7359,6 +7381,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -7653,6 +7676,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -7885,6 +7909,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -8239,6 +8264,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -8593,6 +8619,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9001,6 +9028,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9290,6 +9318,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9556,6 +9585,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9823,6 +9853,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -10057,6 +10088,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -10356,6 +10388,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -10675,6 +10708,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -10913,6 +10947,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -11840,6 +11875,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -12102,6 +12138,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -12339,6 +12376,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -12578,6 +12616,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -12983,6 +13022,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -13195,6 +13235,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -13404,6 +13445,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 1000,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -13707,6 +13749,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 1000,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -14007,6 +14050,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -14254,6 +14298,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -14493,6 +14538,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -14732,6 +14778,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -14981,6 +15028,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -15314,6 +15362,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -15486,6 +15535,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -15772,6 +15822,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -16037,6 +16088,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -16192,6 +16244,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -16476,6 +16529,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -16640,6 +16694,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -17390,6 +17445,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -18038,6 +18094,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -18686,6 +18743,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -19437,6 +19495,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -20207,6 +20266,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -20689,6 +20749,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -21202,6 +21263,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -21663,6 +21725,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 0,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
|
||||
@@ -57,6 +57,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -485,6 +486,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -903,6 +905,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -1471,6 +1474,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -1680,6 +1684,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2066,6 +2071,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2313,6 +2319,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2495,6 +2502,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -2822,6 +2830,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3079,6 +3088,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3322,6 +3332,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3560,6 +3571,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -3820,6 +3832,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -4136,6 +4149,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -4574,6 +4588,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -4859,6 +4874,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -5137,6 +5153,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -5347,6 +5364,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -5549,6 +5567,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -5944,6 +5963,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -6243,6 +6263,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -7037,6 +7058,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -7373,6 +7395,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -7654,6 +7677,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -7891,6 +7915,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -8133,6 +8158,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -8315,6 +8341,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -8497,6 +8524,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -8679,6 +8707,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -8914,6 +8943,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9147,6 +9177,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9345,6 +9376,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9580,6 +9612,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9762,6 +9795,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -9995,6 +10029,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -10177,6 +10212,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -10375,6 +10411,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -10557,6 +10594,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -11090,6 +11128,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -11372,6 +11411,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -11497,6 +11537,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -11699,6 +11740,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -12020,6 +12062,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -12451,6 +12494,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -13093,6 +13137,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -13221,6 +13266,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -13854,6 +13900,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -14195,6 +14242,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -14461,6 +14509,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -14586,6 +14635,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -14952,6 +15002,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
@@ -15077,6 +15128,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"height": 768,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
|
||||
860
packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx
Normal file
860
packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx
Normal file
@@ -0,0 +1,860 @@
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { getTextEditor, updateTextEditor } from "./queries/dom";
|
||||
import { render, unmountComponent } from "./test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
/**
|
||||
* An arrow whose endpoint sits at (endX, endY), approached from (startX, startY).
|
||||
*/
|
||||
const createArrow = (
|
||||
id: string,
|
||||
[startX, startY]: [number, number],
|
||||
[endX, endY]: [number, number],
|
||||
overrides: Partial<ExcalidrawArrowElement> = {},
|
||||
) =>
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
id,
|
||||
x: startX,
|
||||
y: startY,
|
||||
width: Math.abs(endX - startX),
|
||||
height: Math.abs(endY - startY),
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(endX - startX, endY - startY),
|
||||
],
|
||||
...overrides,
|
||||
}) as ExcalidrawArrowElement;
|
||||
|
||||
const getText = () =>
|
||||
h.elements.find(
|
||||
(element): element is ExcalidrawTextElement => element.type === "text",
|
||||
)!;
|
||||
|
||||
const getArrow = (id: string) =>
|
||||
h.elements.find(
|
||||
(element): element is ExcalidrawArrowElement => element.id === id,
|
||||
)!;
|
||||
|
||||
/** global coordinates of an arrow's start/end point */
|
||||
const endpointOf = (arrow: ExcalidrawArrowElement, which: "start" | "end") => {
|
||||
const point = arrow.points[which === "start" ? 0 : arrow.points.length - 1];
|
||||
return [arrow.x + point[0], arrow.y + point[1]] as const;
|
||||
};
|
||||
|
||||
/**
|
||||
* `normalizeFixedPoint` nudges exact side midpoints off 0.5 by 1e-4 to keep
|
||||
* headings unambiguous, which shows up as a sub-pixel offset scaled by the
|
||||
* text size.
|
||||
*/
|
||||
const expectPointsClose = (
|
||||
actual: readonly [number, number],
|
||||
expected: readonly [number, number],
|
||||
) => {
|
||||
expect(actual[0]).toBeCloseTo(expected[0], 1);
|
||||
expect(actual[1]).toBeCloseTo(expected[1], 1);
|
||||
};
|
||||
|
||||
/** creates a text bound to the arrow endpoint at (x, y) via the text tool */
|
||||
const bindTextAt = async (x: number, y: number, text: string) => {
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(x, y);
|
||||
mouse.clickAt(x, y);
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, text);
|
||||
return editor;
|
||||
};
|
||||
|
||||
describe("binding text to an arrow endpoint", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally />);
|
||||
});
|
||||
|
||||
describe("hover affordance", () => {
|
||||
it("highlights a free arrow endpoint while the text tool is active", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
|
||||
// away from the arrow entirely
|
||||
mouse.moveTo(400, 400);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("highlights the label midpoint when hovering the arrow itself", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
// mid-arrow, clear of both endpoints
|
||||
mouse.moveTo(100, 200);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the endpoint over the label midpoint", () => {
|
||||
// short enough that the endpoint and the midpoint are close together
|
||||
API.setElements([createArrow("arrow", [100, 160], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor?.anchor).toBe("end");
|
||||
});
|
||||
|
||||
it("does not offer a label midpoint on an arrow that already has one", () => {
|
||||
const arrow = createArrow("arrow", [100, 300], [100, 100]);
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
containerId: arrow.id,
|
||||
text: "existing",
|
||||
});
|
||||
API.setElements([
|
||||
{ ...arrow, boundElements: [{ id: label.id, type: "text" }] },
|
||||
label,
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 200);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
// a click only becomes a label when it snaps to the arrow's center, so
|
||||
// merely being inside the arrow's bounding box must not raise the anchor
|
||||
it("does not offer a label midpoint away from the arrow's center", () => {
|
||||
// bounding box spans (100,400)-(400,580); label centers on (250,580)
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow",
|
||||
x: 100,
|
||||
y: 400,
|
||||
width: 300,
|
||||
height: 180,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(150, 180),
|
||||
pointFrom<LocalPoint>(300, 0),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
|
||||
// inside the bounding box but nowhere near the stroke or the midpoint
|
||||
mouse.moveTo(150, 560);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// on the stroke, but far from the midpoint
|
||||
mouse.moveTo(175, 490);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
mouse.moveTo(250, 580);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not highlight endpoints for other tools", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("selection");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the highlight on escape", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).not.toBeNull();
|
||||
|
||||
// escape reverts the tool via the action's appState rather than
|
||||
// `setActiveTool`, so the highlight has to be cleared there too
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("binding toggle (ctrl/cmd)", () => {
|
||||
it("does not offer an endpoint while binding is disabled", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
API.setAppState({ isBindingEnabled: false });
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("still offers the label anchor — a label is not an arrow binding", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
API.setAppState({ isBindingEnabled: false });
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 200);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
// the highlight is maintained at write time — the renderer draws whatever
|
||||
// `hoveredArrowTextAnchor` says — so the toggle itself has to refresh the
|
||||
// anchor: there is no pointermove to do it
|
||||
it("clears the highlight the moment ctrl is pressed, restores on release", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).not.toBeNull();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyDown("Control");
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
|
||||
Keyboard.keyUp("Control");
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops plain text instead of binding the endpoint", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
API.setAppState({ isBindingEnabled: false });
|
||||
|
||||
await bindTextAt(100, 100, "plain");
|
||||
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(getArrow("arrow").startBinding).toBeNull();
|
||||
expect(getText().containerId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not highlight an endpoint that is already bound", () => {
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100], {
|
||||
endBinding: {
|
||||
elementId: "other",
|
||||
fixedPoint: [0.5001, 0.5001],
|
||||
mode: "orbit",
|
||||
},
|
||||
}),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "other",
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// ...but the arrow can still take a label at its midpoint
|
||||
mouse.moveTo(100, 200);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "label",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the highlight when switching away from the text tool", () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).not.toBeNull();
|
||||
|
||||
UI.clickTool("selection");
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placement strategy", () => {
|
||||
// the side of the text the arrow attaches to is the one it already points
|
||||
// at, so the text lands beyond the tip and the arrow needn't move
|
||||
it.each([
|
||||
{
|
||||
name: "arrow pointing up binds the text's bottom side midpoint",
|
||||
from: [100, 300] as [number, number],
|
||||
to: [100, 100] as [number, number],
|
||||
fixedPoint: [0.5001, 1],
|
||||
textAlign: "center",
|
||||
verticalAlign: "bottom",
|
||||
},
|
||||
{
|
||||
name: "arrow pointing right binds the text's left side midpoint",
|
||||
from: [100, 100] as [number, number],
|
||||
to: [300, 100] as [number, number],
|
||||
fixedPoint: [0, 0.5001],
|
||||
textAlign: "left",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
{
|
||||
name: "arrow pointing down binds the text's top side midpoint",
|
||||
from: [100, 100] as [number, number],
|
||||
to: [100, 300] as [number, number],
|
||||
fixedPoint: [0.5001, 0],
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
},
|
||||
{
|
||||
name: "arrow pointing left binds the text's right side midpoint",
|
||||
from: [300, 100] as [number, number],
|
||||
to: [100, 100] as [number, number],
|
||||
fixedPoint: [1, 0.5001],
|
||||
textAlign: "right",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
])("$name", async ({ from, to, fixedPoint, textAlign, verticalAlign }) => {
|
||||
API.setElements([createArrow("arrow", from, to)]);
|
||||
|
||||
await bindTextAt(to[0], to[1], "label");
|
||||
|
||||
const text = getText();
|
||||
expect(getArrow("arrow").endBinding).toEqual({
|
||||
elementId: text.id,
|
||||
fixedPoint,
|
||||
mode: "orbit",
|
||||
});
|
||||
expect(text.textAlign).toBe(textAlign);
|
||||
expect(text.verticalAlign).toBe(verticalAlign);
|
||||
expect(text.containerId).toBeNull();
|
||||
expect(text.boundElements).toEqual([{ id: "arrow", type: "arrow" }]);
|
||||
});
|
||||
|
||||
it("binds the start point when that is the endpoint under the cursor", async () => {
|
||||
// arrow drawn away from (100, 100) to the right
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
|
||||
await bindTextAt(100, 100, "label");
|
||||
|
||||
const text = getText();
|
||||
const arrow = getArrow("arrow");
|
||||
expect(arrow.startBinding?.elementId).toBe(text.id);
|
||||
expect(arrow.endBinding).toBeNull();
|
||||
// the arrow leaves the tip heading right, so the text sits to its left
|
||||
expect(arrow.startBinding?.fixedPoint).toEqual([1, 0.5001]);
|
||||
expect(text.textAlign).toBe("right");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the arrow stays put", () => {
|
||||
it("does not move the arrow when the text is created and typed into", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
const editor = await bindTextAt(100, 100, "a label");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
|
||||
// growing the text (wider + a second line) must not drag the tip
|
||||
updateTextEditor(editor, "a much longer label\nspanning two lines");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
|
||||
it("keeps the bound side midpoint pinned as the text grows", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
const editor = await bindTextAt(100, 100, "short");
|
||||
const bottomMidOf = (text: ExcalidrawTextElement) =>
|
||||
[text.x + text.width / 2, text.y + text.height] as const;
|
||||
const anchorBefore = bottomMidOf(getText());
|
||||
|
||||
updateTextEditor(editor, "a much longer label\nspanning two lines");
|
||||
|
||||
const text = getText();
|
||||
expect(text.width).toBeGreaterThan(0);
|
||||
expectPointsClose(bottomMidOf(text), anchorBefore);
|
||||
});
|
||||
|
||||
// An orbit binding resolves the endpoint by intersecting the arrow's own
|
||||
// line with the gap-grown text outline. Anchoring along the bound side's
|
||||
// normal instead used to leave the tip `gap * tan(angle)` off to the side
|
||||
// — invisible on orthogonal arrows, several px on diagonal ones, and only
|
||||
// once the first keystroke triggered `updateBoundElements`.
|
||||
it.each([
|
||||
{ name: "45°", from: [100, 100] as [number, number] },
|
||||
{ name: "steep", from: [160, 100] as [number, number] },
|
||||
{ name: "shallow", from: [100, 160] as [number, number] },
|
||||
])("does not shift a diagonal arrow's tip ($name)", async ({ from }) => {
|
||||
API.setElements([createArrow("arrow", from, [300, 300])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
const editor = await bindTextAt(300, 300, "x");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
|
||||
updateTextEditor(editor, "a considerably longer label");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
|
||||
// the binding gap is derived from the *bind target*, so anchoring with the
|
||||
// arrow's stroke width offsets the tip by half the difference between the
|
||||
// two the moment `updateBoundElements` first runs
|
||||
it.each([
|
||||
{ arrowStroke: 2, textStroke: "bold" as const },
|
||||
{ arrowStroke: 4, textStroke: "thin" as const },
|
||||
{ arrowStroke: 1, textStroke: "medium" as const },
|
||||
])(
|
||||
"does not shift when the text's stroke width differs (arrow $arrowStroke vs $textStroke)",
|
||||
async ({ arrowStroke, textStroke }) => {
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100], {
|
||||
strokeWidth: arrowStroke,
|
||||
}),
|
||||
]);
|
||||
API.setAppState({ currentItemStrokeWidthKey: textStroke });
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
const editor = await bindTextAt(100, 100, "x");
|
||||
expect(getText().strokeWidth).not.toBe(arrowStroke);
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
|
||||
updateTextEditor(editor, "a considerably longer label");
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("drag-sizing", () => {
|
||||
/** press at the endpoint, drag to `toX`, release */
|
||||
const dragTextAt = async (
|
||||
[x, y]: [number, number],
|
||||
toX: number,
|
||||
text: string,
|
||||
) => {
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(x, y);
|
||||
mouse.downAt(x, y);
|
||||
// several moves so the drag registers
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
mouse.moveTo(x + ((toX - x) * i) / 4, y);
|
||||
}
|
||||
mouse.upAt(toX, y);
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, text);
|
||||
return editor;
|
||||
};
|
||||
|
||||
it("sets a fixed width without moving the anchor (left-bound)", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
|
||||
// arrow points right, so the text hangs off its left edge at x = tip+gap
|
||||
await dragTextAt([300, 100], 520, "a label long enough to wrap");
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
expect(text.textAlign).toBe("left");
|
||||
// left edge is the anchor and must be untouched by the drag
|
||||
expect(text.x).toBeCloseTo(306, 0);
|
||||
expect(text.width).toBeCloseTo(214, 0);
|
||||
});
|
||||
|
||||
it("grows leftwards for a right-bound text, pinning its right edge", async () => {
|
||||
API.setElements([createArrow("arrow", [500, 100], [300, 100])]);
|
||||
|
||||
await dragTextAt([300, 100], 80, "a label long enough to wrap");
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
expect(text.textAlign).toBe("right");
|
||||
// the right edge is the anchor
|
||||
expect(text.x + text.width).toBeCloseTo(294, 0);
|
||||
});
|
||||
|
||||
it("keeps a centred text centred on the anchor", async () => {
|
||||
API.setElements([createArrow("arrow", [300, 100], [300, 300])]);
|
||||
|
||||
await dragTextAt([300, 300], 430, "a label long enough to wrap");
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
expect(text.textAlign).toBe("center");
|
||||
expect(text.x + text.width / 2).toBeCloseTo(300, 0);
|
||||
});
|
||||
|
||||
it("does not let the drag run back over the arrow", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
|
||||
// drag towards the arrow, i.e. the wrong side of the anchor
|
||||
await dragTextAt([300, 100], 150, "label");
|
||||
|
||||
const text = getText();
|
||||
// treated as no drag at all — the text keeps autogrowing
|
||||
expect(text.autoResize).toBe(true);
|
||||
expect(text.x).toBeCloseTo(306, 0);
|
||||
});
|
||||
|
||||
// the autoResize handle unwraps the text back to one line; anchoring that
|
||||
// resize by alignment is what keeps the arrow still
|
||||
it("does not move the arrow when the text is un-wrapped", async () => {
|
||||
API.setElements([createArrow("arrow", [500, 100], [300, 100])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
// grows leftwards off the text's right edge
|
||||
await dragTextAt([300, 100], 80, "a label long enough to wrap");
|
||||
Keyboard.exitTextEditor(await getTextEditor());
|
||||
|
||||
const text = getText();
|
||||
expect(text.autoResize).toBe(false);
|
||||
const rightEdge = text.x + text.width;
|
||||
|
||||
API.setAppState({ selectedElementIds: { [text.id]: true } });
|
||||
API.executeAction(actionTextAutoResize);
|
||||
|
||||
const resized = getText();
|
||||
expect(resized.autoResize).toBe(true);
|
||||
expect(resized.x + resized.width).toBeCloseTo(rightEdge, 4);
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
|
||||
// the anchor can only pin one point, so every other arrow bound to the text
|
||||
// has to be re-routed to the resized box
|
||||
it("re-routes the other arrows bound to the text", () => {
|
||||
const wrapped = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
id: "text",
|
||||
x: 200,
|
||||
y: 280,
|
||||
width: 300,
|
||||
height: 50,
|
||||
text: "this is it my friends\nald aksdl askdlasdk",
|
||||
textAlign: "right",
|
||||
verticalAlign: "middle",
|
||||
boundElements: [{ id: "fromTop", type: "arrow" }],
|
||||
}),
|
||||
// a wrapped, fixed-width text: `originalText` is what the resize
|
||||
// measures against once it unwraps
|
||||
originalText: "this is it my friends ald aksdl askdlasdk",
|
||||
autoResize: false,
|
||||
};
|
||||
// bound to the text's top side, i.e. not the side the alignment pins
|
||||
const fromTop = createArrow("fromTop", [350, 120], [350, 260], {
|
||||
endBinding: {
|
||||
elementId: "text",
|
||||
fixedPoint: [0.5001, 0],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
API.setElements([wrapped, fromTop]);
|
||||
const tipBefore = endpointOf(getArrow("fromTop"), "end");
|
||||
|
||||
API.setAppState({ selectedElementIds: { text: true } });
|
||||
API.executeAction(actionTextAutoResize);
|
||||
|
||||
const text = getText();
|
||||
// the box really did move out from under the arrow
|
||||
expect(text.y).not.toBeCloseTo(280, 0);
|
||||
|
||||
const tipAfter = endpointOf(getArrow("fromTop"), "end");
|
||||
expect(tipAfter).not.toEqual(tipBefore);
|
||||
// still just clear of the top edge it is bound to...
|
||||
expect(tipAfter[1]).toBeLessThan(text.y);
|
||||
// ...and it chased the box's new centre. Not asserting the centre
|
||||
// exactly: the arrow is diagonal now, so the outline intersection sits
|
||||
// slightly to one side of the fixed point.
|
||||
const centre = text.x + text.width / 2;
|
||||
expect(Math.abs(tipAfter[0] - centre)).toBeLessThan(
|
||||
Math.abs(tipBefore[0] - centre),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the arrow endpoint where it was", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [300, 100])]);
|
||||
const tipBefore = endpointOf(getArrow("arrow"), "end");
|
||||
|
||||
await dragTextAt([300, 100], 520, "a label long enough to wrap");
|
||||
|
||||
expectPointsClose(endpointOf(getArrow("arrow"), "end"), tipBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occlusion", () => {
|
||||
const coveringRect = (overrides: Record<string, unknown> = {}) =>
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect",
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 120,
|
||||
height: 120,
|
||||
backgroundColor: "#ffc9c9",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("does not reach through an element stacked above the endpoint", async () => {
|
||||
// rect is drawn after (above) the arrow and covers its tip at (100, 100)
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
coveringRect(),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// the click belongs to the rect, as it would without the arrow there
|
||||
await bindTextAt(100, 100, "label");
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(getText().containerId).toBe("rect");
|
||||
});
|
||||
|
||||
it("still offers the endpoint when the arrow is stacked above", () => {
|
||||
API.setElements([
|
||||
coveringRect(),
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
|
||||
it("is not occluded by a transparent element above it", () => {
|
||||
// transparent shapes are only hit on their stroke, so they don't cover
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
coveringRect({ backgroundColor: "transparent" }),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("history", () => {
|
||||
// generic text-creation history behaviour lives in textWysiwyg.test.tsx;
|
||||
// these cover what the endpoint binding adds on top
|
||||
it("removes the text and its binding in a single undo", async () => {
|
||||
UI.createElement("arrow", { x: 100, y: 300, width: 0, height: -200 });
|
||||
const arrowId = h.elements[0].id;
|
||||
const arrow = () =>
|
||||
h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.id === arrowId,
|
||||
)!;
|
||||
|
||||
const editor = await bindTextAt(100, 100, "bound");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
const textId = arrow().endBinding?.elementId;
|
||||
expect(textId).toBeDefined();
|
||||
|
||||
Keyboard.undo();
|
||||
|
||||
expect(h.elements.find((el) => el.id === textId)?.isDeleted).toBe(true);
|
||||
expect(arrow().isDeleted).toBe(false);
|
||||
expect(arrow().endBinding).toBeNull();
|
||||
|
||||
// and the endpoint is immediately reusable
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: arrowId,
|
||||
anchor: "end",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the text and its binding on redo", async () => {
|
||||
UI.createElement("arrow", { x: 100, y: 300, width: 0, height: -200 });
|
||||
const arrowId = h.elements[0].id;
|
||||
const arrow = () =>
|
||||
h.elements.find(
|
||||
(el): el is ExcalidrawArrowElement => el.id === arrowId,
|
||||
)!;
|
||||
|
||||
const editor = await bindTextAt(100, 100, "bound");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
const textId = arrow().endBinding?.elementId;
|
||||
|
||||
Keyboard.undo();
|
||||
Keyboard.redo();
|
||||
|
||||
const text = h.elements.find((el) => el.id === textId)!;
|
||||
expect(text.isDeleted).toBe(false);
|
||||
expect(isTextElement(text) && text.text).toBe("bound");
|
||||
expect(arrow().endBinding?.elementId).toBe(textId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interaction with existing text-tool behavior", () => {
|
||||
it("adds a label to the arrow when clicking away from the endpoints", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 100], [500, 100])]);
|
||||
|
||||
await bindTextAt(300, 100, "label");
|
||||
|
||||
const text = getText();
|
||||
// mid-arrow click is still the "arrow label" gesture
|
||||
expect(text.containerId).toBe("arrow");
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(getArrow("arrow").startBinding).toBeNull();
|
||||
});
|
||||
|
||||
// the text tool edits before it creates: a text that is the top-most hit
|
||||
// under the cursor takes the click, so the endpoint must not be offered
|
||||
// over it. Occlusion alone can't provide this — its hit test skips bound
|
||||
// texts.
|
||||
it("yields to a container-bound label overlapping the endpoint", async () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rect",
|
||||
x: 60,
|
||||
y: 60,
|
||||
width: 80,
|
||||
height: 80,
|
||||
// transparent, so the rect itself doesn't occlude the endpoint
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
containerId: rect.id,
|
||||
text: "under",
|
||||
x: 75,
|
||||
y: 87.5,
|
||||
width: 50,
|
||||
height: 25,
|
||||
});
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
{ ...rect, boundElements: [{ id: label.id, type: "text" }] },
|
||||
label,
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toBeNull();
|
||||
|
||||
// the click edits the label rather than binding the endpoint under it
|
||||
mouse.clickAt(100, 100);
|
||||
await getTextEditor();
|
||||
expect(h.state.editingTextElement?.id).toBe("label");
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a fresh text instead of adopting the selected one", async () => {
|
||||
// with a text selected, a text-tool click normally edits that text no
|
||||
// matter where the click lands — but a click on an endpoint promises a
|
||||
// new label there, not a binding to whatever happened to be selected
|
||||
API.setElements([
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "faraway",
|
||||
x: 400,
|
||||
y: 400,
|
||||
text: "faraway",
|
||||
}),
|
||||
]);
|
||||
API.setAppState({ selectedElementIds: { faraway: true } });
|
||||
|
||||
const editor = await bindTextAt(100, 100, "fresh");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const boundTextId = getArrow("arrow").endBinding?.elementId;
|
||||
expect(boundTextId).toBeDefined();
|
||||
expect(boundTextId).not.toBe("faraway");
|
||||
|
||||
const faraway = h.elements.find((el) => el.id === "faraway")!;
|
||||
expect(isTextElement(faraway) && faraway.text).toBe("faraway");
|
||||
expect(faraway.boundElements ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates a fresh text instead of adopting one lying beyond the tip", async () => {
|
||||
// the text covers the anchor region past the tip, but the arrow is the
|
||||
// top-most hit at the endpoint itself — the endpoint stays available,
|
||||
// and the new label must not be conflated with the text underneath
|
||||
API.setElements([
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "under",
|
||||
x: 75,
|
||||
y: 55,
|
||||
width: 50,
|
||||
height: 50,
|
||||
text: "under",
|
||||
}),
|
||||
createArrow("arrow", [100, 300], [100, 100]),
|
||||
]);
|
||||
|
||||
UI.clickTool("text");
|
||||
mouse.moveTo(100, 100);
|
||||
expect(h.state.hoveredArrowTextAnchor).toEqual({
|
||||
elementId: "arrow",
|
||||
anchor: "end",
|
||||
});
|
||||
|
||||
const editor = await bindTextAt(100, 100, "fresh");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
const boundTextId = getArrow("arrow").endBinding?.elementId;
|
||||
expect(boundTextId).toBeDefined();
|
||||
expect(boundTextId).not.toBe("under");
|
||||
|
||||
const under = h.elements.find((el) => el.id === "under")!;
|
||||
expect(isTextElement(under) && under.text).toBe("under");
|
||||
expect(under.boundElements ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes the binding when the text is submitted empty", async () => {
|
||||
API.setElements([createArrow("arrow", [100, 300], [100, 100])]);
|
||||
|
||||
const editor = await bindTextAt(100, 100, "temporary");
|
||||
expect(getArrow("arrow").endBinding).not.toBeNull();
|
||||
|
||||
updateTextEditor(editor, "");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
expect(getArrow("arrow").endBinding).toBeNull();
|
||||
expect(h.elements.filter((element) => !element.isDeleted)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ExcalidrawElement,
|
||||
GroupId,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawArrowElement,
|
||||
Arrowhead,
|
||||
FontFamilyValues,
|
||||
FileId,
|
||||
@@ -230,6 +231,7 @@ export type InteractiveCanvasAppState = Readonly<
|
||||
isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"];
|
||||
gridModeEnabled: AppState["gridModeEnabled"];
|
||||
suggestedBinding: AppState["suggestedBinding"];
|
||||
hoveredArrowTextAnchor: AppState["hoveredArrowTextAnchor"];
|
||||
isRotating: AppState["isRotating"];
|
||||
elementsToHighlight: AppState["elementsToHighlight"];
|
||||
// Collaborators
|
||||
@@ -364,6 +366,15 @@ export interface AppState {
|
||||
element: NonDeleted<ExcalidrawBindableElement>;
|
||||
midPoint?: GlobalPoint;
|
||||
} | null;
|
||||
/**
|
||||
* Where on a hovered arrow the text tool would attach text if clicked —
|
||||
* a free endpoint (binds the arrow to a new text element positioned against
|
||||
* that endpoint) or the arrow's midpoint (adds a label bound to the arrow).
|
||||
*/
|
||||
hoveredArrowTextAnchor: {
|
||||
elementId: ExcalidrawArrowElement["id"];
|
||||
anchor: "start" | "end" | "label";
|
||||
} | null;
|
||||
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement> | null;
|
||||
frameRendering: {
|
||||
enabled: boolean;
|
||||
@@ -557,6 +568,7 @@ export type UIAppState = Omit<
|
||||
| "snapLines"
|
||||
| "originSnapOffset"
|
||||
| "suggestedBinding"
|
||||
| "hoveredArrowTextAnchor"
|
||||
| "frameToHighlight"
|
||||
| "elementsToHighlight"
|
||||
>;
|
||||
|
||||
@@ -40,6 +40,9 @@ import {
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "../tests/test-utils";
|
||||
import { actionBindText } from "../actions";
|
||||
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
unmountComponent();
|
||||
|
||||
@@ -1965,4 +1968,133 @@ describe("textWysiwyg", () => {
|
||||
expect(colorsAreEqual(editor.style.color, originalColor)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoResize handle", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
API.setElements([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* a fixed-width text wrapped onto two lines. `originalText`/`autoResize`
|
||||
* are set after the fact — `API.createElement` accepts neither, and would
|
||||
* otherwise leave an autogrowing element whose `originalText` still holds
|
||||
* the line break.
|
||||
*/
|
||||
const wrappedText = (
|
||||
overrides: Partial<ExcalidrawTextElement> = {},
|
||||
): ExcalidrawTextElement =>
|
||||
({
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
id: "text",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 50,
|
||||
text: "this is it my friends\nald aksdl askdlasdk",
|
||||
}),
|
||||
originalText: "this is it my friends ald aksdl askdlasdk",
|
||||
autoResize: false,
|
||||
...overrides,
|
||||
} as ExcalidrawTextElement);
|
||||
|
||||
// unwrapping resizes the box, so whichever edge the alignment pins has to
|
||||
// stay put — otherwise the text slides out from under itself
|
||||
it.each([
|
||||
{ textAlign: "left" as const, edge: (t: ExcalidrawTextElement) => t.x },
|
||||
{
|
||||
textAlign: "right" as const,
|
||||
edge: (t: ExcalidrawTextElement) => t.x + t.width,
|
||||
},
|
||||
{
|
||||
textAlign: "center" as const,
|
||||
edge: (t: ExcalidrawTextElement) => t.x + t.width / 2,
|
||||
},
|
||||
])("keeps the $textAlign anchor when unwrapping", ({ textAlign, edge }) => {
|
||||
API.setElements([wrappedText({ textAlign })]);
|
||||
API.setAppState({ selectedElementIds: { text: true } });
|
||||
expect((h.elements[0] as ExcalidrawTextElement).autoResize).toBe(false);
|
||||
const before = edge(h.elements[0] as ExcalidrawTextElement);
|
||||
|
||||
API.executeAction(actionTextAutoResize);
|
||||
|
||||
const text = h.elements[0] as ExcalidrawTextElement;
|
||||
expect(text.autoResize).toBe(true);
|
||||
// the box really did change size, so the assertion isn't vacuous
|
||||
expect(text.width).not.toBeCloseTo(300, 0);
|
||||
expect(edge(text)).toBeCloseTo(before, 4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
verticalAlign: "top" as const,
|
||||
edge: (t: ExcalidrawTextElement) => t.y,
|
||||
},
|
||||
{
|
||||
verticalAlign: "bottom" as const,
|
||||
edge: (t: ExcalidrawTextElement) => t.y + t.height,
|
||||
},
|
||||
{
|
||||
verticalAlign: "middle" as const,
|
||||
edge: (t: ExcalidrawTextElement) => t.y + t.height / 2,
|
||||
},
|
||||
])(
|
||||
"keeps the $verticalAlign anchor when unwrapping",
|
||||
({ verticalAlign, edge }) => {
|
||||
API.setElements([wrappedText({ verticalAlign })]);
|
||||
API.setAppState({ selectedElementIds: { text: true } });
|
||||
expect((h.elements[0] as ExcalidrawTextElement).autoResize).toBe(false);
|
||||
const before = edge(h.elements[0] as ExcalidrawTextElement);
|
||||
|
||||
API.executeAction(actionTextAutoResize);
|
||||
|
||||
const text = h.elements[0] as ExcalidrawTextElement;
|
||||
expect(edge(text)).toBeCloseTo(before, 4);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("history", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
API.setElements([]);
|
||||
});
|
||||
|
||||
// Creating a text used to land in two history entries — the empty element,
|
||||
// then its content — so the first undo restored a live, zero-content
|
||||
// element. For a text bound to an arrow endpoint that also left the
|
||||
// endpoint occupied by something invisible.
|
||||
//
|
||||
// The invariant behind the single-undo behaviour is that creating the
|
||||
// element must not be captured on its own. Pointerup has more than one
|
||||
// capture site and they don't all fire in every environment, so assert the
|
||||
// stack directly rather than only the observable undo.
|
||||
it("does not open a history entry until the text is submitted", async () => {
|
||||
const stackSize = () => (h.history as any).undoStack.length;
|
||||
|
||||
UI.clickTool("text");
|
||||
const before = stackSize();
|
||||
|
||||
mouse.clickAt(400, 400);
|
||||
const editor = await getTextEditor();
|
||||
expect(stackSize()).toBe(before);
|
||||
|
||||
updateTextEditor(editor, "plain");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
expect(stackSize()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("removes a plain text in a single undo", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(400, 400);
|
||||
const editor = await getTextEditor();
|
||||
updateTextEditor(editor, "plain");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
|
||||
Keyboard.undo();
|
||||
|
||||
expect(h.elements.filter((el) => !el.isDeleted)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
"gridModeEnabled": false,
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"hoveredArrowTextAnchor": null,
|
||||
"hoveredElementIds": {},
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
|
||||
Reference in New Issue
Block a user