fix: Common midpoint handling with render

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-05-08 14:53:35 +00:00
parent 4a161c1764
commit 60f29dc188
2 changed files with 113 additions and 189 deletions
+99 -103
View File
@@ -588,13 +588,13 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo]; return [diagonalOne, diagonalTwo];
}; };
export const getSnapOutlineMidPoint = ( export const getHighlightedMidpointIndex = (
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: AppState["zoom"], zoom: AppState["zoom"],
arrow: { elbowed: boolean }, arrow: { elbowed: boolean },
): GlobalPoint | undefined => { ): number => {
const center = elementCenterPoint(element, elementsMap); const center = elementCenterPoint(element, elementsMap);
const TOLERANCE = 0.05; const TOLERANCE = 0.05;
const maxDistance = maxBindingDistance_simple(zoom) + element.strokeWidth / 2; const maxDistance = maxBindingDistance_simple(zoom) + element.strokeWidth / 2;
@@ -610,9 +610,8 @@ export const getSnapOutlineMidPoint = (
const bindingGap = getBindingGap(element, arrow); const bindingGap = getBindingGap(element, arrow);
// Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < bindingGap) { if (pointDistance(center, nonRotated) < bindingGap) {
return undefined; return -1;
} }
if ( if (
@@ -620,45 +619,25 @@ export const getSnapOutlineMidPoint = (
nonRotated[1] > center[1] - verticalThreshold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// LEFT return 2;
return pointRotateRads(
pointFrom<GlobalPoint>(x - bindingGap, center[1]),
center,
angle,
);
} else if ( } else if (
nonRotated[1] <= y + height / 2 && nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP return 3;
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y - bindingGap),
center,
angle,
);
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// RIGHT return 0;
return pointRotateRads(
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
center,
angle,
);
} else if ( } else if (
nonRotated[1] >= y + height / 2 && nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// DOWN return 1;
return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
center,
angle,
);
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const distance = bindingGap; const distance = bindingGap;
const topLeft = pointFrom<GlobalPoint>( const topLeft = pointFrom<GlobalPoint>(
@@ -678,100 +657,117 @@ export const getSnapOutlineMidPoint = (
y + (3 * height) / 4 + distance, y + (3 * height) / 4 + distance,
); );
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topRight, center, angle);
}
if ( if (
pointDistance(bottomLeft, nonRotated) < pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomLeft, center, angle); return 1;
} }
if ( if (
pointDistance(bottomRight, nonRotated) < pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomRight, center, angle); return 0;
}
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 2;
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return 3;
} }
} }
return undefined; return -1;
} }
const baseMidpoints = getAllMidpoints(element, elementsMap);
for (let i = 0; i < baseMidpoints.length; i++) {
const threshold = i % 2 === 0 ? horizontalThreshold : verticalThreshold;
if (
pointDistance(baseMidpoints[i], point) <= threshold &&
!hitElementItself({
point,
element,
threshold: 0,
elementsMap,
overrideShouldTestInside: true,
})
) {
return i;
}
}
return -1;
};
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
arrow: { elbowed: boolean },
): GlobalPoint | undefined => {
const center = elementCenterPoint(element, elementsMap);
const baseMidpoints = getAllMidpoints(element, elementsMap);
const sideMidpoints = const sideMidpoints =
element.type === "diamond" element.type === "diamond"
? getDiamondBaseCorners(element) ? baseMidpoints.map((midpoint) => {
.map((curve) => { return pointFrom<GlobalPoint>(
const point = bezierEquation(curve, 0.5); midpoint[0] + (midpoint[0] - center[0]) * 0.1,
const rotatedPoint = pointRotateRads(point, center, element.angle); midpoint[1] + (midpoint[1] - center[1]) * 0.1,
);
})
: baseMidpoints;
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]); const idx = getHighlightedMidpointIndex(
}) point,
.map((midpoint) => { element,
return pointFrom<GlobalPoint>( elementsMap,
midpoint[0] + (midpoint[0] - center[0]) * 0.1, zoom,
midpoint[1] + (midpoint[1] - center[1]) * 0.1, arrow,
); );
})
: [
// RIGHT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.angle,
),
// BOTTOM midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
// LEFT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
// TOP midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
];
return sideMidpoints if (idx === -1) {
.map((midpoint, i) => { return undefined;
const threshold = i % 2 === 0 ? horizontalThreshold : verticalThreshold; }
return pointDistance(midpoint, point) <= threshold ? midpoint : undefined; return sideMidpoints[idx];
}) };
.find(
(midpoint) => export const getAllMidpoints = (
midpoint && element: ExcalidrawBindableElement,
!hitElementItself({ elementsMap: ElementsMap,
point, ): GlobalPoint[] => {
element, const center = elementCenterPoint(element, elementsMap);
threshold: 0,
elementsMap, if (element.type === "diamond") {
overrideShouldTestInside: true, return getDiamondBaseCorners(element).map((curve) => {
}), const point = bezierEquation(curve, 0.5);
); return pointRotateRads(point, center, element.angle);
});
}
return [
pointFrom(element.width, element.height / 2),
pointFrom(element.width / 2, element.height),
pointFrom(0, element.height / 2),
pointFrom(element.width / 2, 0),
].map(([x, y]) =>
pointRotateRads(
pointFrom<GlobalPoint>(element.x + x, element.y + y),
center,
element.angle,
),
);
}; };
export const projectFixedPointOntoDiagonal = ( export const projectFixedPointOntoDiagonal = (
@@ -7,7 +7,6 @@ import {
type Radians, type Radians,
bezierEquation, bezierEquation,
pointRotateRads, pointRotateRads,
pointDistance,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
@@ -24,7 +23,8 @@ import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
elementCenterPoint, elementCenterPoint,
getDiamondBaseCorners, getAllMidpoints,
getHighlightedMidpointIndex,
FOCUS_POINT_SIZE, FOCUS_POINT_SIZE,
getOmitSidesForEditorInterface, getOmitSidesForEditorInterface,
getTransformHandles, getTransformHandles,
@@ -38,7 +38,6 @@ import {
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
maxBindingDistance_simple,
isTextElement, isTextElement,
LinearElementEditor, LinearElementEditor,
getActiveTextElement, getActiveTextElement,
@@ -441,94 +440,23 @@ const renderBindingHighlightForBindableElement_simple = (
if (!cursorIsInsideBindable || isElbow) { if (!cursorIsInsideBindable || isElbow) {
context.save(); context.save();
const center = elementCenterPoint(suggestedBinding.element, elementsMap); const midpoints = getAllMidpoints(suggestedBinding.element, elementsMap);
let midpoints: GlobalPoint[]; const highlightedIdx = pointerCoords
if (suggestedBinding.element.type === "diamond") { ? getHighlightedMidpointIndex(
const center = elementCenterPoint( pointerCoords,
suggestedBinding.element, suggestedBinding.element,
elementsMap, elementsMap,
); appState.zoom,
midpoints = getDiamondBaseCorners(suggestedBinding.element).map( { elbowed: isElbow },
(curve) => { )
const point = bezierEquation(curve, 0.5); : -1;
const rotatedPoint = pointRotateRads(
point,
center,
suggestedBinding.element.angle,
);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
},
);
} else {
const basePoints = [
{
x: suggestedBinding.element.width,
y: suggestedBinding.element.height / 2,
}, // RIGHT
{
x: suggestedBinding.element.width / 2,
y: suggestedBinding.element.height,
}, // BOTTOM
{ x: 0, y: suggestedBinding.element.height / 2 }, // LEFT
{ x: suggestedBinding.element.width / 2, y: 0 }, // TOP
];
midpoints = basePoints.map((point) => {
const globalPoint = pointFrom<GlobalPoint>(
point.x + suggestedBinding.element.x,
point.y + suggestedBinding.element.y,
);
const rotatedPoint = pointRotateRads(
globalPoint,
center,
suggestedBinding.element.angle,
);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
});
}
const hoveredMidpoint =
pointerCoords &&
midpoints.reduce(
(
closestIdx: {
idx: number;
distance: number;
},
point,
idx,
) => {
const distance = pointDistance(point, pointerCoords);
if (idx === -1 || distance < closestIdx.distance) {
return { idx, distance };
}
return closestIdx;
},
{
idx: -1,
distance: Infinity,
},
);
const midpointRadius = 4 / appState.zoom.value; const midpointRadius = 4 / appState.zoom.value;
const highlightThreshold =
maxBindingDistance_simple(appState.zoom) +
suggestedBinding.element.strokeWidth / 2;
midpoints.forEach((midpoint, idx) => { midpoints.forEach((midpoint, idx) => {
const isHighlighted = const isHighlighted =
(!cursorIsInsideBindable || isElbow) && highlightedIdx === idx && (!cursorIsInsideBindable || isElbow);
hoveredMidpoint?.idx === idx &&
hoveredMidpoint.distance <= highlightThreshold;
// also render midpoint if cursor close but not highlighted
// (for elbows, always show all points)
const isShown =
!isHighlighted &&
(isElbow ||
(idx === hoveredMidpoint?.idx &&
hoveredMidpoint.distance <= highlightThreshold * 2));
if (isHighlighted) { if (isHighlighted) {
context.fillStyle = context.fillStyle =
@@ -539,7 +467,7 @@ const renderBindingHighlightForBindableElement_simple = (
context.beginPath(); context.beginPath();
context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI); context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI);
context.fill(); context.fill();
} else if (isShown) { } else {
context.fillStyle = context.fillStyle =
appState.theme === THEME.DARK appState.theme === THEME.DARK
? `rgba(0, 0, 0, 0.8)` ? `rgba(0, 0, 0, 0.8)`