feat: Blue highlight

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2025-11-05 20:32:03 +01:00
parent d429164f3c
commit a6c633c040
2 changed files with 169 additions and 377 deletions

View File

@@ -1,26 +1,5 @@
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";
@@ -126,304 +105,3 @@ export const strokeRectWithRotation_simple = (
}
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

@@ -23,23 +23,17 @@ import {
deconstructDiamondElement,
deconstructRectanguloidElement,
elementCenterPoint,
FIXED_BINDING_DISTANCE,
LinearElementEditor,
maxBindingGap_simple,
} from "@excalidraw/element";
import {
getOmitSidesForEditorInterface,
getTransformHandles,
getTransformHandlesFromCoords,
hasBoundingBox,
} from "@excalidraw/element";
import {
isElbowArrow,
isFrameLikeElement,
isImageElement,
isLinearElement,
isLineElement,
isTextElement,
LinearElementEditor,
} from "@excalidraw/element";
import { renderSelectionElement } from "@excalidraw/element";
@@ -88,11 +82,8 @@ import { getClientColor, renderRemoteCursors } from "../clients";
import {
bootstrapCanvas,
drawHighlightForDiamondWithRotation_simple,
drawHighlightForRectWithRotation_simple,
fillCircle,
getNormalizedCanvasDimensions,
strokeEllipseWithRotation_simple,
strokeRectWithRotation_simple,
} from "./helpers";
@@ -201,58 +192,181 @@ const renderBindingHighlightForBindableElement_simple = (
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
appState: InteractiveCanvasAppState,
) => {
const padding = maxBindingGap_simple(
element,
element.width,
element.height,
zoom,
);
const enclosingFrame = element.frameId && elementsMap.get(element.frameId);
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
context.translate(
enclosingFrame.x + appState.scrollX,
enclosingFrame.y + appState.scrollY,
);
context.fillStyle = "rgba(0,0,0,.05)";
context.beginPath();
if (FRAME_STYLE.radius && context.roundRect) {
context.roundRect(
-1,
-1,
enclosingFrame.width + 1,
enclosingFrame.height + 1,
FRAME_STYLE.radius / appState.zoom.value,
);
} else {
context.rect(-1, -1, enclosingFrame.width + 1, enclosingFrame.height + 1);
}
context.clip();
context.translate(
-(enclosingFrame.x + appState.scrollX),
-(enclosingFrame.y + appState.scrollY),
);
}
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;
case "frame":
context.save();
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,
context.translate(
element.x + appState.scrollX,
element.y + appState.scrollY,
);
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, 1)`
: `rgba(106, 189, 252, 1)`;
if (FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
element.width,
element.height,
FRAME_STYLE.radius / appState.zoom.value,
);
context.stroke();
context.closePath();
} else {
context.strokeRect(0, 0, element.width, element.height);
}
context.restore();
break;
default:
context.save();
const center = elementCenterPoint(element, elementsMap);
context.translate(center[0], center[1]);
context.rotate(element.angle as Radians);
context.translate(-center[0], -center[1]);
context.translate(element.x, element.y);
context.lineWidth =
clamp(2.5, element.strokeWidth * 1.75, 4) /
Math.max(0.25, appState.zoom.value);
context.strokeStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, 1)`
: `rgba(106, 189, 252, 1)`;
switch (element.type) {
case "ellipse":
context.beginPath();
context.ellipse(
element.width / 2,
element.height / 2,
element.width / 2,
element.height / 2,
0,
0,
2 * Math.PI,
);
context.closePath();
context.stroke();
break;
case "diamond":
{
const [segments, curves] = deconstructDiamondElement(element);
// Draw each line segment individually
segments.forEach((segment) => {
context.beginPath();
context.moveTo(
segment[0][0] - element.x,
segment[0][1] - element.y,
);
context.lineTo(
segment[1][0] - element.x,
segment[1][1] - element.y,
);
context.stroke();
});
// Draw each curve individually (for rounded corners)
curves.forEach((curve) => {
const [start, control1, control2, end] = curve;
context.beginPath();
context.moveTo(start[0] - element.x, start[1] - element.y);
context.bezierCurveTo(
control1[0] - element.x,
control1[1] - element.y,
control2[0] - element.x,
control2[1] - element.y,
end[0] - element.x,
end[1] - element.y,
);
context.stroke();
});
}
break;
default:
{
const [segments, curves] = deconstructRectanguloidElement(element);
// Draw each line segment individually
segments.forEach((segment) => {
context.beginPath();
context.moveTo(
segment[0][0] - element.x,
segment[0][1] - element.y,
);
context.lineTo(
segment[1][0] - element.x,
segment[1][1] - element.y,
);
context.stroke();
});
// Draw each curve individually (for rounded corners)
curves.forEach((curve) => {
const [start, control1, control2, end] = curve;
context.beginPath();
context.moveTo(start[0] - element.x, start[1] - element.y);
context.bezierCurveTo(
control1[0] - element.x,
control1[1] - element.y,
control2[0] - element.x,
control2[1] - element.y,
end[0] - element.x,
end[1] - element.y,
);
context.stroke();
});
}
break;
}
context.restore();
break;
}
}
};
@@ -550,7 +664,7 @@ const renderBindingHighlightForBindableElement = (
context,
element,
allElementsMap,
appState.zoom,
appState,
);
context.restore();
};