diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index a5e050869e..a7a4d7ffb3 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -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, ) => { @@ -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) diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 9c1de59206..7f42cbcad6 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -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) { diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts index a267636af8..3204c4a6e7 100644 --- a/packages/excalidraw/renderer/helpers.ts +++ b/packages/excalidraw/renderer/helpers.ts @@ -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(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(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(); +}; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index da26e01718..0469ace23a 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -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 = ( ); }; -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,