diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index d0c071f2c8..ff02e285b2 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -14,6 +14,14 @@ import { pointDistance, pointFromArray, pointRotateRads, + bezierEquation, + curve, + curveTangent, + vectorNormalize, + vectorNormal, + vectorScale, + pointFromVector, + vector, } from "@excalidraw/math"; import { getCurvePathOps } from "@excalidraw/utils/shape"; @@ -1146,3 +1154,53 @@ export const doBoundsIntersect = ( return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; }; + +export 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; +} + +export 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 69c6a8196e..8c5f58ea17 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1,6 +1,7 @@ import oc from "open-color"; import { pointFrom, + pointRotateRads, type GlobalPoint, type LocalPoint, type Radians, @@ -11,13 +12,13 @@ import { FRAME_STYLE, THEME, arrayToMap, + elementCenterPoint, invariant, 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"; @@ -48,7 +49,10 @@ import { import { getCommonBounds, + getDiamondPoints, getElementAbsoluteCoords, + offsetCubicBezier, + offsetQuadraticBezier, } from "@excalidraw/element/bounds"; import type { @@ -64,10 +68,12 @@ import type { import type { ElementsMap, ExcalidrawBindableElement, + ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawFrameLikeElement, ExcalidrawImageElement, ExcalidrawLinearElement, + ExcalidrawRectanguloidElement, ExcalidrawTextElement, GroupId, NonDeleted, @@ -160,6 +166,120 @@ const highlightPoint = ( ); }; +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(); +}; + const strokeRectWithRotation = ( context: CanvasRenderingContext2D, x: number, @@ -190,24 +310,130 @@ const strokeRectWithRotation = ( context.restore(); }; -const strokeDiamondWithRotation = ( +const drawHighlightForDiamondWithRotation = ( context: CanvasRenderingContext2D, - width: number, - height: number, - cx: number, - cy: number, - angle: number, + padding: number, + element: ExcalidrawDiamondElement, ) => { + const [x, y] = pointRotateRads( + pointFrom(element.x, element.y), + elementCenterPoint(element), + element.angle, + ); 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.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.stroke(); + context.fill(); context.restore(); }; @@ -261,16 +487,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 +500,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, @@ -1241,3 +1444,65 @@ export const renderInteractiveScene = < renderConfig.callback(ret); return ret as T extends true ? void : ReturnType; }; + +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); + } + } +} diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index a79fb43a19..ec2d1afcd6 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 2467220674..12682fcd9f 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]);