feat: Binding highlight band re-added

This commit is contained in:
Mark Tolmacs
2025-10-31 17:53:10 +01:00
parent 03e29cc0b7
commit 465c645240
4 changed files with 460 additions and 10 deletions

View File

@@ -104,10 +104,33 @@ export type BindingStrategy =
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const getFixedBindingDistance = (
element: ExcalidrawBindableElement,
): number => FIXED_BINDING_DISTANCE + element.strokeWidth / 2;
export const maxBindingGap_simple = (
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);
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 + FIXED_BINDING_DISTANCE,
);
};
export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLElement>,
) => {
@@ -626,7 +649,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
globalPoint,
elements,
elementsMap,
(e) => 100, // TODO: Zoom-level
(e) => maxBindingGap_simple(e, e.width, e.height, appState.zoom),
);
const current: BindingStrategy = hit
? isPointInElement(globalPoint, hit, elementsMap)

View File

@@ -2126,7 +2126,11 @@ const pointDraggingUpdates = (
),
};
if (startIsDragged) {
if (
startIsDragged &&
(updates.startBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS"))
) {
updates.suggestedBinding = start.element;
}
} else if (startIsDragged) {
@@ -2148,7 +2152,11 @@ const pointDraggingUpdates = (
),
};
if (endIsDragged && updates.endBinding.mode === "orbit") {
if (
endIsDragged &&
(updates.endBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS"))
) {
updates.suggestedBinding = end.element;
}
} else if (endIsDragged) {

View File

@@ -1,5 +1,26 @@
import { THEME, THEME_FILTER } from "@excalidraw/common";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element";
import { getDiamondPoints } from "@excalidraw/element";
import { elementCenterPoint, getCornerRadius } from "@excalidraw/element";
import {
curve,
curveCatmullRomCubicApproxPoints,
curveCatmullRomQuadraticApproxPoints,
curveOffsetPoints,
type GlobalPoint,
offsetPointsForQuadraticBezier,
pointFrom,
pointRotateRads,
} from "@excalidraw/math";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawRectanguloidElement,
} from "@excalidraw/element/types";
import type { StaticCanvasRenderConfig } from "../scene/types";
import type { AppState, StaticCanvasAppState } from "../types";
@@ -76,7 +97,7 @@ export const bootstrapCanvas = ({
return context;
};
export const strokeRectWithRotation = (
export const strokeRectWithRotation_simple = (
context: CanvasRenderingContext2D,
x: number,
y: number,
@@ -105,3 +126,304 @@ export const strokeRectWithRotation = (
}
context.restore();
};
function drawCatmullRomQuadraticApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
tension = 0.5,
) {
const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length - 1; i++) {
const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
}
}
}
function drawCatmullRomCubicApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
tension = 0.5,
) {
const pointSets = curveCatmullRomCubicApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length; i++) {
const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i];
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
}
}
}
export const drawHighlightForRectWithRotation_simple = (
context: CanvasRenderingContext2D,
element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
padding: number,
) => {
const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element, elementsMap),
element.angle,
);
context.save();
context.translate(x, y);
context.rotate(element.angle);
let radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
if (radius === 0) {
radius = 0.01;
}
context.beginPath();
{
const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, 0 + radius),
pointFrom(0, 0),
pointFrom(0 + radius, 0),
padding,
);
const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, 0),
pointFrom(element.width, 0),
pointFrom(element.width, radius),
padding,
);
const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, element.height - radius),
pointFrom(element.width, element.height),
pointFrom(element.width - radius, element.height),
padding,
);
const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(radius, element.height),
pointFrom(0, element.height),
pointFrom(0, element.height - radius),
padding,
);
context.moveTo(
topLeftApprox[topLeftApprox.length - 1][0],
topLeftApprox[topLeftApprox.length - 1][1],
);
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topRightApprox);
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topLeftApprox);
}
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
// mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees.
{
const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0 + radius, 0),
pointFrom(0, 0),
pointFrom(0, 0 + radius),
-FIXED_BINDING_DISTANCE,
);
const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, radius),
pointFrom(element.width, 0),
pointFrom(element.width - radius, 0),
-FIXED_BINDING_DISTANCE,
);
const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, element.height),
pointFrom(element.width, element.height),
pointFrom(element.width, element.height - radius),
-FIXED_BINDING_DISTANCE,
);
const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, element.height - radius),
pointFrom(0, element.height),
pointFrom(radius, element.height),
-FIXED_BINDING_DISTANCE,
);
context.moveTo(
topLeftApprox[topLeftApprox.length - 1][0],
topLeftApprox[topLeftApprox.length - 1][1],
);
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topRightApprox);
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topLeftApprox);
}
context.closePath();
context.fill();
context.restore();
};
export const strokeEllipseWithRotation_simple = (
context: CanvasRenderingContext2D,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
) => {
context.beginPath();
context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
context.stroke();
};
export const drawHighlightForDiamondWithRotation_simple = (
context: CanvasRenderingContext2D,
padding: number,
element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
) => {
const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element, elementsMap),
element.angle,
);
context.save();
context.translate(x, y);
context.rotate(element.angle);
{
context.beginPath();
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = curveOffsetPoints(
curve(
pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
),
padding,
);
const rightApprox = curveOffsetPoints(
curve(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
),
padding,
);
const bottomApprox = curveOffsetPoints(
curve(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
),
padding,
);
const leftApprox = curveOffsetPoints(
curve(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
),
padding,
);
context.moveTo(
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
// mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees.
{
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = curveOffsetPoints(
curve(
pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
const rightApprox = curveOffsetPoints(
curve(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
const bottomApprox = curveOffsetPoints(
curve(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
const leftApprox = curveOffsetPoints(
curve(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
context.moveTo(
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
context.closePath();
context.fill();
context.restore();
};

View File

@@ -13,6 +13,7 @@ import {
BIND_MODE_TIMEOUT,
DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE,
getFeatureFlag,
invariant,
THEME,
throttleRAF,
@@ -22,7 +23,9 @@ import {
deconstructDiamondElement,
deconstructRectanguloidElement,
elementCenterPoint,
FIXED_BINDING_DISTANCE,
LinearElementEditor,
maxBindingGap_simple,
} from "@excalidraw/element";
import {
getOmitSidesForDevice,
@@ -85,9 +88,12 @@ import { getClientColor, renderRemoteCursors } from "../clients";
import {
bootstrapCanvas,
drawHighlightForDiamondWithRotation_simple,
drawHighlightForRectWithRotation_simple,
fillCircle,
getNormalizedCanvasDimensions,
strokeRectWithRotation,
strokeEllipseWithRotation_simple,
strokeRectWithRotation_simple,
} from "./helpers";
import type {
@@ -191,7 +197,66 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
);
};
const renderBindingHighlightForBindableElement = (
const renderBindingHighlightForBindableElement_simple = (
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const padding = maxBindingGap_simple(
element,
element.width,
element.height,
zoom,
);
context.fillStyle = "rgba(0,0,0,.05)";
switch (element.type) {
case "rectangle":
case "text":
case "image":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
drawHighlightForRectWithRotation_simple(
context,
element,
elementsMap,
padding,
);
break;
case "diamond":
drawHighlightForDiamondWithRotation_simple(
context,
padding,
element,
elementsMap,
);
break;
case "ellipse": {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = padding - FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation_simple(
context,
width + padding + FIXED_BINDING_DISTANCE,
height + padding + FIXED_BINDING_DISTANCE,
x1 + width / 2,
y1 + height / 2,
element.angle,
);
break;
}
}
};
const renderBindingHighlightForBindableElement_complex = (
app: AppClassProperties,
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
@@ -458,6 +523,38 @@ const renderBindingHighlightForBindableElement = (
};
};
const renderBindingHighlightForBindableElement = (
app: AppClassProperties,
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
allElementsMap: NonDeletedSceneElementsMap,
appState: InteractiveCanvasAppState,
deltaTime: number,
state?: { runtime: number },
) => {
if (getFeatureFlag("COMPLEX_BINDINGS")) {
return renderBindingHighlightForBindableElement_complex(
app,
context,
element,
allElementsMap,
appState,
deltaTime,
state,
);
}
context.save();
context.translate(appState.scrollX, appState.scrollY);
renderBindingHighlightForBindableElement_simple(
context,
element,
allElementsMap,
appState.zoom,
);
context.restore();
};
type ElementSelectionBorder = {
angle: number;
x1: number;
@@ -513,7 +610,7 @@ const renderSelectionBorder = (
]);
}
context.lineDashOffset = (lineWidth + spaceWidth) * index;
strokeRectWithRotation(
strokeRectWithRotation_simple(
context,
x1 - linePadding,
y1 - linePadding,
@@ -542,7 +639,7 @@ const renderFrameHighlight = (
context.save();
context.translate(appState.scrollX, appState.scrollY);
strokeRectWithRotation(
strokeRectWithRotation_simple(
context,
x1,
y1,
@@ -755,7 +852,7 @@ const renderTransformHandles = (
context.fill();
context.stroke();
} else {
strokeRectWithRotation(
strokeRectWithRotation_simple(
context,
x,
y,
@@ -1264,7 +1361,7 @@ const _renderInteractiveScene = ({
const lineWidth = context.lineWidth;
context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = selectionColor;
strokeRectWithRotation(
strokeRectWithRotation_simple(
context,
x1 - dashedLinePadding,
y1 - dashedLinePadding,