Files
2026-09-16 05:31:04 +05:00

353 lines
10 KiB
TypeScript

import { CURSOR_TYPE, DRAGGING_THRESHOLD } from "@excalidraw/common";
import {
bindBindingElementToFixedPoint,
dragNewTextElement,
getBoundTextElement,
getEndpointBoundTextDragAnchor,
getTextBindingForArrowEndpoint,
getUnboundArrowEndpointAtPoint,
hitElementBoundText,
isArrowElement,
isBindingEnabled,
isEndpointBoundText,
isTextElement,
LinearElementEditor,
} from "@excalidraw/element";
import { pointDistance, pointFrom } from "@excalidraw/math";
import type { ArrowEndpoint } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
FixedPoint,
NonDeleted,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type App from "./App";
import type { AppState, PointerDownState } from "../types";
/**
* Text ↔ arrow interactions.
*
* With the text tool: 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.
*
* With the selection tool: dragging an arrow's existing label along the arrow
* — the grab affordance, the drag itself, and where a double-click starts
* editing a label the drag has moved.
*
* The scene-level logic lives in `@excalidraw/element`'s
* `arrowEndpointText.ts` and `linearElementEditor.ts`.
*/
export class AppArrowText {
constructor(private app: App) {}
/**
* With the text tool active, an arrow under the cursor is a target for
* attaching text. Returns where the text would land (if anywhere), and keeps
* `appState.hoveredArrowTextAnchor` — which drives the highlight — in sync.
*/
updateHoveredAnchor = (scenePointer: {
x: number;
y: number;
}): AppState["hoveredArrowTextAnchor"] => {
const hovered =
this.app.state.activeTool.type === "text" &&
!this.app.state.editingTextElement &&
!this.app.state.newElement
? this.getAnchorAtPosition(scenePointer.x, scenePointer.y)
: null;
const previous = this.app.state.hoveredArrowTextAnchor;
if (
previous?.elementId !== hovered?.elementId ||
previous?.anchor !== hovered?.anchor
) {
this.app.setState({ hoveredArrowTextAnchor: hovered });
}
return hovered;
};
/**
* Re-evaluates the hovered anchor at the last known pointer position — for
* events that change what a click would do without the pointer moving,
* i.e. the ctrl/cmd binding toggle. (Events that invalidate the anchor
* wholesale — tool switches, finalize, deselect — clear it directly
* instead.)
*/
refresh = (): void => {
if (this.app.lastPointerMoveCoords) {
this.updateHoveredAnchor(this.app.lastPointerMoveCoords);
}
};
/**
* A free arrow endpoint a new text could be bound to. Binding an endpoint is
* an arrow binding, so it follows the binding toggle (ctrl/cmd) like every
* other one — holding it makes the text tool drop plain text instead.
*/
getBindableEndpointAtPosition(x: number, y: number): ArrowEndpoint | null {
if (!isBindingEnabled(this.app.state)) {
return null;
}
const endpoint = getUnboundArrowEndpointAtPoint(
pointFrom(x, y),
this.app.scene.getNonDeletedElements(),
this.app.scene.getNonDeletedElementsMap(),
this.app.state.zoom,
);
if (!endpoint) {
return null;
}
// The text tool edits before it creates: when a text element is the
// top-most hit at this position, a click edits that text, so a nearby
// endpoint must not be offered over it.
if (this.app.getTextElementAtPosition(x, y)) {
return null;
}
// The endpoint scan only knows about arrows, so it happily reaches through
// whatever is drawn on top of them. An element stacked above the arrow that
// the pointer actually hits owns the click — the text tool should label
// that element rather than bind the endpoint hidden behind it.
const hitElement = this.app.getElementAtPosition(x, y, {
includeLockedElements: true,
});
if (
hitElement &&
hitElement.id !== endpoint.arrow.id &&
this.app.scene.getElementIndex(hitElement.id) >
this.app.scene.getElementIndex(endpoint.arrow.id)
) {
return null;
}
return endpoint;
}
/**
* Mirrors what `handleTextOnPointerDown` would do at this position, so the
* highlight can't promise something the click won't deliver.
*/
private getAnchorAtPosition(
x: number,
y: number,
): AppState["hoveredArrowTextAnchor"] {
const endpoint = this.getBindableEndpointAtPosition(x, y);
if (endpoint) {
return { elementId: endpoint.arrow.id, anchor: endpoint.startOrEnd };
}
const container = this.app.getTextBindableContainerAtPosition(x, y);
// Only arrows get a midpoint label anchor worth pointing at; other
// containers center the text in themselves, which needs no affordance.
// An arrow that already has a label is edited in place, not re-anchored.
if (
!isArrowElement(container) ||
getBoundTextElement(container, this.app.scene.getNonDeletedElementsMap())
) {
return null;
}
// `getTextBindableContainerAtPosition` resolves an arrow anywhere in its
// bounding box, but a click only becomes a *label* when it also snaps to
// the arrow's center — off-center clicks drop a free-floating text
// instead. Gate on the same check so the highlight can't promise a label
// the click won't deliver.
const snappedToCenter = this.app.getTextWysiwygSnappedToCenterPosition(
x,
y,
this.app.state,
container,
);
return snappedToCenter
? { elementId: container.id, anchor: "label" }
: null;
}
/**
* How a text should be created to read as a label for this endpoint — the
* side midpoint to bind, the alignment that pins it, and the scene position
* it must sit at. `targetStrokeWidth` is the caller's to provide so it can
* guarantee it matches the stroke width the text is then created with — the
* binding gap derives from it (see `getTextBindingForArrowEndpoint`).
*/
getTextBinding(
{ arrow, startOrEnd }: ArrowEndpoint,
targetStrokeWidth: number,
) {
return getTextBindingForArrowEndpoint(
arrow,
startOrEnd,
this.app.scene.getNonDeletedElementsMap(),
targetStrokeWidth,
);
}
/**
* Binds the arrow endpoint to the created text, at the side midpoint the
* placement resolved (`getTextBinding`'s `fixedPoint`).
*/
bindText(
{ arrow, startOrEnd }: ArrowEndpoint,
text: NonDeleted<ExcalidrawTextElement>,
fixedPoint: FixedPoint,
): void {
bindBindingElementToFixedPoint(
arrow,
text,
startOrEnd,
fixedPoint,
this.app.scene,
);
}
/**
* A text bound to an arrow endpoint can't be positioned by the drag — the
* binding already placed it — so only its width is dragged out. Returns
* whether it owned the drag.
*/
maybeDragNewText(
newElement: ExcalidrawElement,
pointerCoords: { x: number; y: number },
): boolean {
if (
!isTextElement(newElement) ||
!isEndpointBoundText(
newElement,
this.app.scene.getNonDeletedElementsMap(),
)
) {
return false;
}
dragNewTextElement({
newElement,
...getEndpointBoundTextDragAnchor(newElement),
pointerX: pointerCoords.x,
zoom: this.app.state.zoom.value,
scene: this.app.scene,
});
return true;
}
/**
* Whether the arrow's label is what a grab at this position would pick up:
* the pointer is over the label, and no element stacked above the arrow
* owns the hit instead.
*/
isBoundTextGrabbable(
element: NonDeletedExcalidrawElement,
x: number,
y: number,
): boolean {
if (
!isArrowElement(element) ||
!hitElementBoundText(
pointFrom(x, y),
element,
this.app.scene.getNonDeletedElementsMap(),
)
) {
return false;
}
const hitElements = this.app.getElementsAtPosition(x, y);
const arrowIndex = hitElements.findIndex((el) => el.id === element.id);
return (
arrowIndex !== -1 &&
!hitElements
.slice(arrowIndex + 1)
.some((el) => this.app.hitElement(x, y, el, false))
);
}
/**
* The pointer-move half of dragging a label along its arrow. Owns the move
* whenever the gesture started on the label (`pointerDownState.hit.arrowLabel`),
* dragging only once past the threshold. Returns whether it owned it.
*/
maybeDragLabel(
linearElementEditor: LinearElementEditor,
pointerDownState: PointerDownState,
pointerCoords: { x: number; y: number },
): boolean {
if (!pointerDownState.hit.arrowLabel) {
return false;
}
this.app.cursor.set(CURSOR_TYPE.GRABBING);
if (
linearElementEditor.isDragging ||
pointDistance(
pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
pointFrom(pointerCoords.x, pointerCoords.y),
) >=
DRAGGING_THRESHOLD / this.app.state.zoom.value
) {
const updatedEditor = LinearElementEditor.handleBoundTextDragging(
linearElementEditor,
this.app.scene,
pointerCoords.x,
pointerCoords.y,
);
if (updatedEditor) {
pointerDownState.drag.hasOccurred = true;
this.app.setState({
selectedLinearElement: updatedEditor,
});
}
}
return true;
}
/**
* Where a double-click on a labeled arrow should start text editing: the
* center of the label itself, which a label drag may have moved away from
* the arrow's midpoint. Null when the element is not a labeled arrow.
*/
getLabelCenter(element: ExcalidrawElement): { x: number; y: number } | null {
const elementsMap = this.app.scene.getNonDeletedElementsMap();
if (!isArrowElement(element)) {
return null;
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (!boundTextElement) {
return null;
}
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
return {
x: x + boundTextElement.width / 2,
y: y + boundTextElement.height / 2,
};
}
}