feat(editor): bind text to hovered arrow endpoint (#11777)

This commit is contained in:
David Luzar
2026-07-28 18:12:33 +02:00
committed by GitHub
parent 7c0b329576
commit 1acf66edab
22 changed files with 2132 additions and 180 deletions

View 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,
};
};

View File

@@ -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,
);
};

View File

@@ -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 },

View File

@@ -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";

View File

@@ -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 = (

View File

@@ -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",
},

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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
? {}

View File

@@ -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,
};
},

View File

@@ -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 },

View 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;
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View 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,
);
});
});
});

View File

@@ -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"
>;

View File

@@ -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);
});
});
});

View File

@@ -57,6 +57,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"hoveredArrowTextAnchor": null,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,