From b23768719d91e2815b34d110147f02b3cc7fe0e1 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Tue, 9 Sep 2025 13:50:05 +0200 Subject: [PATCH] fix: New highlight overdraws arrow Signed-off-by: Mark Tolmacs --- packages/element/src/renderElement.ts | 220 +++++++----------- packages/excalidraw/components/App.tsx | 11 +- .../components/canvases/InteractiveCanvas.tsx | 4 + .../excalidraw/renderer/interactiveScene.ts | 140 ++++++++++- packages/excalidraw/types.ts | 5 + 5 files changed, 235 insertions(+), 145 deletions(-) diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 06f79b09a..befda2966 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -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) { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index bf41c6232..ba262c3ac 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -917,12 +917,11 @@ class App extends React.Component { }); }); - 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; diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 1a7b3b865..21ecbfef3 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -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 = ( diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 776d43fd5..d73323413 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -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 = ( ); }; +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, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 8a6704548..ee4d365c8 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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"]; } >;