diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index d0c071f2c..a5b91922b 100644 --- a/packages/element/src/bounds.ts +++ b/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 { - ExcalidrawElement, - ExcalidrawLinearElement, - Arrowhead, - ExcalidrawFreeDrawElement, - NonDeleted, - ExcalidrawTextElementWithContainer, - ElementsMap, - ExcalidrawRectanguloidElement, - ExcalidrawEllipseElement, - ElementsMapOrArray, -} from "./types"; import type { Drawable, Op } from "roughjs/bin/core"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; +import type { + Arrowhead, + ElementsMap, + ElementsMapOrArray, + ExcalidrawElement, + ExcalidrawEllipseElement, + ExcalidrawFreeDrawElement, + ExcalidrawLinearElement, + ExcalidrawRectanguloidElement, + ExcalidrawTextElementWithContainer, + NonDeleted, +} from "./types"; export type RectangleBox = { x: number; diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index ae6b52296..6a6dc0c6d 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -10,7 +10,12 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type Scene from "@excalidraw/element/Scene"; import StatsDragInput from "./DragInput"; -import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; +import { + getAtomicUnits, + getStepSizedValue, + isPropertyEditable, + STEP_SIZE, +} from "./utils"; import { getElementsInAtomicUnit, moveElement } from "./utils"; import type { DragInputCallbackType } from "./DragInput"; @@ -26,8 +31,6 @@ interface MultiPositionProps { appState: AppState; } -const STEP_SIZE = 10; - const moveElements = ( property: MultiPositionProps["property"], changeInTopX: number, diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index 523538581..7b590cdf7 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type Scene from "@excalidraw/element/Scene"; import StatsDragInput from "./DragInput"; -import { getStepSizedValue, moveElement } from "./utils"; +import { getStepSizedValue, moveElement, STEP_SIZE } from "./utils"; import type { DragInputCallbackType } from "./DragInput"; import type { AppState } from "../../types"; @@ -24,8 +24,6 @@ interface PositionProps { appState: AppState; } -const STEP_SIZE = 10; - const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ accumulatedChange, instantChange, diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 9aefa5544..769601a46 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -12,6 +12,8 @@ import { isInGroup, } from "@excalidraw/element/groups"; +import { getFrameChildren } from "@excalidraw/element/frame"; + import type { Radians } from "@excalidraw/math"; import type { @@ -36,6 +38,7 @@ export type StatsInputProperty = | "gridStep"; export const SMALLEST_DELTA = 0.01; +export const STEP_SIZE = 10; export const isPropertyEditable = ( element: ExcalidrawElement, @@ -169,6 +172,68 @@ export const moveElement = ( { informMutation: shouldInformMutation, isDragging: false }, ); } + + if (isFrameLikeElement(originalElement)) { + const originalChildren = getFrameChildren( + originalElementsMap, + originalElement.id, + ); + originalChildren.forEach((child) => { + const latestChildElement = elementsMap.get(child.id); + + if (!latestChildElement) { + return; + } + + const [childCX, childCY] = [ + child.x + child.width / 2, + child.y + child.height / 2, + ]; + const [childTopLeftX, childTopLeftY] = pointRotateRads( + pointFrom(child.x, child.y), + pointFrom(childCX, childCY), + child.angle, + ); + + const childNewTopLeftX = Math.round(childTopLeftX + changeInX); + const childNewTopLeftY = Math.round(childTopLeftY + changeInY); + + const [childX, childY] = pointRotateRads( + pointFrom(childNewTopLeftX, childNewTopLeftY), + pointFrom(childCX + changeInX, childCY + changeInY), + -child.angle as Radians, + ); + + scene.mutateElement( + latestChildElement, + { + x: childX, + y: childY, + }, + { informMutation: shouldInformMutation, isDragging: false }, + ); + updateBindings(latestChildElement, scene, { + simultaneouslyUpdated: originalChildren, + }); + + const boundTextElement = getBoundTextElement( + latestChildElement, + originalElementsMap, + ); + if (boundTextElement) { + const latestBoundTextElement = elementsMap.get(boundTextElement.id); + latestBoundTextElement && + scene.mutateElement( + latestBoundTextElement, + { + x: boundTextElement.x + changeInX, + y: boundTextElement.y + changeInY, + }, + { informMutation: shouldInformMutation, isDragging: false }, + ); + } + }); + } }; export const getAtomicUnits = ( diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts index 765ef4294..726044989 100644 --- a/packages/excalidraw/renderer/helpers.ts +++ b/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(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(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( + 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; +} diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 69c6a8196..dcef13209 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/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 = ( ); }; -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 = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -237,19 +187,6 @@ const renderSingleLinearPoint = ( ); }; -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, diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index a79fb43a1..ec2d1afcd 100644 --- a/packages/math/src/curve.ts +++ b/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 = ( +export const bezierEquation = ( c: Curve, t: number, ) => @@ -274,6 +275,26 @@ export function isCurve

( ); } +export function curveTangent( + [p0, p1, p2, p3]: Curve, + 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( c: Curve, ): Bounds { diff --git a/packages/math/src/vector.ts b/packages/math/src/vector.ts index 246722067..12682fcd9 100644 --- a/packages/math/src/vector.ts +++ b/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]);