Browse Source

fix: Prevent gradual canvas misalignment (#3833)

Co-authored-by: dwelle <luzar.david@gmail.com>
Hargobind S. Khalsa 3 years ago
parent
commit
9581c45522
3 changed files with 48 additions and 79 deletions
  1. 8 0
      src/packages/excalidraw/CHANGELOG.md
  2. 12 28
      src/renderer/renderElement.ts
  3. 28 51
      src/renderer/renderScene.ts

+ 8 - 0
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.
 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)
 ## 0.9.0 (2021-07-10)
 
 
 ## Excalidraw API
 ## Excalidraw API

+ 12 - 28
src/renderer/renderElement.ts

@@ -102,8 +102,8 @@ const generateElementCanvas = (
       padding * zoom.value * 2;
       padding * zoom.value * 2;
   }
   }
 
 
+  context.save();
   context.translate(padding * zoom.value, padding * zoom.value);
   context.translate(padding * zoom.value, padding * zoom.value);
-
   context.scale(
   context.scale(
     window.devicePixelRatio * zoom.value,
     window.devicePixelRatio * zoom.value,
     window.devicePixelRatio * zoom.value,
     window.devicePixelRatio * zoom.value,
@@ -112,12 +112,7 @@ const generateElementCanvas = (
   const rc = rough.canvas(canvas);
   const rc = rough.canvas(canvas);
 
 
   drawElementOnCanvas(element, rc, context);
   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 {
   return {
     element,
     element,
     canvas,
     canvas,
@@ -175,11 +170,9 @@ const drawElementOnCanvas = (
           document.body.appendChild(context.canvas);
           document.body.appendChild(context.canvas);
         }
         }
         context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
         context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
-        const font = context.font;
+        context.save();
         context.font = getFontString(element);
         context.font = getFontString(element);
-        const fillStyle = context.fillStyle;
         context.fillStyle = element.strokeColor;
         context.fillStyle = element.strokeColor;
-        const textAlign = context.textAlign;
         context.textAlign = element.textAlign as CanvasTextAlign;
         context.textAlign = element.textAlign as CanvasTextAlign;
 
 
         // Canvas does not support multiline text by default
         // Canvas does not support multiline text by default
@@ -199,9 +192,7 @@ const drawElementOnCanvas = (
             (index + 1) * lineHeight - verticalOffset,
             (index + 1) * lineHeight - verticalOffset,
           );
           );
         }
         }
-        context.fillStyle = fillStyle;
-        context.font = font;
-        context.textAlign = textAlign;
+        context.restore();
         if (shouldTemporarilyAttach) {
         if (shouldTemporarilyAttach) {
           context.canvas.remove();
           context.canvas.remove();
         }
         }
@@ -518,6 +509,7 @@ const drawElementFromCanvas = (
 
 
   const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
   const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
   const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
   const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
+  context.save();
   context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
   context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
   context.translate(cx, cy);
   context.translate(cx, cy);
   context.rotate(element.angle);
   context.rotate(element.angle);
@@ -531,9 +523,7 @@ const drawElementFromCanvas = (
     elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
     elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
     elementWithCanvas.canvas!.height / 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
   // Clear the nested element we appended to the DOM
 };
 };
@@ -548,18 +538,14 @@ export const renderElement = (
   const generator = rc.generator;
   const generator = rc.generator;
   switch (element.type) {
   switch (element.type) {
     case "selection": {
     case "selection": {
+      context.save();
       context.translate(
       context.translate(
         element.x + sceneState.scrollX,
         element.x + sceneState.scrollX,
         element.y + sceneState.scrollY,
         element.y + sceneState.scrollY,
       );
       );
-      const fillStyle = context.fillStyle;
       context.fillStyle = "rgba(0, 0, 255, 0.10)";
       context.fillStyle = "rgba(0, 0, 255, 0.10)";
       context.fillRect(0, 0, element.width, element.height);
       context.fillRect(0, 0, element.width, element.height);
-      context.fillStyle = fillStyle;
-      context.translate(
-        -element.x - sceneState.scrollX,
-        -element.y - sceneState.scrollY,
-      );
+      context.restore();
       break;
       break;
     }
     }
     case "freedraw": {
     case "freedraw": {
@@ -577,13 +563,12 @@ export const renderElement = (
         const cy = (y1 + y2) / 2 + sceneState.scrollY;
         const cy = (y1 + y2) / 2 + sceneState.scrollY;
         const shiftX = (x2 - x1) / 2 - (element.x - x1);
         const shiftX = (x2 - x1) / 2 - (element.x - x1);
         const shiftY = (y2 - y1) / 2 - (element.y - y1);
         const shiftY = (y2 - y1) / 2 - (element.y - y1);
+        context.save();
         context.translate(cx, cy);
         context.translate(cx, cy);
         context.rotate(element.angle);
         context.rotate(element.angle);
         context.translate(-shiftX, -shiftY);
         context.translate(-shiftX, -shiftY);
         drawElementOnCanvas(element, rc, context);
         drawElementOnCanvas(element, rc, context);
-        context.translate(shiftX, shiftY);
-        context.rotate(-element.angle);
-        context.translate(-cx, -cy);
+        context.restore();
       }
       }
 
 
       break;
       break;
@@ -607,13 +592,12 @@ export const renderElement = (
         const cy = (y1 + y2) / 2 + sceneState.scrollY;
         const cy = (y1 + y2) / 2 + sceneState.scrollY;
         const shiftX = (x2 - x1) / 2 - (element.x - x1);
         const shiftX = (x2 - x1) / 2 - (element.x - x1);
         const shiftY = (y2 - y1) / 2 - (element.y - y1);
         const shiftY = (y2 - y1) / 2 - (element.y - y1);
+        context.save();
         context.translate(cx, cy);
         context.translate(cx, cy);
         context.rotate(element.angle);
         context.rotate(element.angle);
         context.translate(-shiftX, -shiftY);
         context.translate(-shiftX, -shiftY);
         drawElementOnCanvas(element, rc, context);
         drawElementOnCanvas(element, rc, context);
-        context.translate(shiftX, shiftY);
-        context.rotate(-element.angle);
-        context.translate(-cx, -cy);
+        context.restore();
       }
       }
       break;
       break;
     }
     }

+ 28 - 51
src/renderer/renderScene.ts

@@ -64,14 +64,14 @@ const strokeRectWithRotation = (
   angle: number,
   angle: number,
   fill: boolean = false,
   fill: boolean = false,
 ) => {
 ) => {
+  context.save();
   context.translate(cx, cy);
   context.translate(cx, cy);
   context.rotate(angle);
   context.rotate(angle);
   if (fill) {
   if (fill) {
     context.fillRect(x - cx, y - cy, width, height);
     context.fillRect(x - cx, y - cy, width, height);
   }
   }
   context.strokeRect(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 = (
 const strokeDiamondWithRotation = (
@@ -82,6 +82,7 @@ const strokeDiamondWithRotation = (
   cy: number,
   cy: number,
   angle: number,
   angle: number,
 ) => {
 ) => {
+  context.save();
   context.translate(cx, cy);
   context.translate(cx, cy);
   context.rotate(angle);
   context.rotate(angle);
   context.beginPath();
   context.beginPath();
@@ -91,8 +92,7 @@ const strokeDiamondWithRotation = (
   context.lineTo(-width / 2, 0);
   context.lineTo(-width / 2, 0);
   context.closePath();
   context.closePath();
   context.stroke();
   context.stroke();
-  context.rotate(-angle);
-  context.translate(-cx, -cy);
+  context.restore();
 };
 };
 
 
 const strokeEllipseWithRotation = (
 const strokeEllipseWithRotation = (
@@ -128,7 +128,7 @@ const strokeGrid = (
   width: number,
   width: number,
   height: number,
   height: number,
 ) => {
 ) => {
-  const origStrokeStyle = context.strokeStyle;
+  context.save();
   context.strokeStyle = "rgba(0,0,0,0.1)";
   context.strokeStyle = "rgba(0,0,0,0.1)";
   context.beginPath();
   context.beginPath();
   for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
   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.lineTo(offsetX + width + gridSize * 2, y);
   }
   }
   context.stroke();
   context.stroke();
-  context.strokeStyle = origStrokeStyle;
+  context.restore();
 };
 };
 
 
 const renderLinearPointHandles = (
 const renderLinearPointHandles = (
@@ -149,9 +149,8 @@ const renderLinearPointHandles = (
   sceneState: SceneState,
   sceneState: SceneState,
   element: NonDeleted<ExcalidrawLinearElement>,
   element: NonDeleted<ExcalidrawLinearElement>,
 ) => {
 ) => {
+  context.save();
   context.translate(sceneState.scrollX, sceneState.scrollY);
   context.translate(sceneState.scrollX, sceneState.scrollY);
-  const origStrokeStyle = context.strokeStyle;
-  const lineWidth = context.lineWidth;
   context.lineWidth = 1 / sceneState.zoom.value;
   context.lineWidth = 1 / sceneState.zoom.value;
 
 
   LinearElementEditor.getPointsGlobalCoordinates(element).forEach(
   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 = (
 export const renderScene = (
@@ -207,6 +203,8 @@ export const renderScene = (
 
 
   const context = canvas.getContext("2d")!;
   const context = canvas.getContext("2d")!;
 
 
+  context.setTransform(1, 0, 0, 1, 0, 0);
+  context.save();
   context.scale(scale, scale);
   context.scale(scale, scale);
 
 
   // When doing calculations based on canvas width we should used normalized one
   // When doing calculations based on canvas width we should used normalized one
@@ -227,10 +225,10 @@ export const renderScene = (
     if (hasTransparence) {
     if (hasTransparence) {
       context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
       context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
     }
     }
-    const fillStyle = context.fillStyle;
+    context.save();
     context.fillStyle = sceneState.viewBackgroundColor;
     context.fillStyle = sceneState.viewBackgroundColor;
     context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
     context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
-    context.fillStyle = fillStyle;
+    context.restore();
   } else {
   } else {
     context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
     context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
   }
   }
@@ -238,6 +236,7 @@ export const renderScene = (
   // Apply zoom
   // Apply zoom
   const zoomTranslationX = sceneState.zoom.translation.x;
   const zoomTranslationX = sceneState.zoom.translation.x;
   const zoomTranslationY = sceneState.zoom.translation.y;
   const zoomTranslationY = sceneState.zoom.translation.y;
+  context.save();
   context.translate(zoomTranslationX, zoomTranslationY);
   context.translate(zoomTranslationX, zoomTranslationY);
   context.scale(sceneState.zoom.value, sceneState.zoom.value);
   context.scale(sceneState.zoom.value, sceneState.zoom.value);
 
 
@@ -382,6 +381,7 @@ export const renderScene = (
     const locallySelectedElements = getSelectedElements(elements, appState);
     const locallySelectedElements = getSelectedElements(elements, appState);
 
 
     // Paint resize transformHandles
     // Paint resize transformHandles
+    context.save();
     context.translate(sceneState.scrollX, sceneState.scrollY);
     context.translate(sceneState.scrollX, sceneState.scrollY);
     if (locallySelectedElements.length === 1) {
     if (locallySelectedElements.length === 1) {
       context.fillStyle = oc.white;
       context.fillStyle = oc.white;
@@ -427,12 +427,11 @@ export const renderScene = (
       );
       );
       renderTransformHandles(context, sceneState, transformHandles, 0);
       renderTransformHandles(context, sceneState, transformHandles, 0);
     }
     }
-    context.translate(-sceneState.scrollX, -sceneState.scrollY);
+    context.restore();
   }
   }
 
 
   // Reset zoom
   // Reset zoom
-  context.scale(1 / sceneState.zoom.value, 1 / sceneState.zoom.value);
-  context.translate(-zoomTranslationX, -zoomTranslationY);
+  context.restore();
 
 
   // Paint remote pointers
   // Paint remote pointers
   for (const clientId in sceneState.remotePointerViewportCoords) {
   for (const clientId in sceneState.remotePointerViewportCoords) {
@@ -457,9 +456,7 @@ export const renderScene = (
 
 
     const { background, stroke } = getClientColors(clientId, appState);
     const { background, stroke } = getClientColors(clientId, appState);
 
 
-    const strokeStyle = context.strokeStyle;
-    const fillStyle = context.fillStyle;
-    const globalAlpha = context.globalAlpha;
+    context.save();
     context.strokeStyle = stroke;
     context.strokeStyle = stroke;
     context.fillStyle = background;
     context.fillStyle = background;
 
 
@@ -545,9 +542,7 @@ export const renderScene = (
       );
       );
     }
     }
 
 
-    context.strokeStyle = strokeStyle;
-    context.fillStyle = fillStyle;
-    context.globalAlpha = globalAlpha;
+    context.restore();
     context.closePath();
     context.closePath();
   }
   }
 
 
@@ -561,8 +556,7 @@ export const renderScene = (
       sceneState,
       sceneState,
     );
     );
 
 
-    const fillStyle = context.fillStyle;
-    const strokeStyle = context.strokeStyle;
+    context.save();
     context.fillStyle = SCROLLBAR_COLOR;
     context.fillStyle = SCROLLBAR_COLOR;
     context.strokeStyle = "rgba(255,255,255,0.8)";
     context.strokeStyle = "rgba(255,255,255,0.8)";
     [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => {
     [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 };
   return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
 };
 };
 
 
@@ -595,7 +587,7 @@ const renderTransformHandles = (
   Object.keys(transformHandles).forEach((key) => {
   Object.keys(transformHandles).forEach((key) => {
     const transformHandle = transformHandles[key as TransformHandleType];
     const transformHandle = transformHandles[key as TransformHandleType];
     if (transformHandle !== undefined) {
     if (transformHandle !== undefined) {
-      const lineWidth = context.lineWidth;
+      context.save();
       context.lineWidth = 1 / sceneState.zoom.value;
       context.lineWidth = 1 / sceneState.zoom.value;
       if (key === "rotation") {
       if (key === "rotation") {
         fillCircle(
         fillCircle(
@@ -617,7 +609,7 @@ const renderTransformHandles = (
           true, // fill before stroke
           true, // fill before stroke
         );
         );
       }
       }
-      context.lineWidth = lineWidth;
+      context.restore();
     }
     }
   });
   });
 };
 };
@@ -645,18 +637,13 @@ const renderSelectionBorder = (
   const elementWidth = elementX2 - elementX1;
   const elementWidth = elementX2 - elementX1;
   const elementHeight = elementY2 - elementY1;
   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 dashedLinePadding = 4 / sceneState.zoom.value;
   const dashWidth = 8 / sceneState.zoom.value;
   const dashWidth = 8 / sceneState.zoom.value;
   const spaceWidth = 4 / sceneState.zoom.value;
   const spaceWidth = 4 / sceneState.zoom.value;
 
 
-  context.lineWidth = 1 / sceneState.zoom.value;
-
+  context.save();
   context.translate(sceneState.scrollX, sceneState.scrollY);
   context.translate(sceneState.scrollX, sceneState.scrollY);
+  context.lineWidth = 1 / sceneState.zoom.value;
 
 
   const count = selectionColors.length;
   const count = selectionColors.length;
   for (let index = 0; index < count; ++index) {
   for (let index = 0; index < count; ++index) {
@@ -677,11 +664,7 @@ const renderSelectionBorder = (
       angle,
       angle,
     );
     );
   }
   }
-  context.lineDashOffset = lineDashOffset;
-  context.strokeStyle = strokeStyle;
-  context.lineWidth = lineWidth;
-  context.setLineDash(initialLineDash);
-  context.translate(-sceneState.scrollX, -sceneState.scrollY);
+  context.restore();
 };
 };
 
 
 const renderBindingHighlight = (
 const renderBindingHighlight = (
@@ -689,21 +672,15 @@ const renderBindingHighlight = (
   sceneState: SceneState,
   sceneState: SceneState,
   suggestedBinding: SuggestedBinding,
   suggestedBinding: SuggestedBinding,
 ) => {
 ) => {
-  // preserve context settings to restore later
-  const originalStrokeStyle = context.strokeStyle;
-  const originalLineWidth = context.lineWidth;
-
   const renderHighlight = Array.isArray(suggestedBinding)
   const renderHighlight = Array.isArray(suggestedBinding)
     ? renderBindingHighlightForSuggestedPointBinding
     ? renderBindingHighlightForSuggestedPointBinding
     : renderBindingHighlightForBindableElement;
     : renderBindingHighlightForBindableElement;
 
 
+  context.save();
   context.translate(sceneState.scrollX, sceneState.scrollY);
   context.translate(sceneState.scrollX, sceneState.scrollY);
   renderHighlight(context, suggestedBinding as any);
   renderHighlight(context, suggestedBinding as any);
 
 
-  // restore context settings
-  context.strokeStyle = originalStrokeStyle;
-  context.lineWidth = originalLineWidth;
-  context.translate(-sceneState.scrollX, -sceneState.scrollY);
+  context.restore();
 };
 };
 
 
 const renderBindingHighlightForBindableElement = (
 const renderBindingHighlightForBindableElement = (