feat: Focus indicator (#10613)

* feat: Focus indicator

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snapshot update

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Move visualdebug to utils and introduce volume bindable volume visualization

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Move visualdebug to elements

Due to dep circles

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Possible test timeout

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Incorrect hit test point input

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Add fallback when dragged outside of allowed area

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Elbow arrows don't need focus point mgmt

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: End bound indirect fix

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Show indicator when arrow endpoint dragging

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Update bound arrow endpoint at mid-point drag

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Refactor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Curve endpoint intersection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Outline focus point is reset on existing arrow drag

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Fix lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Dragging focus point off

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Don't show the focus indicator when arrow endpoint is dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Drag area for focus handles

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus point size unified

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Size bump for focus knob

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Cache hits and scene lookups

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Consider hit threshold and inside override too

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Other shape switching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* perf: Update tolerance params

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus know line width

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: knob offset

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Full overlap

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove Map caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: incorrect threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: threshold setting

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: cache override

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snapshots

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Redesigned focus point handling

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Inside-inside mode

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove comment

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Allow focus knob outside the shape

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow endpoint offset

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus knob element distance

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Increase iteration on curve intersection calc

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Handle disabled binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt mode

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Nested shape focus rewrite

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Alt + Ctrl + arrow endpoitn

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit ordering for focus points

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus point visibility

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* dry out renderFocusPointIndicator

* do not higlight point when dragging & make focus point smaller

* optimize retrieval of selectedLinearElement

* move focus highlighting into renderFocusPointIndicator to DRY out and colocate

* remove `disabled` state from focus highlight

* make focus point stroke color less prominent

* fix: No focus point for multi-point arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow edit mode drag focus point release

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* DRY out arrow point-like drag

* move `focus.ts` to `arrows/focus.ts`

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács
2026-01-31 15:08:28 +01:00
committed by GitHub
parent 216afc3625
commit b552c60714
16 changed files with 1112 additions and 90 deletions
+541
View File
@@ -0,0 +1,541 @@
import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { invariant } from "@excalidraw/common";
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
import {
bindBindingElement,
calculateFixedPointForNonElbowArrowBinding,
FOCUS_POINT_SIZE,
getBindingGap,
getGlobalFixedPointForBindableElement,
isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement,
updateBoundPoint,
} from "../binding";
import {
isBindableElement,
isBindingElement,
isElbowArrow,
} from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
NonDeletedSceneElementsMap,
PointsPositionUpdates,
} from "../types";
import type { Scene } from "../Scene";
export const isFocusPointVisible = (
focusPoint: GlobalPoint,
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
appState: {
isBindingEnabled: AppState["isBindingEnabled"];
zoom: AppState["zoom"];
},
ignoreOverlap = false,
): boolean => {
// No focus point management for elbow arrows, because elbow arrows
// always have their focus point at the arrow point itself
if (
isElbowArrow(arrow) ||
!isBindingEnabled(appState) ||
arrow.points.length !== 2
) {
return false;
}
// Avoid showing the focus point indicator if the focus point is essentially
// on top of the arrow point it belongs to itself, if not ignoring specifically
if (!ignoreOverlap) {
const associatedPointIdx =
arrow.startBinding?.elementId === bindableElement.id
? 0
: arrow.points.length - 1;
const associatedArrowPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
associatedPointIdx,
elementsMap,
);
if (
pointDistance(focusPoint, associatedArrowPoint) <
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value
) {
return false;
}
}
// Check if the focus point is within the element's shape bounds
return hitElementItself({
element: bindableElement,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true,
});
};
// Updates the arrow endpoints in "orbit" configuration
const focusPointUpdate = (
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement | null,
isStartBinding: boolean,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
appState: AppState,
switchToInsideBinding: boolean,
) => {
const pointUpdates = new Map();
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
let currentBinding = arrow[bindingField];
let adjacentBinding = arrow[adjacentBindingField];
// Update the dragged focus point related end
if (currentBinding && bindableElement) {
// Update the targeted bindings
const boundToSameElement =
bindableElement &&
adjacentBinding &&
currentBinding.elementId === adjacentBinding.elementId;
if (switchToInsideBinding || boundToSameElement) {
currentBinding = {
...currentBinding,
mode: "inside",
};
} else {
currentBinding = {
...currentBinding,
mode: "orbit",
};
}
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
const newPoint = updateBoundPoint(
arrow,
bindingField as "startBinding" | "endBinding",
currentBinding,
bindableElement,
elementsMap,
);
if (newPoint) {
pointUpdates.set(pointIndex, { point: newPoint });
}
}
// Also update the adjacent end if it has a binding
if (adjacentBinding) {
const adjacentBindableElement = elementsMap.get(
adjacentBinding.elementId,
) as ExcalidrawBindableElement;
if (
adjacentBindableElement &&
isBindableElement(adjacentBindableElement) &&
isBindingEnabled(appState)
) {
// Same shape bound on both ends
const boundToSameElementAfterUpdate =
bindableElement && adjacentBinding.elementId === bindableElement.id;
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
adjacentBinding = {
...adjacentBinding,
mode: "inside",
};
} else {
adjacentBinding = {
...adjacentBinding,
mode: "orbit",
};
}
const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0;
const adjacentNewPoint = updateBoundPoint(
arrow,
adjacentBindingField,
adjacentBinding,
adjacentBindableElement,
elementsMap,
);
if (adjacentNewPoint) {
pointUpdates.set(adjacentPointIndex, {
point: adjacentNewPoint,
});
}
}
}
if (pointUpdates.size > 0) {
LinearElementEditor.movePoints(arrow, scene, pointUpdates, {
[bindingField]: currentBinding,
[adjacentBindingField]: adjacentBinding,
});
}
};
export const handleFocusPointDrag = (
linearElementEditor: LinearElementEditor,
elementsMap: NonDeletedSceneElementsMap,
pointerCoords: { x: number; y: number },
scene: Scene,
appState: AppState,
gridSize: NullableGridSize,
switchToInsideBinding: boolean,
) => {
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
// Sanity checks
if (
!arrow ||
!isBindingElement(arrow) ||
isElbowArrow(arrow) ||
!linearElementEditor.hoveredFocusPointBinding ||
!linearElementEditor.draggedFocusPointBinding
) {
return;
}
const isStartBinding =
linearElementEditor.draggedFocusPointBinding === "start";
const binding = isStartBinding ? arrow.startBinding : arrow.endBinding;
const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset;
const point = pointFrom<GlobalPoint>(
pointerCoords.x - offsetX,
pointerCoords.y - offsetY,
);
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForFocusPoint(
point,
arrow,
scene.getNonDeletedElements(),
elementsMap,
maxBindingDistance_simple(appState.zoom),
);
// Hovering a bindable element
if (hit && isBindingEnabled(appState)) {
// Break existing binding if bound to another shape or if binding is disabled
if (arrow[bindingField] && hit.id !== binding?.elementId) {
unbindBindingElement(
arrow,
linearElementEditor.draggedFocusPointBinding,
scene,
);
}
// Handle binding mode switch
const newMode =
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
? "inside"
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
? "orbit"
: null;
// If no existing binding, create it
if (!arrow[bindingField] || newMode) {
// Create a new binding if none exists
bindBindingElement(
arrow,
hit,
newMode || "orbit",
linearElementEditor.draggedFocusPointBinding,
scene,
point,
);
}
// Update the binding's fixed point
scene.mutateElement(arrow, {
[bindingField]: {
...arrow[bindingField],
elementId: hit.id,
mode: newMode || arrow[bindingField]?.mode || "orbit",
...calculateFixedPointForNonElbowArrowBinding(
arrow,
hit,
linearElementEditor.draggedFocusPointBinding,
elementsMap,
point,
),
},
});
} else {
// Not hovering any bindable element, move the arrow endpoint
const pointUpdates: PointsPositionUpdates = new Map();
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
pointUpdates.set(pointIndex, {
point: LinearElementEditor.createPointAt(
arrow,
elementsMap,
point[0],
point[1],
gridSize,
),
});
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
if (arrow[bindingField]) {
unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene);
}
}
// Update the arrow endpoints
focusPointUpdate(
arrow,
hit,
isStartBinding,
elementsMap,
scene,
appState,
switchToInsideBinding,
);
if (hit && isBindingEnabled(appState)) {
moveArrowAboveBindable(
point,
arrow,
scene.getElementsIncludingDeleted(),
elementsMap,
scene,
hit,
);
}
};
export const handleFocusPointPointerDown = (
arrow: ExcalidrawArrowElement,
pointerDownState: { origin: { x: number; y: number } },
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
): {
hitFocusPoint: "start" | "end" | null;
pointerOffset: { x: number; y: number };
} => {
const pointerPos = pointFrom(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "start",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
// Check end binding focus point (only if start not already hit)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "end",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
return {
hitFocusPoint: null,
pointerOffset: { x: 0, y: 0 },
};
};
export const handleFocusPointPointerUp = (
linearElementEditor: LinearElementEditor,
scene: Scene,
) => {
invariant(
linearElementEditor.draggedFocusPointBinding,
"Must have a dragged focus point at pointer release",
);
const arrow = LinearElementEditor.getElement<ExcalidrawArrowElement>(
linearElementEditor.elementId,
scene.getNonDeletedElementsMap(),
);
invariant(arrow, "Arrow must be in the scene");
// Clean up
const bindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "startBinding"
: "endBinding";
const otherBindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "endBinding"
: "startBinding";
const boundElementId = arrow[bindingKey]?.elementId;
const otherBoundElementId = arrow[otherBindingKey]?.elementId;
const oldBoundElement =
boundElementId &&
scene
.getNonDeletedElements()
.find(
(element) =>
element.id !== boundElementId &&
element.id !== otherBoundElementId &&
isBindableElement(element) &&
element.boundElements?.find(({ id }) => id === arrow.id),
);
if (oldBoundElement) {
scene.mutateElement(oldBoundElement, {
boundElements: oldBoundElement.boundElements?.filter(
({ id }) => id !== arrow.id,
),
});
}
// Record the new bound element
const boundElement =
boundElementId && scene.getNonDeletedElementsMap().get(boundElementId);
if (boundElement) {
scene.mutateElement(boundElement, {
boundElements: [
...(boundElement.boundElements || [])?.filter(
({ id }) => id !== arrow.id,
),
{
id: arrow.id,
type: "arrow",
},
],
});
}
};
export const handleFocusPointHover = (
arrow: ExcalidrawArrowElement,
scenePointerX: number,
scenePointerY: number,
scene: Scene,
appState: AppState,
): "start" | "end" | null => {
const elementsMap = scene.getNonDeletedElementsMap();
const pointerPos = pointFrom(scenePointerX, scenePointerY);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "start";
}
}
}
// Check end binding focus point (only if start not already hovered)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "end";
}
}
}
return null;
};
+45
View File
@@ -0,0 +1,45 @@
import type { App } from "@excalidraw/excalidraw/types";
import { LinearElementEditor } from "../linearElementEditor";
import { handleFocusPointDrag } from "./focus";
export const maybeHandleArrowPointlikeDrag = ({
app,
event,
}: {
app: App;
event: KeyboardEvent | React.KeyboardEvent<Element> | PointerEvent;
}): boolean => {
const appState = app.state;
if (appState.selectedLinearElement && app.lastPointerMoveCoords) {
// Update focus point status if the binding mode is changing
if (appState.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointDrag(
appState.selectedLinearElement,
app.scene.getNonDeletedElementsMap(),
app.lastPointerMoveCoords,
app.scene,
appState,
app.getEffectiveGridSize(),
event.altKey,
);
return true;
} else if (
appState.selectedLinearElement.hoverPointIndex !== null &&
app.lastPointerMoveEvent &&
appState.selectedLinearElement.initialState.lastClickedPoint >= 0 &&
appState.selectedLinearElement.isDragging
) {
LinearElementEditor.handlePointDragging(
app.lastPointerMoveEvent,
app,
app.lastPointerMoveCoords.x,
app.lastPointerMoveCoords.y,
appState.selectedLinearElement,
);
return true;
}
}
return false;
};
+20 -15
View File
@@ -116,6 +116,7 @@ export type BindingStrategy =
*/
export const BASE_BINDING_GAP = 10;
export const BASE_BINDING_GAP_ELBOW = 5;
export const FOCUS_POINT_SIZE = 10 / 1.5;
export const getBindingGap = (
bindTarget: ExcalidrawBindableElement,
@@ -145,7 +146,9 @@ export const shouldEnableBindingForPointerEvent = (
return !event[KEYS.CTRL_OR_CMD];
};
export const isBindingEnabled = (appState: AppState): boolean => {
export const isBindingEnabled = (appState: {
isBindingEnabled: AppState["isBindingEnabled"];
}): boolean => {
return appState.isBindingEnabled;
};
@@ -259,7 +262,7 @@ const bindingStrategyForElbowArrowEndpointDragging = (
globalPoint,
elements,
elementsMap,
(element) => maxBindingDistance_simple(zoom),
maxBindingDistance_simple(zoom),
);
const current = hit
@@ -684,7 +687,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
globalPoint,
elements,
elementsMap,
(e) => maxBindingDistance_simple(appState.zoom),
maxBindingDistance_simple(appState.zoom),
);
const pointInElement =
hit &&
@@ -711,7 +714,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
const otherFocusPointIsInElement =
otherBindableElement &&
otherFocusPoint &&
isPointInElement(otherFocusPoint, otherBindableElement, elementsMap);
hitElementItself({
point: otherFocusPoint,
element: otherBindableElement,
elementsMap,
threshold: 0,
overrideShouldTestInside: true,
});
// Handle outside-outside binding to the same element
if (otherBinding && otherBinding.elementId === hit?.id) {
@@ -1674,7 +1683,9 @@ export const updateBoundPoint = (
binding: FixedPointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>,
opts?: {
customIntersector?: LineSegment<GlobalPoint>;
},
): LocalPoint | null => {
if (
binding == null ||
@@ -1761,16 +1772,14 @@ export const updateBoundPoint = (
const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther;
let _customIntersector = customIntersector;
let _customIntersector = opts?.customIntersector;
if (!elbowed && !_customIntersector) {
const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords(
arrow,
elementsMap,
);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(arrow, bindableElement, elementsMap, global)
: global;
const edgePoint = global;
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
arrow.x +
@@ -1884,7 +1893,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap: ElementsMap,
focusPoint?: GlobalPoint,
): { fixedPoint: FixedPoint } => {
const edgePoint = focusPoint
const edgePoint: GlobalPoint = focusPoint
? focusPoint
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
@@ -1892,11 +1901,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap,
);
// Convert the global point to element-local coordinates
const elementCenter = pointFrom(
hoveredElement.x + hoveredElement.width / 2,
hoveredElement.y + hoveredElement.height / 2,
);
const elementCenter = elementCenterPoint(hoveredElement, elementsMap);
// Rotate the point to account for element rotation
const nonRotatedPoint = pointRotateRads(
+57 -4
View File
@@ -59,8 +59,11 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
@@ -290,7 +293,7 @@ export const getAllHoveredElementAtPoint = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
@@ -306,7 +309,7 @@ export const getAllHoveredElementAtPoint = (
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
@@ -323,13 +326,13 @@ export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint(
point,
elements,
elementsMap,
toleranceFn,
tolerance,
);
if (!candidateElements || candidateElements.length === 0) {
@@ -348,6 +351,56 @@ export const getHoveredElementForBinding = (
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
export const getHoveredElementForFocusPoint = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): ExcalidrawBindableElement | null => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
}
}
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
const distanceFilteredCandidateElements = candidateElements
// Resolve by distance
.filter(
(el) =>
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
isPointInElement(point, el, elementsMap),
);
if (distanceFilteredCandidateElements.length === 0) {
return null;
}
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
};
/**
* Intersect a line with an element for binding test
*
+1 -1
View File
@@ -2276,7 +2276,7 @@ const getHoveredElement = (
origPoint,
elements,
elementsMap,
(element) => maxBindingDistance_simple(zoom),
maxBindingDistance_simple(zoom),
);
};
+2
View File
@@ -70,6 +70,7 @@ export * from "./elbowArrow";
export * from "./elementLink";
export * from "./embeddable";
export * from "./flowchart";
export * from "./arrows/focus";
export * from "./fractionalIndex";
export * from "./frame";
export * from "./groups";
@@ -97,3 +98,4 @@ export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
+74 -14
View File
@@ -149,6 +149,8 @@ export class LinearElementEditor {
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly hoveredFocusPointBinding: "start" | "end" | null;
public readonly draggedFocusPointBinding: "start" | "end" | null;
public readonly elbowed: boolean;
public readonly customLineAngle: number | null;
public readonly isEditing: boolean;
@@ -194,6 +196,8 @@ export class LinearElementEditor {
};
this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null;
this.hoveredFocusPointBinding = null;
this.draggedFocusPointBinding = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
this.isEditing = isEditing;
@@ -2135,6 +2139,63 @@ const pointDraggingUpdates = (
};
}
// Handle the case where neither endpoint is being dragged
// but we need to update bound endpoints
if (!startIsDragged && !endIsDragged) {
const nextArrow = {
...element,
points: element.points.map((p, idx) => {
return naiveDraggingPoints.get(idx)?.point ?? p;
}),
};
const positions = new Map(naiveDraggingPoints);
if (element.startBinding) {
const startBindable = elementsMap.get(element.startBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (startBindable) {
const startPoint =
updateBoundPoint(
nextArrow,
"startBinding",
element.startBinding,
startBindable,
elementsMap,
) ?? null;
if (startPoint) {
positions.set(0, { point: startPoint, isDragging: true });
}
}
}
if (element.endBinding) {
const endBindable = elementsMap.get(element.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (endBindable) {
const endPoint =
updateBoundPoint(
nextArrow,
"endBinding",
element.endBinding,
endBindable,
elementsMap,
) ?? null;
if (endPoint) {
positions.set(element.points.length - 1, {
point: endPoint,
isDragging: true,
});
}
}
}
return {
positions,
};
}
if (startIsDragged === endIsDragged) {
return {
positions: naiveDraggingPoints,
@@ -2279,7 +2340,9 @@ const pointDraggingUpdates = (
nextArrow.endBinding,
endBindable,
elementsMap,
endCustomIntersector,
{
customIntersector: endCustomIntersector,
},
) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1];
@@ -2302,7 +2365,7 @@ const pointDraggingUpdates = (
: startIsDraggingOverEndElement &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
? nextArrow.points[nextArrow.points.length - 1]
? endLocalPoint
: startBindable
? updateBoundPoint(
element,
@@ -2310,15 +2373,18 @@ const pointDraggingUpdates = (
nextArrow.startBinding,
startBindable,
elementsMap,
startCustomIntersector,
{ customIntersector: startCustomIntersector },
) || nextArrow.points[0]
: nextArrow.points[0];
const endChanged =
pointDistance(
endLocalPoint,
nextArrow.points[nextArrow.points.length - 1],
) !== 0;
!startIsDraggingOverEndElement &&
!(
endIsDraggingOverStartElement &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
) &&
!!endBindable;
const startChanged =
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
@@ -2332,13 +2398,7 @@ const pointDraggingUpdates = (
const indices = Array.from(indicesSet);
return {
updates:
updates.startBinding || updates.suggestedBinding
? {
startBinding: updates.startBinding,
suggestedBinding: updates.suggestedBinding,
}
: undefined,
updates,
positions: new Map(
indices.map((idx) => {
return [
+4 -5
View File
@@ -156,12 +156,11 @@ export const moveArrowAboveBindable = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
hit?: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = getHoveredElementForBinding(
point,
elements,
elementsMap,
);
const hoveredElement = hit
? hit
: getHoveredElementForBinding(point, elements, elementsMap);
if (!hoveredElement) {
return elements;
@@ -378,7 +378,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -419,7 +419,7 @@ describe("Test Linear Elements", () => {
fireEvent.click(screen.getByTitle("Round"));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`9`,
`10`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -480,7 +480,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -548,7 +548,7 @@ describe("Test Linear Elements", () => {
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`,
`15`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
@@ -599,7 +599,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -640,7 +640,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -688,7 +688,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`,
`18`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
@@ -746,7 +746,7 @@ describe("Test Linear Elements", () => {
),
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`,
`15`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(line.points.length).toEqual(5);
@@ -844,7 +844,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
+140 -12
View File
@@ -250,6 +250,11 @@ import {
maxBindingDistance_simple,
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
handleFocusPointDrag,
handleFocusPointHover,
handleFocusPointPointerDown,
handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -4734,8 +4739,12 @@ class App extends React.Component<AppProps, AppState> {
}
// Handle Alt key for bind mode
if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleSkipBindMode();
if (event.key === KEYS.ALT) {
if (getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleSkipBindMode();
} else {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
}
if (this.actionManager.handleKeyDown(event)) {
@@ -4751,7 +4760,11 @@ class App extends React.Component<AppProps, AppState> {
this.resetDelayedBindMode();
}
this.setState({ isBindingEnabled: false });
flushSync(() => {
this.setState({ isBindingEnabled: false });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if (isArrowKey(event.key)) {
@@ -5024,6 +5037,11 @@ class App extends React.Component<AppProps, AppState> {
}
isHoldingSpace = false;
}
if (event.key === KEYS.ALT) {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if (
(event.key === KEYS.ALT && this.state.bindMode === "skip") ||
(!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state))
@@ -5034,7 +5052,7 @@ class App extends React.Component<AppProps, AppState> {
});
// Restart the timer if we're creating/editing a linear element and hovering over an element
if (this.lastPointerMoveEvent) {
if (this.lastPointerMoveEvent && getFeatureFlag("COMPLEX_BINDINGS")) {
const scenePointer = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerMoveEvent.clientX,
@@ -5055,14 +5073,18 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
);
if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) {
if (isBindingElement(element)) {
this.handleDelayedBindModeChange(element, hoveredElement);
}
}
}
}
if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
this.setState({ isBindingEnabled: true });
flushSync(() => {
this.setState({ isBindingEnabled: true });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if (isArrowKey(event.key)) {
bindOrUnbindBindingElements(
@@ -6379,7 +6401,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom),
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.setState({
@@ -6410,7 +6432,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom),
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", {
@@ -6564,7 +6586,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
(el) => maxBindingDistance_simple(this.state.zoom),
maxBindingDistance_simple(this.state.zoom),
);
if (
hit &&
@@ -6923,6 +6945,37 @@ class App extends React.Component<AppProps, AppState> {
},
});
}
// Check for focus point hover
let hoveredFocusPointBinding: "start" | "end" | null = null;
const arrow = element as any;
if (arrow.startBinding || arrow.endBinding) {
hoveredFocusPointBinding = handleFocusPointHover(
element as ExcalidrawArrowElement,
scenePointerX,
scenePointerY,
this.scene,
this.state,
);
}
if (
this.state.selectedLinearElement.hoveredFocusPointBinding !==
hoveredFocusPointBinding
) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
hoveredFocusPointBinding,
},
});
}
// Set cursor to pointer when hovering over a focus point
if (hoveredFocusPointBinding) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
} else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
}
@@ -7844,6 +7897,37 @@ class App extends React.Component<AppProps, AppState> {
if (ret.didAddPoint) {
return true;
}
// Also check at current pointer position if focus point is being hovered
// (in case we're clicking directly without a prior move event)
const elementsMap = this.scene.getNonDeletedElementsMap();
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
if (arrow && isBindingElement(arrow)) {
const { hitFocusPoint, pointerOffset } =
handleFocusPointPointerDown(
arrow,
pointerDownState,
elementsMap,
this.state,
);
// If focus point is hit, update state and prevent element selection
if (hitFocusPoint) {
this.setState({
selectedLinearElement: {
...linearElementEditor,
hoveredFocusPointBinding: hitFocusPoint,
draggedFocusPointBinding: hitFocusPoint,
pointerOffset,
},
});
return false;
}
}
}
const allHitElements = this.getElementsAtPosition(
@@ -8991,6 +9075,31 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.selectedLinearElement) {
const linearElementEditor = this.state.selectedLinearElement;
// Handle focus point dragging if needed
if (linearElementEditor.draggedFocusPointBinding) {
handleFocusPointDrag(
linearElementEditor,
elementsMap,
pointerCoords,
this.scene,
this.state,
this.getEffectiveGridSize(),
event.altKey,
);
this.setState({
selectedLinearElement: {
...linearElementEditor,
isDragging: false,
selectedPointsIndices: [],
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
});
return;
}
if (
LinearElementEditor.shouldAddMidpoint(
this.state.selectedLinearElement,
@@ -9859,12 +9968,14 @@ class App extends React.Component<AppProps, AppState> {
// and sets binding element
if (
this.state.selectedLinearElement?.isEditing &&
!this.state.newElement
!this.state.newElement &&
this.state.selectedLinearElement.draggedFocusPointBinding === null
) {
if (
!pointerDownState.boxSelection.hasOccurred &&
pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId
this.state.selectedLinearElement.elementId &&
this.state.selectedLinearElement.draggedFocusPointBinding === null
) {
this.actionManager.executeAction(actionFinalize);
} else {
@@ -9900,7 +10011,18 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (
if (this.state.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointPointerUp(
this.state.selectedLinearElement,
this.scene,
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
draggedFocusPointBinding: null,
},
});
} else if (
pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId
) {
@@ -9910,6 +10032,12 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ selectedLinearElement: null });
}
} else if (this.state.selectedLinearElement.isDragging) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
},
});
this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent,
sceneCoords,
@@ -135,7 +135,8 @@ describe("binding with linear elements", () => {
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("186"));
UI.updateInput(inputX, String("184"));
expect(linear.startBinding).not.toBe(null);
});
+194 -16
View File
@@ -21,10 +21,13 @@ import {
deconstructDiamondElement,
deconstructRectanguloidElement,
elementCenterPoint,
FOCUS_POINT_SIZE,
getOmitSidesForEditorInterface,
getTransformHandles,
getTransformHandlesFromCoords,
hasBoundingBox,
isArrowElement,
isBindableElement,
isElbowArrow,
isFrameLikeElement,
isImageElement,
@@ -44,6 +47,10 @@ import {
} from "@excalidraw/element";
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
import {
getGlobalFixedPointForBindableElement,
isFocusPointVisible,
} from "@excalidraw/element";
import type {
TransformHandles,
@@ -52,6 +59,7 @@ import type {
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
@@ -71,11 +79,6 @@ import {
SCROLLBAR_WIDTH,
} from "../scene/scrollbars";
import {
type AppClassProperties,
type InteractiveCanvasAppState,
} from "../types";
import { getClientColor, renderRemoteCursors } from "../clients";
import {
@@ -85,6 +88,8 @@ import {
strokeRectWithRotation_simple,
} from "./helpers";
import type { AppClassProperties, InteractiveCanvasAppState } from "../types";
import type {
InteractiveCanvasRenderConfig,
InteractiveSceneRenderConfig,
@@ -123,6 +128,9 @@ const renderLinearElementPointHighlight = (
) {
return;
}
if (appState.selectedLinearElement?.isDragging) {
return;
}
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
@@ -156,6 +164,19 @@ const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
);
};
const renderFocusPointHighlight = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
focusPoint: GlobalPoint,
) => {
context.save();
context.translate(appState.scrollX, appState.scrollY);
highlightPoint(focusPoint, context, appState);
context.restore();
};
const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
@@ -920,6 +941,145 @@ const renderLinearPointHandles = (
context.restore();
};
const renderFocusPointConnectionLine = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
fromPoint: GlobalPoint,
toPoint: GlobalPoint,
) => {
context.save();
context.translate(appState.scrollX, appState.scrollY);
context.strokeStyle = "rgba(134, 131, 226, 0.6)";
context.lineWidth = 1 / appState.zoom.value;
context.setLineDash([4 / appState.zoom.value, 4 / appState.zoom.value]);
context.beginPath();
context.moveTo(fromPoint[0], fromPoint[1]);
context.lineTo(toPoint[0], toPoint[1]);
context.stroke();
context.restore();
};
const renderFocusPointCicle = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
point: GlobalPoint,
radius: number,
isHovered: boolean,
) => {
context.save();
context.translate(appState.scrollX, appState.scrollY);
context.strokeStyle = "rgba(134, 131, 226, 0.6)";
context.lineWidth = 1 / appState.zoom.value;
context.setLineDash([]);
context.fillStyle = isHovered
? "rgba(134, 131, 226, 0.9)"
: "rgba(255, 255, 255, 0.9)";
fillCircle(
context,
point[0],
point[1],
radius / appState.zoom.value,
true,
true,
);
context.restore();
};
const renderFocusPointIndicator = ({
arrow,
appState,
type,
context,
elementsMap,
}: {
arrow: NonDeleted<ExcalidrawArrowElement>;
appState: InteractiveCanvasAppState;
context: CanvasRenderingContext2D;
elementsMap: NonDeletedSceneElementsMap;
type: "start" | "end";
}) => {
const binding = type === "start" ? arrow.startBinding : arrow.endBinding;
const bindableElement =
binding?.elementId && elementsMap.get(binding.elementId);
if (
!bindableElement ||
!isBindableElement(bindableElement) ||
bindableElement.isDeleted
) {
return;
}
const focusPoint = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindableElement,
elementsMap,
);
// Only render if focus point is within the bindable element
if (
!isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
)
) {
return;
}
const linearState = appState.selectedLinearElement;
const isDragging = !!linearState?.isDragging;
const pointIndex = type === "start" ? 0 : arrow.points.length - 1;
const pointSelected =
!!linearState?.selectedPointsIndices?.includes(pointIndex);
// render focus point highlight
// ----------------------------
if (
linearState?.hoveredFocusPointBinding === type &&
!linearState.draggedFocusPointBinding
) {
renderFocusPointHighlight(context, appState, focusPoint);
}
// render focus point
// ----------------------------
if (!(pointSelected && isDragging)) {
const focusPoint = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindableElement,
elementsMap,
);
const isHovered = linearState?.hoveredFocusPointBinding === type;
// Render dashed line from arrow start point to focus point
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
pointIndex,
elementsMap,
);
renderFocusPointConnectionLine(context, appState, arrowPoint, focusPoint);
renderFocusPointCicle(
context,
appState,
focusPoint,
FOCUS_POINT_SIZE / 1.5,
isHovered,
);
}
};
const renderTransformHandles = (
context: CanvasRenderingContext2D,
renderConfig: InteractiveCanvasRenderConfig,
@@ -1251,26 +1411,44 @@ const _renderInteractiveScene = ({
);
}
const linearState = appState.selectedLinearElement;
const selectedLinearElement =
linearState &&
LinearElementEditor.getElement(linearState.elementId, allElementsMap);
// Arrows have a different highlight behavior when
// they are the only selected element
if (appState.selectedLinearElement) {
const editor = appState.selectedLinearElement;
const firstSelectedLinear = selectedElements.find(
(el) => el.id === editor.elementId, // Don't forget bound text elements!
);
if (selectedLinearElement) {
if (!appState.selectedLinearElement.isDragging) {
if (editor.segmentMidPointHoveredCoords) {
if (linearState.segmentMidPointHoveredCoords) {
renderElbowArrowMidPointHighlight(context, appState);
} else if (
isElbowArrow(firstSelectedLinear)
? editor.hoverPointIndex === 0 ||
editor.hoverPointIndex === firstSelectedLinear.points.length - 1
: editor.hoverPointIndex >= 0
isElbowArrow(selectedLinearElement)
? linearState.hoverPointIndex === 0 ||
linearState.hoverPointIndex ===
selectedLinearElement.points.length - 1
: linearState.hoverPointIndex >= 0
) {
renderLinearElementPointHighlight(context, appState, elementsMap);
}
}
if (isArrowElement(selectedLinearElement)) {
renderFocusPointIndicator({
arrow: selectedLinearElement,
elementsMap: allElementsMap,
appState,
context,
type: "start",
});
renderFocusPointIndicator({
arrow: selectedLinearElement,
elementsMap: allElementsMap,
appState,
context,
type: "end",
});
}
}
// Paint selected elements
@@ -2398,7 +2398,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "353.93938",
"height": "390.11932",
"id": "id4",
"index": "a2",
"isDeleted": false,
@@ -2412,8 +2412,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"478.03877",
"-353.93938",
"478.03878",
"-390.11932",
],
],
"roughness": 1,
@@ -2424,7 +2424,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -2435,9 +2435,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow",
"updated": 1,
"version": 12,
"width": "478.03877",
"width": "478.03878",
"x": 11,
"y": "-45.14705",
"y": "-8.96692",
}
`;
@@ -2566,7 +2566,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "353.93938",
"height": "390.11932",
"index": "a2",
"isDeleted": false,
"link": null,
@@ -2578,8 +2578,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0,
],
[
"478.03877",
"-353.93938",
"478.03878",
"-390.11932",
],
],
"roughness": 1,
@@ -2590,7 +2590,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"startBinding": {
"elementId": "id0",
"fixedPoint": [
"0.50010",
1,
"0.50010",
],
"mode": "orbit",
@@ -2600,9 +2600,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2,
"type": "arrow",
"version": 12,
"width": "478.03877",
"width": "478.03878",
"x": 11,
"y": "-45.14705",
"y": "-8.96692",
},
"inserted": {
"isDeleted": true,
@@ -8743,9 +8743,11 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor {
"customLineAngle": null,
"draggedFocusPointBinding": null,
"elbowed": false,
"elementId": "id0",
"hoverPointIndex": -1,
"hoveredFocusPointBinding": null,
"initialState": {
"altFocusPoint": null,
"arrowStartIsInside": false,
@@ -8976,9 +8978,11 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor {
"customLineAngle": null,
"draggedFocusPointBinding": null,
"elbowed": false,
"elementId": "id0",
"hoverPointIndex": -1,
"hoveredFocusPointBinding": null,
"initialState": {
"altFocusPoint": null,
"arrowStartIsInside": false,
@@ -9402,9 +9406,11 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor {
"customLineAngle": null,
"draggedFocusPointBinding": null,
"elbowed": false,
"elementId": "id0",
"hoverPointIndex": -1,
"hoveredFocusPointBinding": null,
"initialState": {
"altFocusPoint": null,
"arrowStartIsInside": false,
@@ -9818,9 +9824,11 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor {
"customLineAngle": null,
"draggedFocusPointBinding": null,
"elbowed": false,
"elementId": "id0",
"hoverPointIndex": -1,
"hoveredFocusPointBinding": null,
"initialState": {
"altFocusPoint": null,
"arrowStartIsInside": false,
+1 -1
View File
@@ -5051,7 +5051,7 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([0.5001, 0.5001]),
fixedPoint: expect.arrayContaining([1, 0.5001]),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
+2
View File
@@ -63,6 +63,8 @@ import type { isOverScrollBars } from "./scene/scrollbars";
import type React from "react";
import type { JSX } from "react";
export type { App };
export type SocketId = string & { _brand: "SocketId" };
export type Collaborator = Readonly<{