From 9581c4552287ac648bbce08c82e3bd51919c4689 Mon Sep 17 00:00:00 2001 From: "Hargobind S. Khalsa" Date: Wed, 14 Jul 2021 04:29:22 -0700 Subject: [PATCH] fix: Prevent gradual canvas misalignment (#3833) Co-authored-by: dwelle --- src/packages/excalidraw/CHANGELOG.md | 8 +++ src/renderer/renderElement.ts | 40 +++++--------- src/renderer/renderScene.ts | 79 ++++++++++------------------ 3 files changed, 48 insertions(+), 79 deletions(-) diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index ad5c304b..177f0ed4 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -11,6 +11,14 @@ The change should be grouped under one of the below section and must contain PR Please add the latest change on the top under the correct section. --> +## Unreleased + +## Excalidraw Library + +### Fixes + +- Prevent gradual misalignment of the canvas due to floating point rounding errors [#3833](https://github.com/excalidraw/excalidraw/pull/3833). + ## 0.9.0 (2021-07-10) ## Excalidraw API diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index cdc95ba0..5605fe99 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -102,8 +102,8 @@ const generateElementCanvas = ( padding * zoom.value * 2; } + context.save(); context.translate(padding * zoom.value, padding * zoom.value); - context.scale( window.devicePixelRatio * zoom.value, window.devicePixelRatio * zoom.value, @@ -112,12 +112,7 @@ const generateElementCanvas = ( const rc = rough.canvas(canvas); drawElementOnCanvas(element, rc, context); - - context.translate(-(padding * zoom.value), -(padding * zoom.value)); - context.scale( - 1 / (window.devicePixelRatio * zoom.value), - 1 / (window.devicePixelRatio * zoom.value), - ); + context.restore(); return { element, canvas, @@ -175,11 +170,9 @@ const drawElementOnCanvas = ( document.body.appendChild(context.canvas); } context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr"); - const font = context.font; + context.save(); context.font = getFontString(element); - const fillStyle = context.fillStyle; context.fillStyle = element.strokeColor; - const textAlign = context.textAlign; context.textAlign = element.textAlign as CanvasTextAlign; // Canvas does not support multiline text by default @@ -199,9 +192,7 @@ const drawElementOnCanvas = ( (index + 1) * lineHeight - verticalOffset, ); } - context.fillStyle = fillStyle; - context.font = font; - context.textAlign = textAlign; + context.restore(); if (shouldTemporarilyAttach) { context.canvas.remove(); } @@ -518,6 +509,7 @@ const drawElementFromCanvas = ( const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio; const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio; + context.save(); context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); context.translate(cx, cy); context.rotate(element.angle); @@ -531,9 +523,7 @@ const drawElementFromCanvas = ( elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom, elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom, ); - context.rotate(-element.angle); - context.translate(-cx, -cy); - context.scale(window.devicePixelRatio, window.devicePixelRatio); + context.restore(); // Clear the nested element we appended to the DOM }; @@ -548,18 +538,14 @@ export const renderElement = ( const generator = rc.generator; switch (element.type) { case "selection": { + context.save(); context.translate( element.x + sceneState.scrollX, element.y + sceneState.scrollY, ); - const fillStyle = context.fillStyle; context.fillStyle = "rgba(0, 0, 255, 0.10)"; context.fillRect(0, 0, element.width, element.height); - context.fillStyle = fillStyle; - context.translate( - -element.x - sceneState.scrollX, - -element.y - sceneState.scrollY, - ); + context.restore(); break; } case "freedraw": { @@ -577,13 +563,12 @@ export const renderElement = ( const cy = (y1 + y2) / 2 + sceneState.scrollY; const shiftX = (x2 - x1) / 2 - (element.x - x1); const shiftY = (y2 - y1) / 2 - (element.y - y1); + context.save(); context.translate(cx, cy); context.rotate(element.angle); context.translate(-shiftX, -shiftY); drawElementOnCanvas(element, rc, context); - context.translate(shiftX, shiftY); - context.rotate(-element.angle); - context.translate(-cx, -cy); + context.restore(); } break; @@ -607,13 +592,12 @@ export const renderElement = ( const cy = (y1 + y2) / 2 + sceneState.scrollY; const shiftX = (x2 - x1) / 2 - (element.x - x1); const shiftY = (y2 - y1) / 2 - (element.y - y1); + context.save(); context.translate(cx, cy); context.rotate(element.angle); context.translate(-shiftX, -shiftY); drawElementOnCanvas(element, rc, context); - context.translate(shiftX, shiftY); - context.rotate(-element.angle); - context.translate(-cx, -cy); + context.restore(); } break; } diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index a6f69d64..3280978a 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -64,14 +64,14 @@ const strokeRectWithRotation = ( angle: number, fill: boolean = false, ) => { + context.save(); context.translate(cx, cy); context.rotate(angle); if (fill) { context.fillRect(x - cx, y - cy, width, height); } context.strokeRect(x - cx, y - cy, width, height); - context.rotate(-angle); - context.translate(-cx, -cy); + context.restore(); }; const strokeDiamondWithRotation = ( @@ -82,6 +82,7 @@ const strokeDiamondWithRotation = ( cy: number, angle: number, ) => { + context.save(); context.translate(cx, cy); context.rotate(angle); context.beginPath(); @@ -91,8 +92,7 @@ const strokeDiamondWithRotation = ( context.lineTo(-width / 2, 0); context.closePath(); context.stroke(); - context.rotate(-angle); - context.translate(-cx, -cy); + context.restore(); }; const strokeEllipseWithRotation = ( @@ -128,7 +128,7 @@ const strokeGrid = ( width: number, height: number, ) => { - const origStrokeStyle = context.strokeStyle; + context.save(); context.strokeStyle = "rgba(0,0,0,0.1)"; context.beginPath(); for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { @@ -140,7 +140,7 @@ const strokeGrid = ( context.lineTo(offsetX + width + gridSize * 2, y); } context.stroke(); - context.strokeStyle = origStrokeStyle; + context.restore(); }; const renderLinearPointHandles = ( @@ -149,9 +149,8 @@ const renderLinearPointHandles = ( sceneState: SceneState, element: NonDeleted, ) => { + context.save(); context.translate(sceneState.scrollX, sceneState.scrollY); - const origStrokeStyle = context.strokeStyle; - const lineWidth = context.lineWidth; context.lineWidth = 1 / sceneState.zoom.value; LinearElementEditor.getPointsGlobalCoordinates(element).forEach( @@ -171,10 +170,7 @@ const renderLinearPointHandles = ( ); }, ); - context.setLineDash([]); - context.lineWidth = lineWidth; - context.translate(-sceneState.scrollX, -sceneState.scrollY); - context.strokeStyle = origStrokeStyle; + context.restore(); }; export const renderScene = ( @@ -207,6 +203,8 @@ export const renderScene = ( const context = canvas.getContext("2d")!; + context.setTransform(1, 0, 0, 1, 0, 0); + context.save(); context.scale(scale, scale); // When doing calculations based on canvas width we should used normalized one @@ -227,10 +225,10 @@ export const renderScene = ( if (hasTransparence) { context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); } - const fillStyle = context.fillStyle; + context.save(); context.fillStyle = sceneState.viewBackgroundColor; context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); - context.fillStyle = fillStyle; + context.restore(); } else { context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); } @@ -238,6 +236,7 @@ export const renderScene = ( // Apply zoom const zoomTranslationX = sceneState.zoom.translation.x; const zoomTranslationY = sceneState.zoom.translation.y; + context.save(); context.translate(zoomTranslationX, zoomTranslationY); context.scale(sceneState.zoom.value, sceneState.zoom.value); @@ -382,6 +381,7 @@ export const renderScene = ( const locallySelectedElements = getSelectedElements(elements, appState); // Paint resize transformHandles + context.save(); context.translate(sceneState.scrollX, sceneState.scrollY); if (locallySelectedElements.length === 1) { context.fillStyle = oc.white; @@ -427,12 +427,11 @@ export const renderScene = ( ); renderTransformHandles(context, sceneState, transformHandles, 0); } - context.translate(-sceneState.scrollX, -sceneState.scrollY); + context.restore(); } // Reset zoom - context.scale(1 / sceneState.zoom.value, 1 / sceneState.zoom.value); - context.translate(-zoomTranslationX, -zoomTranslationY); + context.restore(); // Paint remote pointers for (const clientId in sceneState.remotePointerViewportCoords) { @@ -457,9 +456,7 @@ export const renderScene = ( const { background, stroke } = getClientColors(clientId, appState); - const strokeStyle = context.strokeStyle; - const fillStyle = context.fillStyle; - const globalAlpha = context.globalAlpha; + context.save(); context.strokeStyle = stroke; context.fillStyle = background; @@ -545,9 +542,7 @@ export const renderScene = ( ); } - context.strokeStyle = strokeStyle; - context.fillStyle = fillStyle; - context.globalAlpha = globalAlpha; + context.restore(); context.closePath(); } @@ -561,8 +556,7 @@ export const renderScene = ( sceneState, ); - const fillStyle = context.fillStyle; - const strokeStyle = context.strokeStyle; + context.save(); context.fillStyle = SCROLLBAR_COLOR; context.strokeStyle = "rgba(255,255,255,0.8)"; [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => { @@ -577,12 +571,10 @@ export const renderScene = ( ); } }); - context.fillStyle = fillStyle; - context.strokeStyle = strokeStyle; + context.restore(); } - context.scale(1 / scale, 1 / scale); - + context.restore(); return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars }; }; @@ -595,7 +587,7 @@ const renderTransformHandles = ( Object.keys(transformHandles).forEach((key) => { const transformHandle = transformHandles[key as TransformHandleType]; if (transformHandle !== undefined) { - const lineWidth = context.lineWidth; + context.save(); context.lineWidth = 1 / sceneState.zoom.value; if (key === "rotation") { fillCircle( @@ -617,7 +609,7 @@ const renderTransformHandles = ( true, // fill before stroke ); } - context.lineWidth = lineWidth; + context.restore(); } }); }; @@ -645,18 +637,13 @@ const renderSelectionBorder = ( const elementWidth = elementX2 - elementX1; const elementHeight = elementY2 - elementY1; - const initialLineDash = context.getLineDash(); - const lineWidth = context.lineWidth; - const lineDashOffset = context.lineDashOffset; - const strokeStyle = context.strokeStyle; - const dashedLinePadding = 4 / sceneState.zoom.value; const dashWidth = 8 / sceneState.zoom.value; const spaceWidth = 4 / sceneState.zoom.value; - context.lineWidth = 1 / sceneState.zoom.value; - + context.save(); context.translate(sceneState.scrollX, sceneState.scrollY); + context.lineWidth = 1 / sceneState.zoom.value; const count = selectionColors.length; for (let index = 0; index < count; ++index) { @@ -677,11 +664,7 @@ const renderSelectionBorder = ( angle, ); } - context.lineDashOffset = lineDashOffset; - context.strokeStyle = strokeStyle; - context.lineWidth = lineWidth; - context.setLineDash(initialLineDash); - context.translate(-sceneState.scrollX, -sceneState.scrollY); + context.restore(); }; const renderBindingHighlight = ( @@ -689,21 +672,15 @@ const renderBindingHighlight = ( sceneState: SceneState, suggestedBinding: SuggestedBinding, ) => { - // preserve context settings to restore later - const originalStrokeStyle = context.strokeStyle; - const originalLineWidth = context.lineWidth; - const renderHighlight = Array.isArray(suggestedBinding) ? renderBindingHighlightForSuggestedPointBinding : renderBindingHighlightForBindableElement; + context.save(); context.translate(sceneState.scrollX, sceneState.scrollY); renderHighlight(context, suggestedBinding as any); - // restore context settings - context.strokeStyle = originalStrokeStyle; - context.lineWidth = originalLineWidth; - context.translate(-sceneState.scrollX, -sceneState.scrollY); + context.restore(); }; const renderBindingHighlightForBindableElement = (