diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index be9e6d81c3..52d7bd32c5 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -118,11 +118,13 @@ export interface ExcalidrawElementWithCanvas { canvas: HTMLCanvasElement; theme: AppState["theme"]; scale: number; + angle: number; zoomValue: AppState["zoom"]["value"]; canvasOffsetX: number; canvasOffsetY: number; boundTextElementVersion: number | null; containingFrameOpacity: number; + boundTextCanvas: HTMLCanvasElement; } const cappedElementCanvasSize = ( @@ -182,7 +184,7 @@ const cappedElementCanvasSize = ( const generateElementCanvas = ( element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, + elementsMap: NonDeletedSceneElementsMap, zoom: Zoom, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, @@ -234,8 +236,72 @@ const generateElementCanvas = ( } drawElementOnCanvas(element, rc, context, renderConfig, appState); + context.restore(); + const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextCanvas = document.createElement("canvas"); + const boundTextCanvasContext = boundTextCanvas.getContext("2d")!; + + if (isArrowElement(element) && boundTextElement) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + // Take max dimensions of arrow canvas so that when canvas is rotated + // the arrow doesn't get clipped + const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); + boundTextCanvas.width = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvas.height = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvasContext.translate( + boundTextCanvas.width / 2, + boundTextCanvas.height / 2, + ); + boundTextCanvasContext.rotate(element.angle); + boundTextCanvasContext.drawImage( + canvas!, + -canvas.width / 2, + -canvas.height / 2, + canvas.width, + canvas.height, + ); + + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + elementsMap, + ); + + boundTextCanvasContext.rotate(-element.angle); + const offsetX = (boundTextCanvas.width - canvas!.width) / 2; + const offsetY = (boundTextCanvas.height - canvas!.height) / 2; + const shiftX = + boundTextCanvas.width / 2 - + (boundTextCx - x1) * window.devicePixelRatio * scale - + offsetX - + padding * scale; + + const shiftY = + boundTextCanvas.height / 2 - + (boundTextCy - y1) * window.devicePixelRatio * scale - + offsetY - + padding * scale; + boundTextCanvasContext.translate(-shiftX, -shiftY); + // Clear the bound text area + boundTextCanvasContext.clearRect( + -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + (boundTextElement.width + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + (boundTextElement.height + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + ); + } + return { element, canvas, @@ -248,6 +314,8 @@ const generateElementCanvas = ( getBoundTextElement(element, elementsMap)?.version || null, containingFrameOpacity: getContainingFrame(element, elementsMap)?.opacity || 100, + boundTextCanvas, + angle: element.angle, }; }; @@ -423,7 +491,7 @@ export const elementWithCanvasCache = new WeakMap< const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, + elementsMap: NonDeletedSceneElementsMap, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { @@ -433,8 +501,8 @@ const generateElementWithCanvas = ( prevElementWithCanvas && prevElementWithCanvas.zoomValue !== zoom.value && !appState?.shouldCacheIgnoreZoom; - const boundTextElementVersion = - getBoundTextElement(element, elementsMap)?.version || null; + const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextElementVersion = boundTextElement?.version || null; const containingFrameOpacity = getContainingFrame(element, elementsMap)?.opacity || 100; @@ -444,7 +512,14 @@ const generateElementWithCanvas = ( shouldRegenerateBecauseZoom || prevElementWithCanvas.theme !== appState.theme || prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion || - prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity + prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity || + // since we rotate the canvas when copying from cached canvas, we don't + // regenerate the cached canvas. But we need to in case of labels which are + // cached alongside the arrow, and we want the labels to remain unrotated + // with respect to the arrow. + (isArrowElement(element) && + boundTextElement && + element.angle !== prevElementWithCanvas.angle) ) { const elementWithCanvas = generateElementCanvas( element, @@ -481,75 +556,21 @@ const drawElementFromCanvas = ( const boundTextElement = getBoundTextElement(element, allElementsMap); if (isArrowElement(element) && boundTextElement) { - const tempCanvas = document.createElement("canvas"); - const tempCanvasContext = tempCanvas.getContext("2d")!; - - // Take max dimensions of arrow canvas so that when canvas is rotated - // the arrow doesn't get clipped - const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); - tempCanvas.width = - maxDim * window.devicePixelRatio * zoom + - padding * elementWithCanvas.scale * 10; - tempCanvas.height = - maxDim * window.devicePixelRatio * zoom + - padding * elementWithCanvas.scale * 10; - const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2; - const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2; - - tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2); - tempCanvasContext.rotate(element.angle); - - tempCanvasContext.drawImage( - elementWithCanvas.canvas!, - -elementWithCanvas.canvas.width / 2, - -elementWithCanvas.canvas.height / 2, - elementWithCanvas.canvas.width, - elementWithCanvas.canvas.height, - ); - - const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( - boundTextElement, - allElementsMap, - ); - - tempCanvasContext.rotate(-element.angle); - - // Shift the canvas to the center of the bound text element - const shiftX = - tempCanvas.width / 2 - - (boundTextCx - x1) * window.devicePixelRatio * zoom - - offsetX - - padding * zoom; - - const shiftY = - tempCanvas.height / 2 - - (boundTextCy - y1) * window.devicePixelRatio * zoom - - offsetY - - padding * zoom; - tempCanvasContext.translate(-shiftX, -shiftY); - // Clear the bound text area - tempCanvasContext.clearRect( - -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * - window.devicePixelRatio * - zoom, - -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * - window.devicePixelRatio * - zoom, - (boundTextElement.width + BOUND_TEXT_PADDING * 2) * - window.devicePixelRatio * - zoom, - (boundTextElement.height + BOUND_TEXT_PADDING * 2) * - window.devicePixelRatio * - zoom, - ); - + const offsetX = + (elementWithCanvas.boundTextCanvas.width - + elementWithCanvas.canvas!.width) / + 2; + const offsetY = + (elementWithCanvas.boundTextCanvas.height - + elementWithCanvas.canvas!.height) / + 2; context.translate(cx, cy); context.drawImage( - tempCanvas, + elementWithCanvas.boundTextCanvas, (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding, (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding, - tempCanvas.width / zoom, - tempCanvas.height / zoom, + elementWithCanvas.boundTextCanvas.width / zoom, + elementWithCanvas.boundTextCanvas.height / zoom, ); } else { // we translate context to element center so that rotation and scale @@ -705,7 +726,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, - elementsMap, + allElementsMap, renderConfig, appState, ); @@ -843,7 +864,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, - elementsMap, + allElementsMap, renderConfig, appState, );