fix: New highlight overdraws arrow

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2025-09-09 13:50:05 +02:00
parent fce13ccefd
commit b23768719d
5 changed files with 235 additions and 145 deletions

View File

@@ -90,7 +90,7 @@ const isPendingImageElement = (
const shouldResetImageFilter = (
element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
return (
appState.theme === THEME.DARK &&
@@ -217,7 +217,7 @@ const generateElementCanvas = (
elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
@@ -549,7 +549,7 @@ const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
const zoom: Zoom = renderConfig
? appState.zoom
@@ -602,141 +602,95 @@ const generateElementWithCanvas = (
return prevElementWithCanvas;
};
const drawElementHighlight = (
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState,
) => {
if (appState.suggestedBinding) {
const cx =
(appState.suggestedBinding.x +
appState.suggestedBinding.width / 2 +
appState.scrollX) *
window.devicePixelRatio;
const cy =
(appState.suggestedBinding.y +
appState.suggestedBinding.height / 2 +
appState.scrollY) *
window.devicePixelRatio;
context.save();
context.translate(cx, cy);
context.rotate(appState.suggestedBinding.angle);
context.translate(-cx, -cy);
context.translate(
appState.scrollX + appState.suggestedBinding.x,
appState.scrollY + appState.suggestedBinding.y,
);
const drawable = ShapeCache.generateBindableElementHighlight(
appState.suggestedBinding,
appState,
);
rough.canvas(context.canvas).draw(drawable);
context.restore();
}
};
const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
allElementsMap: NonDeletedSceneElementsMap,
) => {
const isHighlighted =
appState.suggestedBinding?.id === elementWithCanvas.element.id;
if (
!isHighlighted ||
["image", "text"].includes(elementWithCanvas.element.type)
) {
const element = elementWithCanvas.element;
const padding = getCanvasPadding(element);
const zoom = elementWithCanvas.scale;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
const element = elementWithCanvas.element;
const padding = getCanvasPadding(element);
const zoom = elementWithCanvas.scale;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
context.save();
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
context.save();
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
const boundTextElement = getBoundTextElement(element, allElementsMap);
const boundTextElement = getBoundTextElement(element, allElementsMap);
if (isArrowElement(element) && boundTextElement) {
const offsetX =
(elementWithCanvas.boundTextCanvas.width -
elementWithCanvas.canvas!.width) /
2;
const offsetY =
(elementWithCanvas.boundTextCanvas.height -
elementWithCanvas.canvas!.height) /
2;
context.translate(cx, cy);
context.drawImage(
elementWithCanvas.boundTextCanvas,
(-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
(-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
elementWithCanvas.boundTextCanvas.width / zoom,
elementWithCanvas.boundTextCanvas.height / zoom,
if (isArrowElement(element) && boundTextElement) {
const offsetX =
(elementWithCanvas.boundTextCanvas.width -
elementWithCanvas.canvas!.width) /
2;
const offsetY =
(elementWithCanvas.boundTextCanvas.height -
elementWithCanvas.canvas!.height) /
2;
context.translate(cx, cy);
context.drawImage(
elementWithCanvas.boundTextCanvas,
(-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
(-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
elementWithCanvas.boundTextCanvas.width / zoom,
elementWithCanvas.boundTextCanvas.height / zoom,
);
} else {
// we translate context to element center so that rotation and scale
// originates from the element center
context.translate(cx, cy);
context.rotate(element.angle);
if (
"scale" in elementWithCanvas.element &&
!isPendingImageElement(element, renderConfig)
) {
context.scale(
elementWithCanvas.element.scale[0],
elementWithCanvas.element.scale[1],
);
} else {
// we translate context to element center so that rotation and scale
// originates from the element center
context.translate(cx, cy);
context.rotate(element.angle);
if (
"scale" in elementWithCanvas.element &&
!isPendingImageElement(element, renderConfig)
) {
context.scale(
elementWithCanvas.element.scale[0],
elementWithCanvas.element.scale[1],
);
}
// revert afterwards we don't have account for it during drawing
context.translate(-cx, -cy);
context.drawImage(
elementWithCanvas.canvas!,
(x1 + appState.scrollX) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
(y1 + appState.scrollY) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
);
if (
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
"true" &&
hasBoundTextElement(element)
) {
const textElement = getBoundTextElement(
element,
allElementsMap,
) as ExcalidrawTextElementWithContainer;
const coords = getContainerCoords(element);
context.strokeStyle = "#c92a2a";
context.lineWidth = 3;
context.strokeRect(
(coords.x + appState.scrollX) * window.devicePixelRatio,
(coords.y + appState.scrollY) * window.devicePixelRatio,
getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio,
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
);
}
}
context.restore();
// Clear the nested element we appended to the DOM
}
// revert afterwards we don't have account for it during drawing
context.translate(-cx, -cy);
if (isHighlighted) {
drawElementHighlight(context, appState);
context.drawImage(
elementWithCanvas.canvas!,
(x1 + appState.scrollX) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
(y1 + appState.scrollY) * window.devicePixelRatio -
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
);
if (
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
"true" &&
hasBoundTextElement(element)
) {
const textElement = getBoundTextElement(
element,
allElementsMap,
) as ExcalidrawTextElementWithContainer;
const coords = getContainerCoords(element);
context.strokeStyle = "#c92a2a";
context.lineWidth = 3;
context.strokeRect(
(coords.x + appState.scrollX) * window.devicePixelRatio,
(coords.y + appState.scrollY) * window.devicePixelRatio,
getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio,
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
);
}
}
context.restore();
// Clear the nested element we appended to the DOM
};
export const renderSelectionElement = (
@@ -770,7 +724,7 @@ export const renderElement = (
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => {
const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" &&
@@ -789,11 +743,6 @@ export const renderElement = (
case "magicframe":
case "frame": {
if (appState.frameRendering.enabled && appState.frameRendering.outline) {
const isHighlighted = element.id === appState.suggestedBinding?.id;
const {
options: { stroke: highlightStroke },
} = ShapeCache.generateBindableElementHighlight(element, appState);
context.save();
context.translate(
element.x + appState.scrollX,
@@ -802,17 +751,12 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = isHighlighted
? highlightStroke
: FRAME_STYLE.strokeColor;
context.strokeStyle = FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
context.strokeStyle = isHighlighted
? highlightStroke
: appState.theme === THEME.LIGHT
? "#7affd7"
: "#1d8264";
context.strokeStyle =
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
}
if (FRAME_STYLE.radius && context.roundRect) {

View File

@@ -917,12 +917,11 @@ class App extends React.Component<AppProps, AppState> {
});
});
if (this.lastPointerMoveCoords) {
invariant(
this.state.selectedLinearElement,
"Selected element is missing",
);
if (
this.lastPointerMoveCoords &&
this.state.selectedLinearElement?.selectedPointsIndices &&
this.state.selectedLinearElement?.selectedPointsIndices.length
) {
const { x, y } = this.lastPointerMoveCoords;
const event =
this.lastPointerMoveEvent ?? this.lastPointerDownEvent?.nativeEvent;

View File

@@ -215,6 +215,10 @@ const getRelevantAppStateProps = (
croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches,
activeLockedId: appState.activeLockedId,
hoveredElementIds: appState.hoveredElementIds,
frameRendering: appState.frameRendering,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
exportScale: appState.exportScale,
});
const areEqual = (

View File

@@ -1,3 +1,4 @@
import rough from "roughjs/bin/rough";
import {
pointFrom,
pointsEqual,
@@ -16,7 +17,11 @@ import {
throttleRAF,
} from "@excalidraw/common";
import { LinearElementEditor } from "@excalidraw/element";
import {
LinearElementEditor,
renderElement,
ShapeCache,
} from "@excalidraw/element";
import {
getOmitSidesForDevice,
getTransformHandles,
@@ -50,6 +55,7 @@ import type {
import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
ExcalidrawImageElement,
@@ -57,6 +63,7 @@ import type {
ExcalidrawTextElement,
GroupId,
NonDeleted,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import { renderSnaps } from "../renderer/renderSnaps";
@@ -66,6 +73,7 @@ import {
SCROLLBAR_COLOR,
SCROLLBAR_WIDTH,
} from "../scene/scrollbars";
import { type InteractiveCanvasAppState } from "../types";
import { getClientColor, renderRemoteCursors } from "../clients";
@@ -178,6 +186,126 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
);
};
const renderBindingHighlightForBindableElement = (
context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement,
elementsMap: RenderableElementsMap,
allElementsMap: NonDeletedSceneElementsMap,
appState: InteractiveCanvasAppState,
) => {
switch (element.type) {
case "magicframe":
case "frame":
{
const {
options: { stroke: highlightStroke },
} = ShapeCache.generateBindableElementHighlight(element, appState);
context.save();
context.translate(
element.x + appState.scrollX,
element.y + appState.scrollY,
);
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = highlightStroke;
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;
case "image":
case "text":
{
const {
options: { stroke: highlightStroke },
} = ShapeCache.generateBindableElementHighlight(element, appState);
context.save();
context.translate(
element.x + appState.scrollX,
element.y + appState.scrollY,
);
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = highlightStroke;
context.strokeRect(0, 0, element.width, element.height);
context.restore();
}
break;
default:
const cx =
(element.x + element.width / 2 + appState.scrollX) *
window.devicePixelRatio;
const cy =
(element.y + element.height / 2 + appState.scrollY) *
window.devicePixelRatio;
context.save();
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-cx, -cy);
context.translate(
appState.scrollX + element.x,
appState.scrollY + element.y,
);
const drawable = ShapeCache.generateBindableElementHighlight(
element,
appState,
);
drawable.options.fill = "transparent";
rough.canvas(context.canvas).draw(drawable);
context.restore();
// Overdraw the arrow if exists (if there is a suggestedBinding it's an arrow)
if (appState.selectedLinearElement) {
const arrow = LinearElementEditor.getElement(
appState.selectedLinearElement.elementId,
allElementsMap,
);
invariant(arrow, "arrow not found during highlight element binding");
renderElement(
arrow,
elementsMap,
allElementsMap,
rough.canvas(context.canvas),
context,
{
canvasBackgroundColor: "transparent",
imageCache: new Map(),
renderGrid: false,
isExporting: false,
embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(),
pendingFlowchartNodes: null,
},
appState,
);
}
break;
}
};
type ElementSelectionBorder = {
angle: number;
x1: number;
@@ -707,6 +835,16 @@ const _renderInteractiveScene = ({
}
}
if (appState.isBindingEnabled && appState.suggestedBinding) {
renderBindingHighlightForBindableElement(
context,
appState.suggestedBinding,
elementsMap,
allElementsMap,
appState,
);
}
if (appState.frameToHighlight) {
renderFrameHighlight(
context,

View File

@@ -234,6 +234,11 @@ export type InteractiveCanvasAppState = Readonly<
// Search matches
searchMatches: AppState["searchMatches"];
activeLockedId: AppState["activeLockedId"];
// Non-used but needed in binding highlight arrow overdraw
hoveredElementIds: AppState["hoveredElementIds"];
frameRendering: AppState["frameRendering"];
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
exportScale: AppState["exportScale"];
}
>;