fix: Prevent gradual canvas misalignment (#3833)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Hargobind S. Khalsa 2021-07-14 04:29:22 -07:00 committed by GitHub
parent 0749d2c1f3
commit 9581c45522
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 48 additions and 79 deletions

View File

@ -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

View File

@ -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.restore();
context.translate(-(padding * zoom.value), -(padding * zoom.value));
context.scale(
1 / (window.devicePixelRatio * zoom.value),
1 / (window.devicePixelRatio * zoom.value),
);
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.restore();
context.font = font;
context.textAlign = textAlign;
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.restore();
context.translate(-cx, -cy);
context.scale(window.devicePixelRatio, window.devicePixelRatio);
// 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.restore();
context.translate(
-element.x - sceneState.scrollX,
-element.y - sceneState.scrollY,
);
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.restore();
context.rotate(-element.angle);
context.translate(-cx, -cy);
} }
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.restore();
context.rotate(-element.angle);
context.translate(-cx, -cy);
} }
break; break;
} }

View File

@ -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.restore();
context.translate(-cx, -cy);
}; };
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.restore();
context.translate(-cx, -cy);
}; };
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.restore();
context.lineWidth = lineWidth;
context.translate(-sceneState.scrollX, -sceneState.scrollY);
context.strokeStyle = origStrokeStyle;
}; };
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.restore();
context.translate(-zoomTranslationX, -zoomTranslationY);
// 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; context.save();
const fillStyle = context.fillStyle;
const globalAlpha = context.globalAlpha;
context.strokeStyle = stroke; context.strokeStyle = stroke;
context.fillStyle = background; context.fillStyle = background;
@ -545,9 +542,7 @@ export const renderScene = (
); );
} }
context.strokeStyle = strokeStyle; context.restore();
context.fillStyle = fillStyle;
context.globalAlpha = globalAlpha;
context.closePath(); context.closePath();
} }
@ -561,8 +556,7 @@ export const renderScene = (
sceneState, sceneState,
); );
const fillStyle = context.fillStyle; context.save();
const strokeStyle = context.strokeStyle;
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.restore();
context.strokeStyle = strokeStyle;
} }
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.restore();
context.strokeStyle = strokeStyle;
context.lineWidth = lineWidth;
context.setLineDash(initialLineDash);
context.translate(-sceneState.scrollX, -sceneState.scrollY);
}; };
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.restore();
context.strokeStyle = originalStrokeStyle;
context.lineWidth = originalLineWidth;
context.translate(-sceneState.scrollX, -sceneState.scrollY);
}; };
const renderBindingHighlightForBindableElement = ( const renderBindingHighlightForBindableElement = (