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 = ( const shouldResetImageFilter = (
element: ExcalidrawElement, element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => { ) => {
return ( return (
appState.theme === THEME.DARK && appState.theme === THEME.DARK &&
@@ -217,7 +217,7 @@ const generateElementCanvas = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom, zoom: Zoom,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
): ExcalidrawElementWithCanvas | null => { ): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
@@ -549,7 +549,7 @@ const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => { ) => {
const zoom: Zoom = renderConfig const zoom: Zoom = renderConfig
? appState.zoom ? appState.zoom
@@ -602,54 +602,13 @@ const generateElementWithCanvas = (
return prevElementWithCanvas; 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 = ( const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas, elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
allElementsMap: NonDeletedSceneElementsMap, allElementsMap: NonDeletedSceneElementsMap,
) => { ) => {
const isHighlighted =
appState.suggestedBinding?.id === elementWithCanvas.element.id;
if (
!isHighlighted ||
["image", "text"].includes(elementWithCanvas.element.type)
) {
const element = elementWithCanvas.element; const element = elementWithCanvas.element;
const padding = getCanvasPadding(element); const padding = getCanvasPadding(element);
const zoom = elementWithCanvas.scale; const zoom = elementWithCanvas.scale;
@@ -732,11 +691,6 @@ const drawElementFromCanvas = (
context.restore(); context.restore();
// Clear the nested element we appended to the DOM // Clear the nested element we appended to the DOM
}
if (isHighlighted) {
drawElementHighlight(context, appState);
}
}; };
export const renderSelectionElement = ( export const renderSelectionElement = (
@@ -770,7 +724,7 @@ export const renderElement = (
rc: RoughCanvas, rc: RoughCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => { ) => {
const reduceAlphaForSelection = const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" && appState.openDialog?.name === "elementLinkSelector" &&
@@ -789,11 +743,6 @@ export const renderElement = (
case "magicframe": case "magicframe":
case "frame": { case "frame": {
if (appState.frameRendering.enabled && appState.frameRendering.outline) { 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.save();
context.translate( context.translate(
element.x + appState.scrollX, element.x + appState.scrollX,
@@ -802,17 +751,12 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)"; context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = isHighlighted context.strokeStyle = FRAME_STYLE.strokeColor;
? highlightStroke
: FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames // TODO change later to only affect AI frames
if (isMagicFrameElement(element)) { if (isMagicFrameElement(element)) {
context.strokeStyle = isHighlighted context.strokeStyle =
? highlightStroke appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
: appState.theme === THEME.LIGHT
? "#7affd7"
: "#1d8264";
} }
if (FRAME_STYLE.radius && context.roundRect) { if (FRAME_STYLE.radius && context.roundRect) {

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import rough from "roughjs/bin/rough";
import { import {
pointFrom, pointFrom,
pointsEqual, pointsEqual,
@@ -16,7 +17,11 @@ import {
throttleRAF, throttleRAF,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { LinearElementEditor } from "@excalidraw/element"; import {
LinearElementEditor,
renderElement,
ShapeCache,
} from "@excalidraw/element";
import { import {
getOmitSidesForDevice, getOmitSidesForDevice,
getTransformHandles, getTransformHandles,
@@ -50,6 +55,7 @@ import type {
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
ExcalidrawImageElement, ExcalidrawImageElement,
@@ -57,6 +63,7 @@ import type {
ExcalidrawTextElement, ExcalidrawTextElement,
GroupId, GroupId,
NonDeleted, NonDeleted,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { renderSnaps } from "../renderer/renderSnaps"; import { renderSnaps } from "../renderer/renderSnaps";
@@ -66,6 +73,7 @@ import {
SCROLLBAR_COLOR, SCROLLBAR_COLOR,
SCROLLBAR_WIDTH, SCROLLBAR_WIDTH,
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { type InteractiveCanvasAppState } from "../types"; import { type InteractiveCanvasAppState } from "../types";
import { getClientColor, renderRemoteCursors } from "../clients"; 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 = { type ElementSelectionBorder = {
angle: number; angle: number;
x1: number; x1: number;
@@ -707,6 +835,16 @@ const _renderInteractiveScene = ({
} }
} }
if (appState.isBindingEnabled && appState.suggestedBinding) {
renderBindingHighlightForBindableElement(
context,
appState.suggestedBinding,
elementsMap,
allElementsMap,
appState,
);
}
if (appState.frameToHighlight) { if (appState.frameToHighlight) {
renderFrameHighlight( renderFrameHighlight(
context, context,

View File

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