From 1acf66edabc2ac5bbd4aed0714aed7dca7cc2aab Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:12:33 +0200 Subject: [PATCH] feat(editor): bind text to hovered arrow endpoint (#11777) --- packages/element/src/arrowEndpointText.ts | 288 ++++++ packages/element/src/binding.ts | 70 +- packages/element/src/dragElements.ts | 110 ++- packages/element/src/index.ts | 1 + packages/element/src/linearElementEditor.ts | 57 +- packages/element/src/newElement.ts | 41 +- packages/element/src/textElement.ts | 35 +- packages/excalidraw/actions/actionDeselect.ts | 2 + .../excalidraw/actions/actionFinalize.tsx | 2 + .../actions/actionTextAutoResize.ts | 67 +- packages/excalidraw/appState.ts | 2 + .../excalidraw/components/App.arrowText.ts | 237 +++++ packages/excalidraw/components/App.tsx | 197 +++- .../components/canvases/InteractiveCanvas.tsx | 1 + .../excalidraw/renderer/interactiveScene.ts | 65 +- .../__snapshots__/contextmenu.test.tsx.snap | 17 + .../tests/__snapshots__/history.test.tsx.snap | 63 ++ .../regressionTests.test.tsx.snap | 52 ++ .../tests/arrowEndpointTextBinding.test.tsx | 860 ++++++++++++++++++ packages/excalidraw/types.ts | 12 + .../excalidraw/wysiwyg/textWysiwyg.test.tsx | 132 +++ .../tests/__snapshots__/export.test.ts.snap | 1 + 22 files changed, 2132 insertions(+), 180 deletions(-) create mode 100644 packages/element/src/arrowEndpointText.ts create mode 100644 packages/excalidraw/components/App.arrowText.ts create mode 100644 packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx diff --git a/packages/element/src/arrowEndpointText.ts b/packages/element/src/arrowEndpointText.ts new file mode 100644 index 0000000000..0c690c8529 --- /dev/null +++ b/packages/element/src/arrowEndpointText.ts @@ -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; + 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, + 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, + }; +}; diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index ede9079722..b0d0a9a13c 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -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, opts: Pick, ): 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, + bindableElement: NonDeleted, + 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, hoveredElement: NonDeleted, @@ -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, + bindableElement: NonDeleted, + startOrEnd: "start" | "end", + fixedPoint: FixedPoint, + scene: Scene, +): void => { + applyBinding( + arrow, + bindableElement, + { + elementId: bindableElement.id, + fixedPoint: normalizeFixedPoint(fixedPoint), + mode: "orbit", + }, + startOrEnd, + scene, + ); +}; diff --git a/packages/element/src/dragElements.ts b/packages/element/src/dragElements.ts index 38afdceabe..44137eae5a 100644 --- a/packages/element/src/dragElements.ts +++ b/packages/element/src/dragElements.ts @@ -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 }, diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index fe4baaabce..e2a8da535e 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -57,6 +57,7 @@ export const getNonDeletedElements = ( ): readonly NonDeleted[] => elements.filter(isNonDeletedElement); export * from "./align"; +export * from "./arrowEndpointText"; export * from "./binding"; export * from "./bounds"; export * from "./collision"; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 243f1f7b41..a090305490 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -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 = ( diff --git a/packages/element/src/newElement.ts b/packages/element/src/newElement.ts index db509ba774..4400313c73 100644 --- a/packages/element/src/newElement.ts +++ b/packages/element/src/newElement.ts @@ -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", }, diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index bfdccfc475..cd529e84bb 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -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) => { diff --git a/packages/excalidraw/actions/actionDeselect.ts b/packages/excalidraw/actions/actionDeselect.ts index 16c763a20a..86f08957cc 100644 --- a/packages/excalidraw/actions/actionDeselect.ts +++ b/packages/excalidraw/actions/actionDeselect.ts @@ -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, diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 44a10cd00b..0b080f7f4b 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -193,6 +193,7 @@ export const actionFinalize = register({ }, selectionElement: null, suggestedBinding: null, + hoveredArrowTextAnchor: null, newElement: null, multiElement: null, }, @@ -398,6 +399,7 @@ export const actionFinalize = register({ multiElement: null, editingTextElement: null, suggestedBinding: null, + hoveredArrowTextAnchor: null, frameToHighlight: null, selectedElementIds: isDrawShapeTool ? {} diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts index dcc0f16955..70c980f53e 100644 --- a/packages/excalidraw/actions/actionTextAutoResize.ts +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -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, }; }, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index ab85060fe0..e1abb4c94f 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -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 }, diff --git a/packages/excalidraw/components/App.arrowText.ts b/packages/excalidraw/components/App.arrowText.ts new file mode 100644 index 0000000000..05ea573158 --- /dev/null +++ b/packages/excalidraw/components/App.arrowText.ts @@ -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, + 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; + } +} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index cadb6edf04..4a7a32139a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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 { 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 { }); }); + // 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 { } const midPoint = getContainerCenter( selectedElement, - this.state, this.scene.getNonDeletedElementsMap(), ); const sceneX = midPoint.x; @@ -5856,6 +5863,8 @@ class App extends React.Component { flushSync(() => { this.setState({ isBindingEnabled: preferenceEnabled }); }); + + this.arrowText.refresh(); } maybeHandleArrowPointlikeDrag({ app: this, event }); @@ -6003,6 +6012,9 @@ class App extends React.Component { ? 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 { return selectedElement; } - private getTextElementAtPosition( + getTextElementAtPosition( x: number, y: number, ): NonDeleted | null { @@ -6419,7 +6431,7 @@ class App extends React.Component { }; // 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 { }); } - 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 { 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 { container, autoEdit = true, initialCaretSceneCoords, + arrowEndpoint, }: { /** X position to insert text at */ sceneX: number; @@ -6657,9 +6687,35 @@ class App extends React.Component { 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 { 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 { 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 { 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 { } } + 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 { ) { const midPoint = getContainerCenter( container, - this.state, this.scene.getNonDeletedElementsMap(), ); @@ -7996,6 +8070,9 @@ class App extends React.Component { hitElement = hitElementMightBeLocked; } + const hoveredArrowTextAnchor = + this.arrowText.updateHoveredAnchor(scenePointer); + if ( !this.handleIframeLikeElementHover({ hitElement, @@ -8023,7 +8100,11 @@ class App extends React.Component { 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 { 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; - 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; + 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 { }, ); - this.store.scheduleCapture(); + if (!this.isEditingTextContent()) { + this.store.scheduleCapture(); + } if (hitLockedElement?.locked) { this.setState({ @@ -12269,12 +12375,16 @@ class App extends React.Component { } 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 { 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 { }, ); - private getTextWysiwygSnappedToCenterPosition( + getTextWysiwygSnappedToCenterPosition( x: number, y: number, appState: AppState, @@ -13661,7 +13775,6 @@ class App extends React.Component { const elementCenter = getContainerCenter( container, - appState, this.scene.getNonDeletedElementsMap(), ); if (elementCenter) { diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index f5360e485d..631ce27146 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -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 diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 31a6ca8886..ff146a1c13 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -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: 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 = ( 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 = ( 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, diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 2843b16ac3..0161971b52 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -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, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index ca42d47a66..7cd26c4654 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -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, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 9e55a13481..26865c9362 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -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, diff --git a/packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx b/packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx new file mode 100644 index 0000000000..b0309bd7b9 --- /dev/null +++ b/packages/excalidraw/tests/arrowEndpointTextBinding.test.tsx @@ -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 = {}, +) => + API.createElement({ + type: "arrow", + id, + x: startX, + y: startY, + width: Math.abs(endX - startX), + height: Math.abs(endY - startY), + points: [ + pointFrom(0, 0), + pointFrom(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(); + }); + + 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(0, 0), + pointFrom(150, 180), + pointFrom(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 = {}) => + 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, + ); + }); + }); +}); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 3162e59032..0ef9b3cf78 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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; 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 | null; frameRendering: { enabled: boolean; @@ -557,6 +568,7 @@ export type UIAppState = Omit< | "snapLines" | "originSnapOffset" | "suggestedBinding" + | "hoveredArrowTextAnchor" | "frameToHighlight" | "elementsToHighlight" >; diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx index f8647419f3..bc4ff671f5 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.test.tsx @@ -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(); + 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 => + ({ + ...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(); + 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); + }); + }); }); diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index aef46783d3..761445ed92 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -57,6 +57,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "gridModeEnabled": false, "gridSize": 20, "gridStep": 5, + "hoveredArrowTextAnchor": null, "hoveredElementIds": {}, "isBindingEnabled": true, "isCropping": false,