Bladeren bron

Fast & Furious (#655)

* [WIP] Fast & Furious

* ensure we translate before scaling

* implement canvas caching for rest of elements

* remove unnecessary ts-ignore

* fix for devicePixelRatio

* initialize missing element props on restore

* factor out canvas padding

* remove unnecessary filtering

* simplify renderElement

* regenerate canvas on prop changes

* revert swapping shape resetting with canvas

* fix blurry rendering

* apply devicePixelRatio when clearing canvas

* improve blurriness; fix arrow canvas offset

* revert canvas clearing changes in anticipation of merge

* normalize scrollX/Y on update

* fix getDerivedStateFromProps

* swap derivedState for type brands

* tweak types

* remove renderScene offsets

* move selection element translations to renderElement

* dry out canvas zoom transformations

* fix padding offset

* Render cached canvas based on the zoom level

Co-authored-by: David Luzar <luzar.david@gmail.com>
Co-authored-by: Preet <833927+pshihn@users.noreply.github.com>
Christopher Chedeau 5 jaren geleden
bovenliggende
commit
5256096d76
13 gewijzigde bestanden met toevoegingen van 269 en 114 verwijderingen
  1. 3 3
      src/appState.ts
  2. 1 1
      src/clipboard.ts
  3. 5 0
      src/element/newElement.ts
  4. 1 0
      src/history.ts
  5. 25 15
      src/index.tsx
  6. 157 32
      src/renderer/renderElement.ts
  7. 35 44
      src/renderer/renderScene.ts
  8. 15 5
      src/scene/data.ts
  9. 4 4
      src/scene/export.ts
  10. 5 4
      src/scene/scrollbars.ts
  11. 5 4
      src/scene/types.ts
  12. 9 0
      src/styles.scss
  13. 4 2
      src/types.ts

+ 3 - 3
src/appState.ts

@@ -1,4 +1,4 @@
-import { AppState } from "./types";
+import { AppState, FlooredNumber } from "./types";
 import { getDateTime } from "./utils";
 
 const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
@@ -20,8 +20,8 @@ export function getDefaultAppState(): AppState {
     currentItemOpacity: 100,
     currentItemFont: "20px Virgil",
     viewBackgroundColor: "#ffffff",
-    scrollX: 0,
-    scrollY: 0,
+    scrollX: 0 as FlooredNumber,
+    scrollY: 0 as FlooredNumber,
     cursorX: 0,
     cursorY: 0,
     scrolledOutside: false,

+ 1 - 1
src/clipboard.ts

@@ -20,7 +20,7 @@ export async function copyToAppClipboard(
   elements: readonly ExcalidrawElement[],
 ) {
   CLIPBOARD = JSON.stringify(
-    getSelectedElements(elements).map(({ shape, ...el }) => el),
+    getSelectedElements(elements).map(({ shape, canvas, ...el }) => el),
   );
   try {
     // when copying to in-app clipboard, clear system clipboard so that if

+ 5 - 0
src/element/newElement.ts

@@ -36,6 +36,10 @@ export function newElement(
     seed: randomSeed(),
     shape: null as Drawable | Drawable[] | null,
     points: [] as Point[],
+    canvas: null as HTMLCanvasElement | null,
+    canvasZoom: 1, // The zoom level used to render the cached canvas
+    canvasOffsetX: 0,
+    canvasOffsetY: 0,
   };
   return element;
 }
@@ -48,6 +52,7 @@ export function newTextElement(
   const metrics = measureText(text, font);
   const textElement: ExcalidrawTextElement = {
     ...element,
+    shape: null,
     type: "text",
     text: text,
     font: font,

+ 1 - 0
src/history.ts

@@ -16,6 +16,7 @@ class SceneHistory {
       elements: elements.map(({ shape, ...element }) => ({
         ...element,
         shape: null,
+        canvas: null,
         points:
           appState.multiElement && appState.multiElement.id === element.id
             ? element.points.slice(0, -1)

+ 25 - 15
src/index.tsx

@@ -41,7 +41,7 @@ import {
 } from "./scene";
 
 import { renderScene } from "./renderer";
-import { AppState } from "./types";
+import { AppState, FlooredNumber } from "./types";
 import { ExcalidrawElement } from "./element/types";
 
 import {
@@ -106,6 +106,7 @@ import { t, languages, setLanguage, getLanguage } from "./i18n";
 import { HintViewer } from "./components/HintViewer";
 
 import { copyToAppClipboard, getClipboardContent } from "./clipboard";
+import { normalizeScroll } from "./scene/data";
 
 let { elements } = createScene();
 const { history } = createHistory();
@@ -143,8 +144,8 @@ export function viewportCoordsToSceneCoords(
     scrollY,
     zoom,
   }: {
-    scrollX: number;
-    scrollY: number;
+    scrollX: FlooredNumber;
+    scrollY: FlooredNumber;
     zoom: number;
   },
   canvas: HTMLCanvasElement | null,
@@ -166,8 +167,8 @@ export function sceneCoordsToViewportCoords(
     scrollY,
     zoom,
   }: {
-    scrollX: number;
-    scrollY: number;
+    scrollX: FlooredNumber;
+    scrollY: FlooredNumber;
     zoom: number;
   },
   canvas: HTMLCanvasElement | null,
@@ -651,6 +652,7 @@ export class App extends React.Component<any, AppState> {
   public state: AppState = getDefaultAppState();
 
   private onResize = () => {
+    elements = elements.map(el => ({ ...el, shape: null }));
     this.setState({});
   };
 
@@ -958,8 +960,12 @@ export class App extends React.Component<any, AppState> {
                   lastY = e.clientY;
 
                   this.setState({
-                    scrollX: this.state.scrollX - deltaX / this.state.zoom,
-                    scrollY: this.state.scrollY - deltaY / this.state.zoom,
+                    scrollX: normalizeScroll(
+                      this.state.scrollX - deltaX / this.state.zoom,
+                    ),
+                    scrollY: normalizeScroll(
+                      this.state.scrollY - deltaY / this.state.zoom,
+                    ),
                   });
                 };
                 const teardown = (lastMouseUp = () => {
@@ -1294,7 +1300,9 @@ export class App extends React.Component<any, AppState> {
                   const x = e.clientX;
                   const dx = x - lastX;
                   this.setState({
-                    scrollX: this.state.scrollX - dx / this.state.zoom,
+                    scrollX: normalizeScroll(
+                      this.state.scrollX - dx / this.state.zoom,
+                    ),
                   });
                   lastX = x;
                   return;
@@ -1304,7 +1312,9 @@ export class App extends React.Component<any, AppState> {
                   const y = e.clientY;
                   const dy = y - lastY;
                   this.setState({
-                    scrollY: this.state.scrollY - dy / this.state.zoom,
+                    scrollY: normalizeScroll(
+                      this.state.scrollY - dy / this.state.zoom,
+                    ),
                   });
                   lastY = y;
                   return;
@@ -2004,8 +2014,8 @@ export class App extends React.Component<any, AppState> {
     }
 
     this.setState(({ zoom, scrollX, scrollY }) => ({
-      scrollX: scrollX - deltaX / zoom,
-      scrollY: scrollY - deltaY / zoom,
+      scrollX: normalizeScroll(scrollX - deltaX / zoom),
+      scrollY: normalizeScroll(scrollY - deltaY / zoom),
     }));
   };
 
@@ -2069,10 +2079,7 @@ export class App extends React.Component<any, AppState> {
   }
 
   private saveDebounced = debounce(() => {
-    saveToLocalStorage(
-      elements.filter(x => x.type !== "selection"),
-      this.state,
-    );
+    saveToLocalStorage(elements, this.state);
   }, 300);
 
   componentDidUpdate() {
@@ -2087,6 +2094,9 @@ export class App extends React.Component<any, AppState> {
         viewBackgroundColor: this.state.viewBackgroundColor,
         zoom: this.state.zoom,
       },
+      {
+        renderOptimizations: true,
+      },
     );
     const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
     if (this.state.scrolledOutside !== scrolledOutside) {

+ 157 - 32
src/renderer/renderElement.ts

@@ -1,18 +1,111 @@
-import { ExcalidrawElement } from "../element/types";
+import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 import { isTextElement } from "../element/typeChecks";
-import { getDiamondPoints, getArrowPoints } from "../element/bounds";
+import {
+  getDiamondPoints,
+  getArrowPoints,
+  getElementAbsoluteCoords,
+} from "../element/bounds";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import { Drawable } from "roughjs/bin/core";
 import { Point } from "roughjs/bin/geometry";
 import { RoughSVG } from "roughjs/bin/svg";
 import { RoughGenerator } from "roughjs/bin/generator";
-import { SVG_NS } from "../utils";
+import { SceneState } from "../scene/types";
+import { SVG_NS, distance } from "../utils";
+import rough from "roughjs/bin/rough";
+
+const CANVAS_PADDING = 20;
+
+function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
+  const canvas = document.createElement("canvas");
+  var context = canvas.getContext("2d")!;
+
+  const isLinear = /\b(arrow|line)\b/.test(element.type);
+
+  if (isLinear) {
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+    canvas.width =
+      distance(x1, x2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
+    canvas.height =
+      distance(y1, y2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
+
+    element.canvasOffsetX =
+      element.x > x1
+        ? Math.floor(distance(element.x, x1)) * window.devicePixelRatio
+        : 0;
+    element.canvasOffsetY =
+      element.y > y1
+        ? Math.floor(distance(element.y, y1)) * window.devicePixelRatio
+        : 0;
+    context.translate(
+      element.canvasOffsetX * zoom,
+      element.canvasOffsetY * zoom,
+    );
+  } else {
+    canvas.width =
+      element.width * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
+    canvas.height =
+      element.height * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
+  }
+
+  context.translate(CANVAS_PADDING, CANVAS_PADDING);
+  context.scale(window.devicePixelRatio * zoom, window.devicePixelRatio * zoom);
+
+  const rc = rough.canvas(canvas);
+  drawElementOnCanvas(element, rc, context);
+  element.canvas = canvas;
+  element.canvasZoom = zoom;
+  context.translate(-CANVAS_PADDING, -CANVAS_PADDING);
+}
+
+function drawElementOnCanvas(
+  element: ExcalidrawElement,
+  rc: RoughCanvas,
+  context: CanvasRenderingContext2D,
+) {
+  context.globalAlpha = element.opacity / 100;
+  switch (element.type) {
+    case "rectangle":
+    case "diamond":
+    case "ellipse": {
+      rc.draw(element.shape as Drawable);
+      break;
+    }
+    case "arrow":
+    case "line": {
+      (element.shape as Drawable[]).forEach(shape => rc.draw(shape));
+      break;
+    }
+    default: {
+      if (isTextElement(element)) {
+        const font = context.font;
+        context.font = element.font;
+        const fillStyle = context.fillStyle;
+        context.fillStyle = element.strokeColor;
+        // Canvas does not support multiline text by default
+        const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
+        const lineHeight = element.height / lines.length;
+        const offset = element.height - element.baseline;
+        for (let i = 0; i < lines.length; i++) {
+          context.fillText(lines[i], 0, (i + 1) * lineHeight - offset);
+        }
+        context.fillStyle = fillStyle;
+        context.font = font;
+      } else {
+        throw new Error(`Unimplemented type ${element.type}`);
+      }
+    }
+  }
+  context.globalAlpha = 1;
+}
 
 function generateElement(
   element: ExcalidrawElement,
   generator: RoughGenerator,
+  sceneState?: SceneState,
 ) {
   if (!element.shape) {
+    element.canvas = null;
     switch (element.type) {
       case "rectangle":
         element.shape = generator.rectangle(
@@ -32,6 +125,7 @@ function generateElement(
             seed: element.seed,
           },
         );
+
         break;
       case "diamond": {
         const [
@@ -115,18 +209,64 @@ function generateElement(
         }
         break;
       }
+      case "text": {
+        // just to ensure we don't regenerate element.canvas on rerenders
+        element.shape = [];
+        break;
+      }
     }
   }
+  const zoom = sceneState ? sceneState.zoom : 1;
+  if (!element.canvas || element.canvasZoom !== zoom) {
+    generateElementCanvas(element, zoom);
+  }
+}
+
+function drawElementFromCanvas(
+  element: ExcalidrawElement | ExcalidrawTextElement,
+  rc: RoughCanvas,
+  context: CanvasRenderingContext2D,
+  sceneState: SceneState,
+) {
+  context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
+  context.translate(
+    -CANVAS_PADDING / sceneState.zoom,
+    -CANVAS_PADDING / sceneState.zoom,
+  );
+  context.drawImage(
+    element.canvas!,
+    Math.floor(
+      -element.canvasOffsetX +
+        (Math.floor(element.x) + sceneState.scrollX) * window.devicePixelRatio,
+    ),
+    Math.floor(
+      -element.canvasOffsetY +
+        (Math.floor(element.y) + sceneState.scrollY) * window.devicePixelRatio,
+    ),
+    element.canvas!.width / sceneState.zoom,
+    element.canvas!.height / sceneState.zoom,
+  );
+  context.translate(
+    CANVAS_PADDING / sceneState.zoom,
+    CANVAS_PADDING / sceneState.zoom,
+  );
+  context.scale(window.devicePixelRatio, window.devicePixelRatio);
 }
 
 export function renderElement(
   element: ExcalidrawElement,
   rc: RoughCanvas,
   context: CanvasRenderingContext2D,
+  renderOptimizations: boolean,
+  sceneState: SceneState,
 ) {
   const generator = rc.generator;
   switch (element.type) {
     case "selection": {
+      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);
@@ -136,39 +276,24 @@ export function renderElement(
     case "rectangle":
     case "diamond":
     case "ellipse":
-      generateElement(element, generator);
-      context.globalAlpha = element.opacity / 100;
-      rc.draw(element.shape as Drawable);
-      context.globalAlpha = 1;
-      break;
     case "line":
-    case "arrow": {
-      generateElement(element, generator);
-      context.globalAlpha = element.opacity / 100;
-      (element.shape as Drawable[]).forEach(shape => rc.draw(shape));
-      context.globalAlpha = 1;
+    case "arrow":
+    case "text": {
+      generateElement(element, generator, sceneState);
+
+      if (renderOptimizations) {
+        drawElementFromCanvas(element, rc, context, sceneState);
+      } else {
+        const offsetX = Math.floor(element.x + sceneState.scrollX);
+        const offsetY = Math.floor(element.y + sceneState.scrollY);
+        context.translate(offsetX, offsetY);
+        drawElementOnCanvas(element, rc, context);
+        context.translate(-offsetX, -offsetY);
+      }
       break;
     }
     default: {
-      if (isTextElement(element)) {
-        context.globalAlpha = element.opacity / 100;
-        const font = context.font;
-        context.font = element.font;
-        const fillStyle = context.fillStyle;
-        context.fillStyle = element.strokeColor;
-        // Canvas does not support multiline text by default
-        const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
-        const lineHeight = element.height / lines.length;
-        const offset = element.height - element.baseline;
-        for (let i = 0; i < lines.length; i++) {
-          context.fillText(lines[i], 0, (i + 1) * lineHeight - offset);
-        }
-        context.fillStyle = fillStyle;
-        context.font = font;
-        context.globalAlpha = 1;
-      } else {
-        throw new Error(`Unimplemented type ${element.type}`);
-      }
+      throw new Error(`Unimplemented type ${element.type}`);
     }
   }
 }

+ 35 - 44
src/renderer/renderScene.ts

@@ -1,6 +1,7 @@
 import { RoughCanvas } from "roughjs/bin/canvas";
 import { RoughSVG } from "roughjs/bin/svg";
 
+import { FlooredNumber } from "../types";
 import { ExcalidrawElement } from "../element/types";
 import { getElementAbsoluteCoords, handlerRectangles } from "../element";
 
@@ -24,28 +25,22 @@ export function renderScene(
   sceneState: SceneState,
   // extra options, currently passed by export helper
   {
-    offsetX,
-    offsetY,
     renderScrollbars = true,
     renderSelection = true,
+    // Whether to employ render optimizations to improve performance.
+    // Should not be turned on for export operations and similar, because it
+    //  doesn't guarantee pixel-perfect output.
+    renderOptimizations = false,
   }: {
-    offsetX?: number;
-    offsetY?: number;
     renderScrollbars?: boolean;
     renderSelection?: boolean;
+    renderOptimizations?: boolean;
   } = {},
 ): boolean {
   if (!canvas) {
     return false;
   }
 
-  // Use offsets insteads of scrolls if available
-  sceneState = {
-    ...sceneState,
-    scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX,
-    scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY,
-  };
-
   const context = canvas.getContext("2d")!;
 
   // Get initial scale transform as reference for later usage
@@ -57,8 +52,11 @@ export function renderScene(
   const normalizedCanvasHeight =
     canvas.height / getContextTransformScaleY(initialContextTransform);
 
-  // Handle zoom scaling
-  function scaleContextToZoom() {
+  const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom);
+  function applyZoom(context: CanvasRenderingContext2D): void {
+    context.save();
+
+    // Handle zoom scaling
     context.setTransform(
       getContextTransformScaleX(initialContextTransform) * sceneState.zoom,
       0,
@@ -67,11 +65,7 @@ export function renderScene(
       getContextTransformTranslateX(context.getTransform()),
       getContextTransformTranslateY(context.getTransform()),
     );
-  }
-
-  // Handle zoom translation
-  const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom);
-  function translateContextToZoom() {
+    // Handle zoom translation
     context.setTransform(
       getContextTransformScaleX(context.getTransform()),
       0,
@@ -83,6 +77,9 @@ export function renderScene(
         zoomTranslation.y,
     );
   }
+  function resetZoom(context: CanvasRenderingContext2D): void {
+    context.restore();
+  }
 
   // Paint background
   context.save();
@@ -111,27 +108,23 @@ export function renderScene(
     ),
   );
 
-  context.save();
-  scaleContextToZoom();
-  translateContextToZoom();
-  context.translate(sceneState.scrollX, sceneState.scrollY);
+  applyZoom(context);
   visibleElements.forEach(element => {
-    context.save();
-    context.translate(element.x, element.y);
-    renderElement(element, rc, context);
-    context.restore();
+    renderElement(element, rc, context, renderOptimizations, sceneState);
   });
-  context.restore();
+  resetZoom(context);
 
   // Pain selection element
   if (selectionElement) {
-    context.save();
-    scaleContextToZoom();
-    translateContextToZoom();
-    context.translate(sceneState.scrollX, sceneState.scrollY);
-    context.translate(selectionElement.x, selectionElement.y);
-    renderElement(selectionElement, rc, context);
-    context.restore();
+    applyZoom(context);
+    renderElement(
+      selectionElement,
+      rc,
+      context,
+      renderOptimizations,
+      sceneState,
+    );
+    resetZoom(context);
   }
 
   // Pain selected elements
@@ -139,9 +132,7 @@ export function renderScene(
     const selectedElements = getSelectedElements(elements);
     const dashledLinePadding = 4 / sceneState.zoom;
 
-    context.save();
-    scaleContextToZoom();
-    translateContextToZoom();
+    applyZoom(context);
     context.translate(sceneState.scrollX, sceneState.scrollY);
     selectedElements.forEach(element => {
       const [
@@ -164,13 +155,11 @@ export function renderScene(
       );
       context.setLineDash(initialLineDash);
     });
-    context.restore();
+    resetZoom(context);
 
     // Paint resize handlers
     if (selectedElements.length === 1 && selectedElements[0].type !== "text") {
-      context.save();
-      scaleContextToZoom();
-      translateContextToZoom();
+      applyZoom(context);
       context.translate(sceneState.scrollX, sceneState.scrollY);
       const handlers = handlerRectangles(selectedElements[0], sceneState.zoom);
       Object.values(handlers)
@@ -178,8 +167,10 @@ export function renderScene(
         .forEach(handler => {
           context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
         });
-      context.restore();
+      resetZoom(context);
     }
+
+    return visibleElements.length > 0;
   }
 
   // Paint scrollbars
@@ -221,8 +212,8 @@ function isVisibleElement(
     scrollY,
     zoom,
   }: {
-    scrollX: number;
-    scrollY: number;
+    scrollX: FlooredNumber;
+    scrollY: FlooredNumber;
     zoom: number;
   },
 ) {

+ 15 - 5
src/scene/data.ts

@@ -6,7 +6,7 @@ import {
   clearAppStateForLocalStorage,
 } from "../appState";
 
-import { AppState } from "../types";
+import { AppState, FlooredNumber } from "../types";
 import { ExportType } from "./types";
 import { exportToCanvas, exportToSvg } from "./export";
 import nanoid from "nanoid";
@@ -59,17 +59,21 @@ export function serializeAsJSON(
   );
 }
 
+export function normalizeScroll(pos: number) {
+  return Math.floor(pos) as FlooredNumber;
+}
+
 export function calculateScrollCenter(
   elements: readonly ExcalidrawElement[],
-): { scrollX: number; scrollY: number } {
+): { scrollX: FlooredNumber; scrollY: FlooredNumber } {
   const [x1, y1, x2, y2] = getCommonBounds(elements);
 
   const centerX = (x1 + x2) / 2;
   const centerY = (y1 + y2) / 2;
 
   return {
-    scrollX: window.innerWidth / 2 - centerX,
-    scrollY: window.innerHeight / 2 - centerY,
+    scrollX: normalizeScroll(window.innerWidth / 2 - centerX),
+    scrollY: normalizeScroll(window.innerHeight / 2 - centerY),
   };
 }
 
@@ -383,6 +387,10 @@ function restore(
             ? 100
             : element.opacity,
         points,
+        shape: null,
+        canvas: null,
+        canvasOffsetX: element.canvasOffsetX || 0,
+        canvasOffsetY: element.canvasOffsetY || 0,
       };
     });
 
@@ -430,7 +438,9 @@ export function saveToLocalStorage(
   localStorage.setItem(
     LOCAL_STORAGE_KEY,
     JSON.stringify(
-      elements.map(({ shape, ...element }: ExcalidrawElement) => element),
+      elements.map(
+        ({ shape, canvas, ...element }: ExcalidrawElement) => element,
+      ),
     ),
   );
   localStorage.setItem(

+ 4 - 4
src/scene/export.ts

@@ -3,6 +3,7 @@ import { ExcalidrawElement } from "../element/types";
 import { getCommonBounds } from "../element/bounds";
 import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
 import { distance, SVG_NS } from "../utils";
+import { normalizeScroll } from "./data";
 
 export function exportToCanvas(
   elements: readonly ExcalidrawElement[],
@@ -42,15 +43,14 @@ export function exportToCanvas(
     tempCanvas,
     {
       viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
-      scrollX: 0,
-      scrollY: 0,
+      scrollX: normalizeScroll(-minX + exportPadding),
+      scrollY: normalizeScroll(-minY + exportPadding),
       zoom: 1,
     },
     {
-      offsetX: -minX + exportPadding,
-      offsetY: -minY + exportPadding,
       renderScrollbars: false,
       renderSelection: false,
+      renderOptimizations: false,
     },
   );
   return tempCanvas;

+ 5 - 4
src/scene/scrollbars.ts

@@ -1,5 +1,6 @@
 import { ExcalidrawElement } from "../element/types";
 import { getCommonBounds } from "../element";
+import { FlooredNumber } from "../types";
 
 const SCROLLBAR_MARGIN = 4;
 export const SCROLLBAR_WIDTH = 6;
@@ -14,8 +15,8 @@ export function getScrollBars(
     scrollY,
     zoom,
   }: {
-    scrollX: number;
-    scrollY: number;
+    scrollX: FlooredNumber;
+    scrollY: FlooredNumber;
     zoom: number;
   },
 ) {
@@ -93,8 +94,8 @@ export function isOverScrollBars(
     scrollY,
     zoom,
   }: {
-    scrollX: number;
-    scrollY: number;
+    scrollX: FlooredNumber;
+    scrollY: FlooredNumber;
     zoom: number;
   },
 ) {

+ 5 - 4
src/scene/types.ts

@@ -1,16 +1,17 @@
 import { ExcalidrawTextElement } from "../element/types";
+import { FlooredNumber } from "../types";
 
 export type SceneState = {
-  scrollX: number;
-  scrollY: number;
+  scrollX: FlooredNumber;
+  scrollY: FlooredNumber;
   // null indicates transparent bg
   viewBackgroundColor: string | null;
   zoom: number;
 };
 
 export type SceneScroll = {
-  scrollX: number;
-  scrollY: number;
+  scrollX: FlooredNumber;
+  scrollY: FlooredNumber;
 };
 
 export interface Scene {

+ 9 - 0
src/styles.scss

@@ -6,6 +6,15 @@ body {
   color: var(--text-color-primary);
 }
 
+canvas {
+  // following props improve blurriness at certain devicePixelRatios.
+  // AFAIK it doesn't affect export (in fact, export seems sharp either way).
+
+  image-rendering: pixelated; // chromium
+  // NOTE: must be declared *after* the above
+  image-rendering: -moz-crisp-edges; // FF
+}
+
 .container {
   display: flex;
   position: fixed;

+ 4 - 2
src/types.ts

@@ -1,6 +1,8 @@
 import { ExcalidrawElement } from "./element/types";
 import { SHAPES } from "./shapes";
 
+export type FlooredNumber = number & { _brand: "FlooredNumber" };
+
 export type AppState = {
   draggingElement: ExcalidrawElement | null;
   resizingElement: ExcalidrawElement | null;
@@ -20,8 +22,8 @@ export type AppState = {
   currentItemOpacity: number;
   currentItemFont: string;
   viewBackgroundColor: string;
-  scrollX: number;
-  scrollY: number;
+  scrollX: FlooredNumber;
+  scrollY: FlooredNumber;
   cursorX: number;
   cursorY: number;
   scrolledOutside: boolean;