Jelajahi Sumber

fix: Prevent gradual canvas misalignment (#3833)

Co-authored-by: dwelle <luzar.david@gmail.com>
Hargobind S. Khalsa 3 tahun lalu
induk
melakukan
9581c45522

+ 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.
 -->
 
+## 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

+ 12 - 28
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;
     }

+ 28 - 51
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<ExcalidrawLinearElement>,
 ) => {
+  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 = (