mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
fix: make arrow binding area adapt to zoom levels (#8927)
* make binding area adapt to zoom * revert stroke color * normalize binding gap * reduce normalized gap
This commit is contained in:
parent
873698a1a2
commit
1e3399eac8
14 changed files with 247 additions and 119 deletions
|
@ -40,7 +40,6 @@ import {
|
|||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
isRectangularElement,
|
||||
isTextElement,
|
||||
|
@ -97,6 +96,8 @@ export const isBindingEnabled = (appState: AppState): boolean => {
|
|||
};
|
||||
|
||||
export const FIXED_BINDING_DISTANCE = 5;
|
||||
export const BINDING_HIGHLIGHT_THICKNESS = 10;
|
||||
export const BINDING_HIGHLIGHT_OFFSET = 4;
|
||||
|
||||
const getNonDeletedElements = (
|
||||
scene: Scene,
|
||||
|
@ -213,6 +214,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
edge: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
): NonDeleted<ExcalidrawElement> | null => {
|
||||
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||
const elementId =
|
||||
|
@ -223,7 +225,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||
const element = elementsMap.get(elementId);
|
||||
if (
|
||||
isBindableElement(element) &&
|
||||
bindingBorderTest(element, coors, elementsMap)
|
||||
bindingBorderTest(element, coors, elementsMap, zoom)
|
||||
) {
|
||||
return element;
|
||||
}
|
||||
|
@ -235,12 +237,14 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
||||
["start", "end"].map((edge) =>
|
||||
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
||||
linearElement,
|
||||
edge as "start" | "end",
|
||||
elementsMap,
|
||||
zoom,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -250,6 +254,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||
draggingPoints: readonly number[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
zoom?: AppState["zoom"],
|
||||
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||
const startIdx = 0;
|
||||
const endIdx = selectedElement.points.length - 1;
|
||||
|
@ -262,6 +267,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||
"start",
|
||||
elementsMap,
|
||||
elements,
|
||||
zoom,
|
||||
)
|
||||
: null // If binding is disabled and start is dragged, break all binds
|
||||
: // We have to update the focus and gap of the binding, so let's rebind
|
||||
|
@ -270,6 +276,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||
"start",
|
||||
elementsMap,
|
||||
elements,
|
||||
zoom,
|
||||
);
|
||||
const end = endDragged
|
||||
? isBindingEnabled
|
||||
|
@ -278,6 +285,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||
"end",
|
||||
elementsMap,
|
||||
elements,
|
||||
zoom,
|
||||
)
|
||||
: null // If binding is disabled and end is dragged, break all binds
|
||||
: // We have to update the focus and gap of the binding, so let's rebind
|
||||
|
@ -286,6 +294,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||
"end",
|
||||
elementsMap,
|
||||
elements,
|
||||
zoom,
|
||||
);
|
||||
|
||||
return [start, end];
|
||||
|
@ -296,10 +305,12 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
isBindingEnabled: boolean,
|
||||
zoom?: AppState["zoom"],
|
||||
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
|
||||
selectedElement,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
const start = startIsClose
|
||||
? isBindingEnabled
|
||||
|
@ -308,6 +319,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||
"start",
|
||||
elementsMap,
|
||||
elements,
|
||||
zoom,
|
||||
)
|
||||
: null
|
||||
: null;
|
||||
|
@ -318,6 +330,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||
"end",
|
||||
elementsMap,
|
||||
elements,
|
||||
zoom,
|
||||
)
|
||||
: null
|
||||
: null;
|
||||
|
@ -332,6 +345,7 @@ export const bindOrUnbindLinearElements = (
|
|||
scene: Scene,
|
||||
isBindingEnabled: boolean,
|
||||
draggingPoints: readonly number[] | null,
|
||||
zoom?: AppState["zoom"],
|
||||
): void => {
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
const [start, end] = draggingPoints?.length
|
||||
|
@ -342,6 +356,7 @@ export const bindOrUnbindLinearElements = (
|
|||
draggingPoints ?? [],
|
||||
elementsMap,
|
||||
elements,
|
||||
zoom,
|
||||
)
|
||||
: // The arrow itself (the shaft) or the inner joins are dragged
|
||||
getBindingStrategyForDraggingArrowOrJoints(
|
||||
|
@ -349,6 +364,7 @@ export const bindOrUnbindLinearElements = (
|
|||
elementsMap,
|
||||
elements,
|
||||
isBindingEnabled,
|
||||
zoom,
|
||||
);
|
||||
|
||||
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
|
||||
|
@ -358,6 +374,7 @@ export const bindOrUnbindLinearElements = (
|
|||
export const getSuggestedBindingsForArrows = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
): SuggestedBinding[] => {
|
||||
// HOT PATH: Bail out if selected elements list is too large
|
||||
if (selectedElements.length > 50) {
|
||||
|
@ -368,7 +385,7 @@ export const getSuggestedBindingsForArrows = (
|
|||
selectedElements
|
||||
.filter(isLinearElement)
|
||||
.flatMap((element) =>
|
||||
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
|
||||
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom),
|
||||
)
|
||||
.filter(
|
||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||
|
@ -406,6 +423,7 @@ export const maybeBindLinearElement = (
|
|||
pointerCoords,
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
isElbowArrow(linearElement) && isElbowArrow(linearElement),
|
||||
);
|
||||
|
||||
|
@ -422,6 +440,26 @@ export const maybeBindLinearElement = (
|
|||
}
|
||||
};
|
||||
|
||||
const normalizePointBinding = (
|
||||
binding: { focus: number; gap: number },
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
) => {
|
||||
let gap = binding.gap;
|
||||
const maxGap = maxBindingGap(
|
||||
hoveredElement,
|
||||
hoveredElement.width,
|
||||
hoveredElement.height,
|
||||
);
|
||||
|
||||
if (gap > maxGap) {
|
||||
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
|
||||
}
|
||||
return {
|
||||
...binding,
|
||||
gap,
|
||||
};
|
||||
};
|
||||
|
||||
export const bindLinearElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
|
@ -433,11 +471,14 @@ export const bindLinearElement = (
|
|||
}
|
||||
const binding: PointBinding = {
|
||||
elementId: hoveredElement.id,
|
||||
...calculateFocusAndGap(
|
||||
linearElement,
|
||||
...normalizePointBinding(
|
||||
calculateFocusAndGap(
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
),
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
),
|
||||
...(isElbowArrow(linearElement)
|
||||
? calculateFixedPointForElbowArrowBinding(
|
||||
|
@ -462,6 +503,12 @@ export const bindLinearElement = (
|
|||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// update bound elements to make sure the binding tips are in sync with
|
||||
// the normalized gap from above
|
||||
if (!isElbowArrow(linearElement)) {
|
||||
updateBoundElements(hoveredElement, elementsMap);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't bind both ends of a simple segment
|
||||
|
@ -514,6 +561,7 @@ export const getHoveredElementForBinding = (
|
|||
},
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
fullShape?: boolean,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const hoveredElement = getElementAtPosition(
|
||||
|
@ -524,11 +572,13 @@ export const getHoveredElementForBinding = (
|
|||
element,
|
||||
pointerCoords,
|
||||
elementsMap,
|
||||
zoom,
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
fullShape && !isFrameLikeElement(element),
|
||||
fullShape,
|
||||
),
|
||||
);
|
||||
|
||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||
};
|
||||
|
||||
|
@ -578,9 +628,11 @@ export const updateBoundElements = (
|
|||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||
newSize?: { width: number; height: number };
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) => {
|
||||
const { newSize, simultaneouslyUpdated, changedElements } = options ?? {};
|
||||
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
|
||||
options ?? {};
|
||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||
simultaneouslyUpdated,
|
||||
);
|
||||
|
@ -670,6 +722,7 @@ export const updateBoundElements = (
|
|||
},
|
||||
{
|
||||
changedElements,
|
||||
zoom,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -703,6 +756,7 @@ export const getHeadingForElbowArrowSnap = (
|
|||
aabb: Bounds | undefined | null,
|
||||
elementsMap: ElementsMap,
|
||||
origPoint: GlobalPoint,
|
||||
zoom?: AppState["zoom"],
|
||||
): Heading => {
|
||||
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
|
||||
|
||||
|
@ -714,6 +768,7 @@ export const getHeadingForElbowArrowSnap = (
|
|||
origPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
|
||||
if (!distance) {
|
||||
|
@ -737,6 +792,7 @@ const getDistanceForBinding = (
|
|||
point: Readonly<GlobalPoint>,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
const distance = distanceToBindableElement(
|
||||
bindableElement,
|
||||
|
@ -747,6 +803,7 @@ const getDistanceForBinding = (
|
|||
bindableElement,
|
||||
bindableElement.width,
|
||||
bindableElement.height,
|
||||
zoom,
|
||||
);
|
||||
|
||||
return distance > bindDistance ? null : distance;
|
||||
|
@ -1174,11 +1231,13 @@ const getElligibleElementForBindingElement = (
|
|||
startOrEnd: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
zoom?: AppState["zoom"],
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
return getHoveredElementForBinding(
|
||||
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
|
||||
elements,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1341,9 +1400,11 @@ export const bindingBorderTest = (
|
|||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
{ x, y }: { x: number; y: number },
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
fullShape?: boolean,
|
||||
): boolean => {
|
||||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
||||
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
return (
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||
|
@ -1356,12 +1417,21 @@ export const maxBindingGap = (
|
|||
element: ExcalidrawElement,
|
||||
elementWidth: number,
|
||||
elementHeight: number,
|
||||
zoom?: AppState["zoom"],
|
||||
): number => {
|
||||
const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
|
||||
|
||||
// Aligns diamonds with rectangles
|
||||
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
|
||||
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
|
||||
// We make the bindable boundary bigger for bigger elements
|
||||
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
|
||||
|
||||
return Math.max(
|
||||
16,
|
||||
// bigger bindable boundary for bigger elements
|
||||
Math.min(0.25 * smallerDimension, 32),
|
||||
// keep in sync with the zoomed highlight
|
||||
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
|
||||
);
|
||||
};
|
||||
|
||||
export const distanceToBindableElement = (
|
||||
|
|
|
@ -448,6 +448,7 @@ export class LinearElementEditor {
|
|||
),
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
)
|
||||
: null;
|
||||
|
||||
|
@ -787,6 +788,7 @@ export class LinearElementEditor {
|
|||
scenePointer,
|
||||
elements,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
),
|
||||
};
|
||||
|
||||
|
@ -911,6 +913,7 @@ export class LinearElementEditor {
|
|||
element,
|
||||
[points.length - 1],
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
);
|
||||
}
|
||||
return {
|
||||
|
@ -964,6 +967,7 @@ export class LinearElementEditor {
|
|||
element,
|
||||
[{ point: newPoint }],
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
);
|
||||
}
|
||||
return {
|
||||
|
@ -1218,6 +1222,7 @@ export class LinearElementEditor {
|
|||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointIndices: readonly number[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) {
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
@ -1260,6 +1265,7 @@ export class LinearElementEditor {
|
|||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
targetPoints: { point: LocalPoint }[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) {
|
||||
const offsetX = 0;
|
||||
const offsetY = 0;
|
||||
|
@ -1285,6 +1291,7 @@ export class LinearElementEditor {
|
|||
options?: {
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) {
|
||||
const { points } = element;
|
||||
|
@ -1337,6 +1344,7 @@ export class LinearElementEditor {
|
|||
false,
|
||||
),
|
||||
changedElements: options?.changedElements,
|
||||
zoom: options?.zoom,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -1451,6 +1459,7 @@ export class LinearElementEditor {
|
|||
options?: {
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) {
|
||||
if (isElbowArrow(element)) {
|
||||
|
@ -1487,6 +1496,7 @@ export class LinearElementEditor {
|
|||
bindings,
|
||||
{
|
||||
isDragging: options?.isDragging,
|
||||
zoom: options?.zoom,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
import BinaryHeap from "../binaryheap";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { aabbForElement, pointInsideBounds } from "../shapes";
|
||||
import type { AppState } from "../types";
|
||||
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
|
||||
import {
|
||||
bindPointToSnapToElementOutline,
|
||||
|
@ -79,6 +80,7 @@ export const mutateElbowArrow = (
|
|||
options?: {
|
||||
isDragging?: boolean;
|
||||
informMutation?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) => {
|
||||
const update = updateElbowArrow(
|
||||
|
@ -112,6 +114,7 @@ export const updateElbowArrow = (
|
|||
isDragging?: boolean;
|
||||
disableBinding?: boolean;
|
||||
informMutation?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
|
||||
const origStartGlobalPoint: GlobalPoint = pointTranslate(
|
||||
|
@ -136,7 +139,12 @@ export const updateElbowArrow = (
|
|||
arrow.endBinding &&
|
||||
getBindableElementForId(arrow.endBinding.elementId, elementsMap);
|
||||
const [hoveredStartElement, hoveredEndElement] = options?.isDragging
|
||||
? getHoveredElements(origStartGlobalPoint, origEndGlobalPoint, elementsMap)
|
||||
? getHoveredElements(
|
||||
origStartGlobalPoint,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
)
|
||||
: [startElement, endElement];
|
||||
const startGlobalPoint = getGlobalPoint(
|
||||
arrow.startBinding?.fixedPoint,
|
||||
|
@ -1072,6 +1080,7 @@ const getHoveredElements = (
|
|||
origStartGlobalPoint: GlobalPoint,
|
||||
origEndGlobalPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
// TODO: Might be a performance bottleneck and the Map type
|
||||
// remembers the insertion order anyway...
|
||||
|
@ -1084,12 +1093,14 @@ const getHoveredElements = (
|
|||
tupleToCoors(origStartGlobalPoint),
|
||||
elements,
|
||||
nonDeletedSceneElementsMap,
|
||||
zoom,
|
||||
true,
|
||||
),
|
||||
getHoveredElementForBinding(
|
||||
tupleToCoors(origEndGlobalPoint),
|
||||
elements,
|
||||
nonDeletedSceneElementsMap,
|
||||
zoom,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue