Quellcode durchsuchen

feat: Precise highlights for bindings (#9472)

Márk Tolmács vor 3 Monaten
Ursprung
Commit
e19fd1332a

+ 12 - 12
packages/element/src/bounds.ts

@@ -1,17 +1,17 @@
 import rough from "roughjs/bin/rough";
 
 import {
-  rescalePoints,
   arrayToMap,
   invariant,
+  rescalePoints,
   sizeOf,
 } from "@excalidraw/common";
 
 import {
   degreesToRadians,
   lineSegment,
-  pointFrom,
   pointDistance,
+  pointFrom,
   pointFromArray,
   pointRotateRads,
 } from "@excalidraw/math";
@@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
 
 import type { Mutable } from "@excalidraw/common/utility-types";
 
-import { ShapeCache } from "./ShapeCache";
 import { generateRoughOptions } from "./Shape";
+import { ShapeCache } from "./ShapeCache";
 import { LinearElementEditor } from "./linearElementEditor";
 import { getBoundTextElement, getContainerElement } from "./textElement";
 import {
@@ -52,20 +52,20 @@ import {
   deconstructRectanguloidElement,
 } from "./utils";
 
+import type { Drawable, Op } from "roughjs/bin/core";
+import type { Point as RoughPoint } from "roughjs/bin/geometry";
 import type {
-  ExcalidrawElement,
-  ExcalidrawLinearElement,
   Arrowhead,
-  ExcalidrawFreeDrawElement,
-  NonDeleted,
-  ExcalidrawTextElementWithContainer,
   ElementsMap,
-  ExcalidrawRectanguloidElement,
-  ExcalidrawEllipseElement,
   ElementsMapOrArray,
+  ExcalidrawElement,
+  ExcalidrawEllipseElement,
+  ExcalidrawFreeDrawElement,
+  ExcalidrawLinearElement,
+  ExcalidrawRectanguloidElement,
+  ExcalidrawTextElementWithContainer,
+  NonDeleted,
 } from "./types";
-import type { Drawable, Op } from "roughjs/bin/core";
-import type { Point as RoughPoint } from "roughjs/bin/geometry";
 
 export type RectangleBox = {
   x: number;

+ 421 - 2
packages/excalidraw/renderer/helpers.ts

@@ -1,7 +1,30 @@
-import { THEME, THEME_FILTER } from "@excalidraw/common";
+import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common";
+
+import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
+import { getDiamondPoints } from "@excalidraw/element/bounds";
+import { getCornerRadius } from "@excalidraw/element/shapes";
+
+import {
+  bezierEquation,
+  curve,
+  curveTangent,
+  type GlobalPoint,
+  pointFrom,
+  pointFromVector,
+  pointRotateRads,
+  vector,
+  vectorNormal,
+  vectorNormalize,
+  vectorScale,
+} from "@excalidraw/math";
+
+import type {
+  ExcalidrawDiamondElement,
+  ExcalidrawRectanguloidElement,
+} from "@excalidraw/element/types";
 
 import type { StaticCanvasRenderConfig } from "../scene/types";
-import type { StaticCanvasAppState, AppState } from "../types";
+import type { AppState, StaticCanvasAppState } from "../types";
 
 export const fillCircle = (
   context: CanvasRenderingContext2D,
@@ -72,3 +95,399 @@ export const bootstrapCanvas = ({
 
   return context;
 };
+
+function drawCatmullRomQuadraticApprox(
+  ctx: CanvasRenderingContext2D,
+  points: GlobalPoint[],
+  segments = 20,
+) {
+  ctx.lineTo(points[0][0], points[0][1]);
+
+  for (let i = 0; i < points.length - 1; i++) {
+    const p0 = points[i - 1 < 0 ? 0 : i - 1];
+    const p1 = points[i];
+    const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
+
+    for (let t = 0; t <= 1; t += 1 / segments) {
+      const t2 = t * t;
+
+      const x =
+        (1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
+
+      const y =
+        (1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
+
+      ctx.lineTo(x, y);
+    }
+  }
+}
+
+function drawCatmullRomCubicApprox(
+  ctx: CanvasRenderingContext2D,
+  points: GlobalPoint[],
+  segments = 20,
+) {
+  ctx.lineTo(points[0][0], points[0][1]);
+
+  for (let i = 0; i < points.length - 1; i++) {
+    const p0 = points[i - 1 < 0 ? 0 : i - 1];
+    const p1 = points[i];
+    const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
+    const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
+
+    for (let t = 0; t <= 1; t += 1 / segments) {
+      const t2 = t * t;
+      const t3 = t2 * t;
+
+      const x =
+        0.5 *
+        (2 * p1[0] +
+          (-p0[0] + p2[0]) * t +
+          (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
+          (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
+
+      const y =
+        0.5 *
+        (2 * p1[1] +
+          (-p0[1] + p2[1]) * t +
+          (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
+          (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
+
+      ctx.lineTo(x, y);
+    }
+  }
+}
+
+export const drawHighlightForRectWithRotation = (
+  context: CanvasRenderingContext2D,
+  element: ExcalidrawRectanguloidElement,
+  padding: number,
+) => {
+  const [x, y] = pointRotateRads(
+    pointFrom<GlobalPoint>(element.x, element.y),
+    elementCenterPoint(element),
+    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 = offsetQuadraticBezier(
+      pointFrom(0, 0 + radius),
+      pointFrom(0, 0),
+      pointFrom(0 + radius, 0),
+      padding,
+    );
+    const topRightApprox = offsetQuadraticBezier(
+      pointFrom(element.width - radius, 0),
+      pointFrom(element.width, 0),
+      pointFrom(element.width, radius),
+      padding,
+    );
+    const bottomRightApprox = offsetQuadraticBezier(
+      pointFrom(element.width, element.height - radius),
+      pointFrom(element.width, element.height),
+      pointFrom(element.width - radius, element.height),
+      padding,
+    );
+    const bottomLeftApprox = offsetQuadraticBezier(
+      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 = offsetQuadraticBezier(
+      pointFrom(0 + radius, 0),
+      pointFrom(0, 0),
+      pointFrom(0, 0 + radius),
+      -FIXED_BINDING_DISTANCE,
+    );
+    const topRightApprox = offsetQuadraticBezier(
+      pointFrom(element.width, radius),
+      pointFrom(element.width, 0),
+      pointFrom(element.width - radius, 0),
+      -FIXED_BINDING_DISTANCE,
+    );
+    const bottomRightApprox = offsetQuadraticBezier(
+      pointFrom(element.width - radius, element.height),
+      pointFrom(element.width, element.height),
+      pointFrom(element.width, element.height - radius),
+      -FIXED_BINDING_DISTANCE,
+    );
+    const bottomLeftApprox = offsetQuadraticBezier(
+      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 = (
+  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 strokeRectWithRotation = (
+  context: CanvasRenderingContext2D,
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+  cx: number,
+  cy: number,
+  angle: number,
+  fill: boolean = false,
+  /** should account for zoom */
+  radius: number = 0,
+) => {
+  context.save();
+  context.translate(cx, cy);
+  context.rotate(angle);
+  if (fill) {
+    context.fillRect(x - cx, y - cy, width, height);
+  }
+  if (radius && context.roundRect) {
+    context.beginPath();
+    context.roundRect(x - cx, y - cy, width, height, radius);
+    context.stroke();
+    context.closePath();
+  } else {
+    context.strokeRect(x - cx, y - cy, width, height);
+  }
+  context.restore();
+};
+
+export const drawHighlightForDiamondWithRotation = (
+  context: CanvasRenderingContext2D,
+  padding: number,
+  element: ExcalidrawDiamondElement,
+) => {
+  const [x, y] = pointRotateRads(
+    pointFrom<GlobalPoint>(element.x, element.y),
+    elementCenterPoint(element),
+    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 = offsetCubicBezier(
+      pointFrom(topX - verticalRadius, topY + horizontalRadius),
+      pointFrom(topX, topY),
+      pointFrom(topX, topY),
+      pointFrom(topX + verticalRadius, topY + horizontalRadius),
+      padding,
+    );
+    const rightApprox = offsetCubicBezier(
+      pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
+      pointFrom(rightX, rightY),
+      pointFrom(rightX, rightY),
+      pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
+      padding,
+    );
+    const bottomApprox = offsetCubicBezier(
+      pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
+      pointFrom(bottomX, bottomY),
+      pointFrom(bottomX, bottomY),
+      pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
+      padding,
+    );
+    const leftApprox = offsetCubicBezier(
+      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[0][0], rightApprox[0][1]);
+    drawCatmullRomCubicApprox(context, rightApprox);
+    context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
+    drawCatmullRomCubicApprox(context, bottomApprox);
+    context.lineTo(leftApprox[0][0], leftApprox[0][1]);
+    drawCatmullRomCubicApprox(context, leftApprox);
+    context.lineTo(topApprox[0][0], topApprox[0][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 = offsetCubicBezier(
+      pointFrom(topX + verticalRadius, topY + horizontalRadius),
+      pointFrom(topX, topY),
+      pointFrom(topX, topY),
+      pointFrom(topX - verticalRadius, topY + horizontalRadius),
+      -FIXED_BINDING_DISTANCE,
+    );
+    const rightApprox = offsetCubicBezier(
+      pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
+      pointFrom(rightX, rightY),
+      pointFrom(rightX, rightY),
+      pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
+      -FIXED_BINDING_DISTANCE,
+    );
+    const bottomApprox = offsetCubicBezier(
+      pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
+      pointFrom(bottomX, bottomY),
+      pointFrom(bottomX, bottomY),
+      pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
+      -FIXED_BINDING_DISTANCE,
+    );
+    const leftApprox = offsetCubicBezier(
+      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[0][0], leftApprox[0][1]);
+    drawCatmullRomCubicApprox(context, leftApprox);
+    context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
+    drawCatmullRomCubicApprox(context, bottomApprox);
+    context.lineTo(rightApprox[0][0], rightApprox[0][1]);
+    drawCatmullRomCubicApprox(context, rightApprox);
+    context.lineTo(topApprox[0][0], topApprox[0][1]);
+    drawCatmullRomCubicApprox(context, topApprox);
+  }
+  context.closePath();
+  context.fill();
+  context.restore();
+};
+
+function offsetCubicBezier(
+  p0: GlobalPoint,
+  p1: GlobalPoint,
+  p2: GlobalPoint,
+  p3: GlobalPoint,
+  offsetDist: number,
+  steps = 20,
+) {
+  const offsetPoints = [];
+
+  for (let i = 0; i <= steps; i++) {
+    const t = i / steps;
+    const c = curve(p0, p1, p2, p3);
+    const point = bezierEquation(c, t);
+    const tangent = vectorNormalize(curveTangent(c, t));
+    const normal = vectorNormal(tangent);
+
+    offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
+  }
+
+  return offsetPoints;
+}
+
+function offsetQuadraticBezier(
+  p0: GlobalPoint,
+  p1: GlobalPoint,
+  p2: GlobalPoint,
+  offsetDist: number,
+  steps = 20,
+) {
+  const offsetPoints = [];
+
+  for (let i = 0; i <= steps; i++) {
+    const t = i / steps;
+    const t1 = 1 - t;
+    const point = pointFrom<GlobalPoint>(
+      t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
+      t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
+    );
+    const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
+    const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
+    const tangent = vectorNormalize(vector(tangentX, tangentY));
+    const normal = vectorNormal(tangent);
+
+    offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
+  }
+
+  return offsetPoints;
+}

+ 21 - 107
packages/excalidraw/renderer/interactiveScene.ts

@@ -1,23 +1,22 @@
-import oc from "open-color";
 import {
   pointFrom,
   type GlobalPoint,
   type LocalPoint,
   type Radians,
 } from "@excalidraw/math";
+import oc from "open-color";
 
 import {
+  arrayToMap,
   DEFAULT_TRANSFORM_HANDLE_SPACING,
   FRAME_STYLE,
-  THEME,
-  arrayToMap,
   invariant,
+  THEME,
   throttleRAF,
 } from "@excalidraw/common";
 
 import {
-  BINDING_HIGHLIGHT_OFFSET,
-  BINDING_HIGHLIGHT_THICKNESS,
+  FIXED_BINDING_DISTANCE,
   maxBindingGap,
 } from "@excalidraw/element/binding";
 import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
@@ -35,14 +34,12 @@ import {
   isTextElement,
 } from "@excalidraw/element/typeChecks";
 
-import { getCornerRadius } from "@excalidraw/element/shapes";
-
 import { renderSelectionElement } from "@excalidraw/element/renderElement";
 
 import {
-  isSelectedViaGroup,
-  getSelectedGroupIds,
   getElementsInGroup,
+  getSelectedGroupIds,
+  isSelectedViaGroup,
   selectGroupsFromGivenElements,
 } from "@excalidraw/element/groups";
 
@@ -86,8 +83,12 @@ import { getClientColor, renderRemoteCursors } from "../clients";
 
 import {
   bootstrapCanvas,
+  drawHighlightForDiamondWithRotation,
+  drawHighlightForRectWithRotation,
   fillCircle,
   getNormalizedCanvasDimensions,
+  strokeEllipseWithRotation,
+  strokeRectWithRotation,
 } from "./helpers";
 
 import type {
@@ -160,57 +161,6 @@ const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
   );
 };
 
-const strokeRectWithRotation = (
-  context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
-  width: number,
-  height: number,
-  cx: number,
-  cy: number,
-  angle: number,
-  fill: boolean = false,
-  /** should account for zoom */
-  radius: number = 0,
-) => {
-  context.save();
-  context.translate(cx, cy);
-  context.rotate(angle);
-  if (fill) {
-    context.fillRect(x - cx, y - cy, width, height);
-  }
-  if (radius && context.roundRect) {
-    context.beginPath();
-    context.roundRect(x - cx, y - cy, width, height, radius);
-    context.stroke();
-    context.closePath();
-  } else {
-    context.strokeRect(x - cx, y - cy, width, height);
-  }
-  context.restore();
-};
-
-const strokeDiamondWithRotation = (
-  context: CanvasRenderingContext2D,
-  width: number,
-  height: number,
-  cx: number,
-  cy: number,
-  angle: number,
-) => {
-  context.save();
-  context.translate(cx, cy);
-  context.rotate(angle);
-  context.beginPath();
-  context.moveTo(0, height / 2);
-  context.lineTo(width / 2, 0);
-  context.lineTo(0, -height / 2);
-  context.lineTo(-width / 2, 0);
-  context.closePath();
-  context.stroke();
-  context.restore();
-};
-
 const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
   context: CanvasRenderingContext2D,
   appState: InteractiveCanvasAppState,
@@ -237,19 +187,6 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
   );
 };
 
-const strokeEllipseWithRotation = (
-  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();
-};
-
 const renderBindingHighlightForBindableElement = (
   context: CanvasRenderingContext2D,
   element: ExcalidrawBindableElement,
@@ -261,16 +198,10 @@ const renderBindingHighlightForBindableElement = (
   const height = y2 - y1;
 
   context.strokeStyle = "rgba(0,0,0,.05)";
-  // When zooming out, make line width greater for visibility
-  const zoomValue = zoom.value < 1 ? zoom.value : 1;
-  context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue;
-  // To ensure the binding highlight doesn't overlap the element itself
-  const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
+  context.fillStyle = "rgba(0,0,0,.05)";
 
-  const radius = getCornerRadius(
-    Math.min(element.width, element.height),
-    element,
-  );
+  // To ensure the binding highlight doesn't overlap the element itself
+  const padding = maxBindingGap(element, element.width, element.height, zoom);
 
   switch (element.type) {
     case "rectangle":
@@ -280,37 +211,20 @@ const renderBindingHighlightForBindableElement = (
     case "embeddable":
     case "frame":
     case "magicframe":
-      strokeRectWithRotation(
-        context,
-        x1 - padding,
-        y1 - padding,
-        width + padding * 2,
-        height + padding * 2,
-        x1 + width / 2,
-        y1 + height / 2,
-        element.angle,
-        undefined,
-        radius,
-      );
+      drawHighlightForRectWithRotation(context, element, padding);
       break;
     case "diamond":
-      const side = Math.hypot(width, height);
-      const wPadding = (padding * side) / height;
-      const hPadding = (padding * side) / width;
-      strokeDiamondWithRotation(
-        context,
-        width + wPadding * 2,
-        height + hPadding * 2,
-        x1 + width / 2,
-        y1 + height / 2,
-        element.angle,
-      );
+      drawHighlightForDiamondWithRotation(context, padding, element);
       break;
     case "ellipse":
+      context.lineWidth =
+        maxBindingGap(element, element.width, element.height, zoom) -
+        FIXED_BINDING_DISTANCE;
+
       strokeEllipseWithRotation(
         context,
-        width + padding * 2,
-        height + padding * 2,
+        width + padding + FIXED_BINDING_DISTANCE,
+        height + padding + FIXED_BINDING_DISTANCE,
         x1 + width / 2,
         y1 + height / 2,
         element.angle,

+ 22 - 1
packages/math/src/curve.ts

@@ -2,6 +2,7 @@ import type { Bounds } from "@excalidraw/element/bounds";
 
 import { isPoint, pointDistance, pointFrom } from "./point";
 import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
+import { vector } from "./vector";
 
 import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
 
@@ -82,7 +83,7 @@ function solve(
   return [t0, s0];
 }
 
-const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
+export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
   c: Curve<Point>,
   t: number,
 ) =>
@@ -274,6 +275,26 @@ export function isCurve<P extends GlobalPoint | LocalPoint>(
   );
 }
 
+export function curveTangent<Point extends GlobalPoint | LocalPoint>(
+  [p0, p1, p2, p3]: Curve<Point>,
+  t: number,
+) {
+  return vector(
+    -3 * (1 - t) * (1 - t) * p0[0] +
+      3 * (1 - t) * (1 - t) * p1[0] -
+      6 * t * (1 - t) * p1[0] -
+      3 * t * t * p2[0] +
+      6 * t * (1 - t) * p2[0] +
+      3 * t * t * p3[0],
+    -3 * (1 - t) * (1 - t) * p0[1] +
+      3 * (1 - t) * (1 - t) * p1[1] -
+      6 * t * (1 - t) * p1[1] -
+      3 * t * t * p2[1] +
+      6 * t * (1 - t) * p2[1] +
+      3 * t * t * p3[1],
+  );
+}
+
 function curveBounds<Point extends GlobalPoint | LocalPoint>(
   c: Curve<Point>,
 ): Bounds {

+ 5 - 0
packages/math/src/vector.ts

@@ -143,3 +143,8 @@ export const vectorNormalize = (v: Vector): Vector => {
 
   return vector(v[0] / m, v[1] / m);
 };
+
+/**
+ * Calculate the right-hand normal of the vector.
+ */
+export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);