From 216ba26a450ca4bb5bc2df1b5c3fc4438204a3f1 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Thu, 21 Aug 2025 20:55:45 +0200 Subject: [PATCH] fix: Refactored timeout bind mode handling fix: Center when orbiting feat: Color change on highlight --- packages/element/src/binding.ts | 20 +- packages/element/src/linearElementEditor.ts | 5 +- packages/element/src/renderElement.ts | 15 +- packages/excalidraw/components/App.tsx | 404 ++++++++---------- packages/excalidraw/package.json | 6 +- packages/excalidraw/renderer/helpers.ts | 214 ---------- .../excalidraw/renderer/interactiveScene.ts | 51 +-- yarn.lock | 10 + 8 files changed, 211 insertions(+), 514 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 758a59502a..c3bcd72868 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -415,7 +415,9 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = ( end = { mode: isInsideBinding ? "inside" : "orbit", element: hit, - focusPoint: point, + focusPoint: isInsideBinding + ? point + : snapToCenter(hit, elementsMap, point), }; } else { end = { mode: null }; @@ -437,7 +439,6 @@ const bindingStrategyForSimpleArrowEndpointDragging = ( elements: readonly Ordered[], globalBindMode?: AppState["bindMode"], opts?: { - newArrow?: boolean; appState?: AppState; }, ): { current: BindingStrategy; other: BindingStrategy } => { @@ -493,12 +494,7 @@ const bindingStrategyForSimpleArrowEndpointDragging = ( current = { element: hit, mode: "orbit", - focusPoint: opts?.newArrow - ? pointFrom( - hit.x + hit.width / 2, - hit.y + hit.height / 2, - ) - : point, + focusPoint: snapToCenter(hit, elementsMap, point), }; return { current, other }; @@ -509,9 +505,7 @@ const bindingStrategyForSimpleArrowEndpointDragging = ( current = { element: hit, mode: "orbit", - focusPoint: opts?.newArrow - ? pointFrom(hit.x + hit.width / 2, hit.y + hit.height / 2) - : point, + focusPoint: snapToCenter(hit, elementsMap, point), }; } @@ -624,7 +618,7 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = ( elementsMap, elements, globalBindMode, - opts, + { appState }, ); return { start: current, end: other }; @@ -645,7 +639,7 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = ( elementsMap, elements, globalBindMode, - opts, + { appState }, ); return { start: other, end: current }; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index b1236d9eb0..b7e5925293 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -49,6 +49,7 @@ import { getOutlineAvoidingPoint, isBindingEnabled, maybeSuggestBindingsForBindingElementAtCoords, + snapToCenter, } from "./binding"; import { getElementAbsoluteCoords, @@ -2037,7 +2038,7 @@ const pointDraggingUpdates = ( newGlobalPointPosition = getOutlineAvoidingPoint( element, hoveredElement, - newGlobalPointPosition, + snapToCenter(hoveredElement, elementsMap, newGlobalPointPosition), pointIndex, elementsMap, customIntersector, @@ -2127,7 +2128,7 @@ const pointDraggingUpdates = ( const newGlobalPointPosition = getOutlineAvoidingPoint( element, hoveredElement, - focusGlobalPoint, + snapToCenter(hoveredElement, elementsMap, focusGlobalPoint), pointIndex, elementsMap, ); diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 008d6afc4a..3c2d994095 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -269,7 +269,7 @@ const generateElementCanvas = ( context.filter = IMAGE_INVERT_FILTER; } - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); context.restore(); @@ -404,7 +404,6 @@ const drawElementOnCanvas = ( rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, ) => { switch (element.type) { case "rectangle": @@ -795,7 +794,7 @@ export const renderElement = ( context.translate(cx, cy); context.rotate(element.angle); context.translate(-shiftX, -shiftY); - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); context.restore(); } else { const elementWithCanvas = generateElementWithCanvas( @@ -888,13 +887,7 @@ export const renderElement = ( tempCanvasContext.translate(-shiftX, -shiftY); - drawElementOnCanvas( - element, - tempRc, - tempCanvasContext, - renderConfig, - appState, - ); + drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig); tempCanvasContext.translate(shiftX, shiftY); @@ -933,7 +926,7 @@ export const renderElement = ( } context.translate(-shiftX, -shiftY); - drawElementOnCanvas(element, rc, context, renderConfig, appState); + drawElementOnCanvas(element, rc, context, renderConfig); } context.restore(); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d78f5ac50f..619604897c 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -243,7 +243,6 @@ import { bindOrUnbindBindingElement, getBindingStrategyForDraggingBindingElementEndpoints, getStartGlobalEndLocalPointsForSimpleArrowBinding, - snapToCenter, mutateElement, } from "@excalidraw/element"; @@ -787,7 +786,10 @@ class App extends React.Component { // } // if (newState && Object.hasOwn(newState, "selectedLinearElement")) { - // console.trace(!!newState.selectedLinearElement); + // //console.trace(!!newState.selectedLinearElement); + // if (!newState.selectedLinearElement?.selectedPointsIndices?.length) { + // console.trace(newState.selectedLinearElement?.selectedPointsIndices); + // } // } // super.setState(newState, callback); @@ -865,6 +867,130 @@ class App extends React.Component { } } + private handleSkipBindMode() { + if (this.state.bindMode === "orbit") { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + this.setState({ + bindMode: "orbit", + }); + } + } + + private resetDelayedBindMode() { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + if (this.state.bindMode !== "orbit") { + // We need this iteration to complete binding and change + // back to orbit mode after that + setTimeout(() => + this.setState({ + bindMode: "orbit", + }), + ); + } + } + + private handleDelayedBindModeChange( + arrow: ExcalidrawArrowElement, + hoveredElement: NonDeletedExcalidrawElement | null, + ) { + if (isElbowArrow(arrow)) { + return; + } + + const effector = () => { + this.bindModeHandler = null; + + invariant( + this.lastPointerMoveCoords, + "Expected lastPointerMoveCoords to be set", + ); + + if (!this.state.selectedLinearElement?.selectedPointsIndices?.length) { + return; + } + + const startDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes(0); + const endDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes( + arrow.points.length - 1, + ); + if ((!startDragged && !endDragged) || (startDragged && endDragged)) { + return; + } + + const { x, y } = this.lastPointerMoveCoords; + const hoveredElement = getHoveredElementForBinding( + pointFrom(x, y), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); + + if (hoveredElement) { + flushSync(() => { + invariant( + this.state.selectedLinearElement?.elementId === arrow.id, + "The selectedLinearElement is expected to not change while a bind mode timeout is ticking", + ); + + // Change the global binding mode + this.setState({ + bindMode: "inside", + selectedLinearElement: { + ...this.state.selectedLinearElement, + pointerDownState: { + ...this.state.selectedLinearElement.pointerDownState, + arrowStartIsInside: true, + }, + }, + }); + + // Make the arrow endpoint "jump" to the cursor + const point = LinearElementEditor.createPointAt( + arrow, + this.scene.getNonDeletedElementsMap(), + x, + y, + isBindingEnabled(this.state) ? this.getEffectiveGridSize() : null, + ); + this.scene.mutateElement(arrow, { + points: startDragged + ? [point, ...arrow.points.slice(1)] + : [...arrow.points.slice(0, -1), point], + }); + }); + } + }; + + if (!hoveredElement) { + // Clear the timeout if we're not hovering a bindable + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + // Clear the inside binding mode too + if (this.state.bindMode !== "orbit") { + flushSync(() => { + this.setState({ + bindMode: "orbit", + }); + }); + } + } else if (!this.bindModeHandler) { + // We are hovering a bindable element + this.bindModeHandler = setTimeout(effector, BIND_MODE_TIMEOUT); + } + } + private cacheEmbeddableRef( element: ExcalidrawIframeLikeElement, ref: HTMLIFrameElement | null, @@ -4430,16 +4556,8 @@ class App extends React.Component { } // Handle Alt key for bind mode - if (event.key === KEYS.ALT && this.state.bindMode === "orbit") { - // Cancel any pending bind mode timer - if (this.bindModeHandler) { - clearTimeout(this.bindModeHandler); - this.bindModeHandler = null; - } - // Immediately switch to skip bind mode - this.setState({ - bindMode: "skip", - }); + if (event.key === KEYS.ALT) { + this.handleSkipBindMode(); } if (this.actionManager.handleKeyDown(event)) { @@ -4451,10 +4569,7 @@ class App extends React.Component { } if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { - if (this.bindModeHandler) { - clearTimeout(this.bindModeHandler); - this.bindModeHandler = null; - } + this.resetDelayedBindMode(); this.setState({ isBindingEnabled: false }); } @@ -4767,15 +4882,15 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - if (hoveredElement && !this.bindModeHandler) { - this.bindModeHandler = setTimeout(() => { - if (hoveredElement) { - this.setState({ - bindMode: "inside", - }); - } - this.bindModeHandler = null; - }, BIND_MODE_TIMEOUT); + if (this.state.selectedLinearElement) { + const element = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + this.scene.getNonDeletedElementsMap(), + ); + + if (isBindingElement(element)) { + this.handleDelayedBindModeChange(element, hoveredElement); + } } } } @@ -5916,6 +6031,12 @@ class App extends React.Component { ) => { this.savePointer(event.clientX, event.clientY, this.state.cursorButton); this.lastPointerMoveEvent = event.nativeEvent; + const scenePointer = viewportCoordsToSceneCoords(event, this.state); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; if (gesture.pointers.has(event.pointerId)) { gesture.pointers.set(event.pointerId, { @@ -6004,13 +6125,6 @@ class App extends React.Component { } } - const scenePointer = viewportCoordsToSceneCoords(event, this.state); - const { x: scenePointerX, y: scenePointerY } = scenePointer; - this.lastPointerMoveCoords = { - x: scenePointerX, - y: scenePointerY, - }; - if ( !this.state.newElement && isActiveToolNonLinearSnappable(this.state.activeTool.type) @@ -6202,54 +6316,7 @@ class App extends React.Component { elementsMap, ); - // Timed bind mode handler for arrow elements - if (this.state.bindMode === "orbit") { - if (this.bindModeHandler && !hoveredElement) { - clearTimeout(this.bindModeHandler); - this.bindModeHandler = null; - } else if (!this.bindModeHandler && hoveredElement) { - this.bindModeHandler = setTimeout(() => { - if (hoveredElement) { - flushSync(() => { - this.setState({ - bindMode: "inside", - selectedLinearElement: this.state.selectedLinearElement - ? { - ...this.state.selectedLinearElement, - pointerDownState: { - ...this.state.selectedLinearElement - .pointerDownState, - arrowStartIsInside: true, - }, - } - : null, - }); - }); - this.scene.mutateElement(multiElement, { - points: [ - ...multiElement.points.slice(0, -1), - pointFrom( - this.lastPointerMoveCoords!.x - multiElement.x, - this.lastPointerMoveCoords!.y - multiElement.y, - ), - ], - }); - } - - this.bindModeHandler = null; - }, BIND_MODE_TIMEOUT); - } - } else if (!hoveredElement) { - if (this.bindModeHandler) { - clearTimeout(this.bindModeHandler); - this.bindModeHandler = null; - } - flushSync(() => { - this.setState({ - bindMode: "orbit", - }); - }); - } + this.handleDelayedBindModeChange(multiElement, hoveredElement); const point = pointFrom( scenePointerX - rx, @@ -6659,6 +6726,13 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + const scenePointer = viewportCoordsToSceneCoords(event, this.state); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; + const target = event.target as HTMLElement; // capture subsequent pointer events to the canvas // this makes other elements non-interactive until pointer up @@ -7083,29 +7157,19 @@ class App extends React.Component { private handleCanvasPointerUp = ( event: React.PointerEvent, ) => { + this.resetDelayedBindMode(); this.removePointer(event); this.lastPointerUpEvent = event; - // Cancel any pending timeout for bind mode change - if (this.state.bindMode === "inside" || this.state.bindMode === "skip") { - if (this.bindModeHandler) { - clearTimeout(this.bindModeHandler); - this.bindModeHandler = null; - } - - // We need this iteration to complete binding and change - // back to orbit mode after that - setTimeout(() => - this.setState({ - bindMode: "orbit", - }), - ); - } - const scenePointer = viewportCoordsToSceneCoords( { clientX: event.clientX, clientY: event.clientY }, this.state, ); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; const clicklength = event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0); @@ -7205,10 +7269,7 @@ class App extends React.Component { * pointerup handlers manually */ private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => { - if (this.bindModeHandler) { - clearTimeout(this.bindModeHandler); - this.bindModeHandler = null; - } + this.resetDelayedBindMode(); this.setState({ bindMode: "orbit", @@ -8337,28 +8398,8 @@ class App extends React.Component { this.state, { newArrow: true }, ); - } - if (isSimpleArrow(element)) { - if (this.bindModeHandler) { - clearTimeout(this.bindModeHandler); - this.bindModeHandler = null; - } - - this.bindModeHandler = setTimeout(() => { - this.setState({ - bindMode: "inside", - selectedLinearElement: this.state.selectedLinearElement - ? { - ...this.state.selectedLinearElement, - pointerDownState: { - ...this.state.selectedLinearElement?.pointerDownState, - arrowStartIsInside: !!boundElement, - }, - } - : null, - }); - }, BIND_MODE_TIMEOUT); + this.handleDelayedBindModeChange(element, boundElement); } this.setState((prevState) => { @@ -8378,6 +8419,7 @@ class App extends React.Component { pointerDownState.origin.y, ), }, + selectedPointsIndices: [1], }; nextSelectedElementIds = makeNextSelectedElementIds( { [element.id]: true }, @@ -8750,7 +8792,6 @@ class App extends React.Component { linearElementEditor.elementId, elementsMap, ); - let [x, y] = [pointerCoords.x, pointerCoords.y]; if (isBindingElement(element)) { const hoveredElement = getHoveredElementForBinding( @@ -8759,122 +8800,19 @@ class App extends React.Component { elementsMap, ); - // Timed bind mode handler for arrow elements - if (this.state.bindMode === "orbit") { - if (this.bindModeHandler && !hoveredElement) { - clearTimeout(this.bindModeHandler); - this.bindModeHandler = null; - } else if (!this.bindModeHandler && hoveredElement) { - this.bindModeHandler = setTimeout(() => { - if (hoveredElement) { - flushSync(() => { - this.setState({ - bindMode: "inside", - selectedLinearElement: this.state.selectedLinearElement - ? { - ...this.state.selectedLinearElement, - pointerDownState: { - ...this.state.selectedLinearElement - .pointerDownState, - arrowStartIsInside: true, - }, - } - : null, - }); - }); - - const [lastX, lastY] = - hoveredElement && element.startBinding?.mode !== "inside" - ? snapToCenter( - hoveredElement, - elementsMap, - pointFrom( - this.lastPointerMoveCoords?.x ?? - pointerDownState.origin.x, - this.lastPointerMoveCoords?.y ?? - pointerDownState.origin.y, - ), - ) - : [ - this.lastPointerMoveCoords?.x ?? - pointerDownState.origin.x, - this.lastPointerMoveCoords?.y ?? - pointerDownState.origin.y, - ]; - - const newState = LinearElementEditor.handlePointDragging( - event, - this, - lastX, - lastY, - linearElementEditor, - ); - if (newState) { - pointerDownState.lastCoords.x = - this.lastPointerMoveCoords?.x ?? - pointerDownState.origin.x; - pointerDownState.lastCoords.y = - this.lastPointerMoveCoords?.y ?? - pointerDownState.origin.y; - pointerDownState.drag.hasOccurred = true; - - flushSync(() => { - this.setState(newState); - }); - } - const selectedPointIndices = - this.state.selectedLinearElement?.selectedPointsIndices; - const nextPoint = pointFrom( - (this.lastPointerMoveCoords?.x ?? - pointerDownState.origin.x) - element.x, - (this.lastPointerMoveCoords?.y ?? - pointerDownState.origin.y) - element.y, - ); - if ( - selectedPointIndices?.length === 1 && - selectedPointIndices[0] === 0 - ) { - this.scene.mutateElement(element, { - points: [nextPoint, ...element.points.slice(1)], - }); - } else { - this.scene.mutateElement(element, { - points: [...element.points.slice(0, -1), nextPoint], - }); - } - } - - this.bindModeHandler = null; - }, BIND_MODE_TIMEOUT); - } - } else if (!hoveredElement) { - flushSync(() => { - this.setState({ - bindMode: "orbit", - }); - }); - } - - [x, y] = - hoveredElement && element.startBinding?.mode !== "inside" - ? snapToCenter( - hoveredElement, - elementsMap, - pointFrom(pointerCoords.x, pointerCoords.y), - ) - : [pointerCoords.x, pointerCoords.y]; + this.handleDelayedBindModeChange(element, hoveredElement); } const newState = LinearElementEditor.handlePointDragging( event, this, - x, - y, + pointerCoords.x, + pointerCoords.y, linearElementEditor, ); if (newState) { - pointerDownState.lastCoords.x = x; - pointerDownState.lastCoords.y = y; + pointerDownState.lastCoords.x = pointerCoords.x; + pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.drag.hasOccurred = true; if ( @@ -9628,7 +9566,6 @@ class App extends React.Component { // just in case, tool changes mid drag, always clean up this.lassoTrail.endPath(); - this.lastPointerMoveCoords = null; SnapCache.setReferenceSnapPoints(null); SnapCache.setVisibleGaps(null); @@ -9680,10 +9617,7 @@ class App extends React.Component { }); } - if (this.bindModeHandler) { - clearTimeout(this.bindModeHandler); - this.bindModeHandler = null; - } + this.resetDelayedBindMode(); this.setState({ selectedElementsAreBeingDragged: false, diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 845efc15c8..454a98fa9f 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -81,14 +81,15 @@ "@braintree/sanitize-url": "6.0.2", "@excalidraw/common": "0.18.0", "@excalidraw/element": "0.18.0", - "@excalidraw/math": "0.18.0", "@excalidraw/laser-pointer": "1.3.1", + "@excalidraw/math": "0.18.0", "@excalidraw/mermaid-to-excalidraw": "1.1.3", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.1.6", "@radix-ui/react-tabs": "1.1.3", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", + "chroma-js": "3.1.2", "clsx": "1.1.1", "cross-env": "7.0.3", "es6-promise-pool": "2.5.0", @@ -97,8 +98,8 @@ "image-blob-reduce": "3.0.1", "jotai": "2.11.0", "jotai-scope": "0.7.2", - "lodash.throttle": "4.1.1", "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", "nanoid": "3.3.3", "open-color": "1.9.1", "pako": "2.0.3", @@ -118,6 +119,7 @@ "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.2.0", + "@types/chroma-js": "3.1.1", "@types/lodash.debounce": "4.0.8", "@types/pako": "2.0.3", "@types/pica": "5.1.3", diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts index 6ae1ee6f48..a267636af8 100644 --- a/packages/excalidraw/renderer/helpers.ts +++ b/packages/excalidraw/renderer/helpers.ts @@ -1,25 +1,5 @@ import { THEME, THEME_FILTER } from "@excalidraw/common"; -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"; @@ -96,119 +76,6 @@ export const bootstrapCanvas = ({ return context; }; -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 = ( - context: CanvasRenderingContext2D, - element: ExcalidrawRectanguloidElement, - 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); - - 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), - 0, - ); - const topRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width - radius, 0), - pointFrom(element.width, 0), - pointFrom(element.width, radius), - 0, - ); - const bottomRightApprox = offsetPointsForQuadraticBezier( - pointFrom(element.width, element.height - radius), - pointFrom(element.width, element.height), - pointFrom(element.width - radius, element.height), - 0, - ); - const bottomLeftApprox = offsetPointsForQuadraticBezier( - pointFrom(radius, element.height), - pointFrom(0, element.height), - pointFrom(0, element.height - radius), - 0, - ); - - 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); - } - - context.closePath(); - context.stroke(); - - context.restore(); -}; - -export const strokeEllipseWithRotation = ( - 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 strokeRectWithRotation = ( context: CanvasRenderingContext2D, x: number, @@ -238,84 +105,3 @@ export const strokeRectWithRotation = ( } context.restore(); }; - -export const drawHighlightForDiamondWithRotation = ( - context: CanvasRenderingContext2D, - 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), - ), - 0, - ); - const rightApprox = curveOffsetPoints( - curve( - pointFrom(rightX - verticalRadius, rightY - horizontalRadius), - pointFrom(rightX, rightY), - pointFrom(rightX, rightY), - pointFrom(rightX - verticalRadius, rightY + horizontalRadius), - ), - 0, - ); - const bottomApprox = curveOffsetPoints( - curve( - pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), - pointFrom(bottomX, bottomY), - pointFrom(bottomX, bottomY), - pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), - ), - 0, - ); - const leftApprox = curveOffsetPoints( - curve( - pointFrom(leftX + verticalRadius, leftY + horizontalRadius), - pointFrom(leftX, leftY), - pointFrom(leftX, leftY), - pointFrom(leftX + verticalRadius, leftY - horizontalRadius), - ), - 0, - ); - - 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); - } - - context.closePath(); - context.stroke(); - context.restore(); -}; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 90e5dc2ba4..19fb6eac42 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1,3 +1,5 @@ +import rough from "roughjs/bin/rough"; + import { pointFrom, pointsEqual, @@ -16,7 +18,7 @@ import { throttleRAF, } from "@excalidraw/common"; -import { LinearElementEditor } from "@excalidraw/element"; +import { LinearElementEditor, ShapeCache } from "@excalidraw/element"; import { getOmitSidesForDevice, getTransformHandles, @@ -42,6 +44,7 @@ import { } from "@excalidraw/element"; import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; +import chroma from "chroma-js"; import type { SuggestedBinding, @@ -78,11 +81,8 @@ import { getClientColor, renderRemoteCursors } from "../clients"; import { bootstrapCanvas, - drawHighlightForDiamondWithRotation, - drawHighlightForRectWithRotation, fillCircle, getNormalizedCanvasDimensions, - strokeEllipseWithRotation, strokeRectWithRotation, } from "./helpers"; @@ -190,42 +190,19 @@ const renderSingleLinearPoint = ( const renderBindingHighlightForBindableElement = ( context: CanvasRenderingContext2D, element: ExcalidrawBindableElement, - elementsMap: ElementsMap, ) => { - context.lineWidth = 1; - context.strokeStyle = element.strokeColor; - context.shadowColor = element.strokeColor; - context.shadowBlur = 10; + context.translate(element.x, element.y); - switch (element.type) { - case "rectangle": - case "text": - case "image": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - drawHighlightForRectWithRotation(context, element, elementsMap); - break; - case "diamond": - drawHighlightForDiamondWithRotation(context, element, elementsMap); - break; - case "ellipse": { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const width = x2 - x1; - const height = y2 - y1; + const rc = rough.canvas(context.canvas); + const drawable = ShapeCache.get(element)!; + const originalColor = drawable.options.stroke; + const originalStrokeWidth = drawable.options.strokeWidth; - strokeEllipseWithRotation( - context, - width, - height, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - } - } + drawable.options.stroke = chroma(drawable.options.stroke).desaturate().hex(); + drawable.options.strokeWidth = drawable.options.strokeWidth * 1.1; + rc.draw(drawable); + drawable.options.stroke = originalColor; + drawable.options.strokeWidth = originalStrokeWidth; }; const renderBindingHighlightForSuggestedPointBinding = ( diff --git a/yarn.lock b/yarn.lock index 446297a280..efaf841e32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2840,6 +2840,11 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.0.tgz#23509ebc1fa32f1b4d50d6a66c4032d5b8eaabdc" integrity sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw== +"@types/chroma-js@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/chroma-js/-/chroma-js-3.1.1.tgz#92cac57fb32d642ce156dbc4c052b5e3a3a25db1" + integrity sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA== + "@types/d3-scale-chromatic@^3.0.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" @@ -4045,6 +4050,11 @@ chownr@^1.1.1: resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== +chroma-js@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-3.1.2.tgz#cfb807045182228574eae5380587cdb830e985d6" + integrity sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg== + chrome-trace-event@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b"