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