浏览代码

fix: export scale quality regression (#4316)

David Luzar 3 年之前
父节点
当前提交
8ff159e76e
共有 5 个文件被更改,包括 179 次插入172 次删除
  1. 6 6
      src/components/App.tsx
  2. 62 45
      src/renderer/renderElement.ts
  3. 73 84
      src/renderer/renderScene.ts
  4. 17 28
      src/scene/export.ts
  5. 21 9
      src/scene/types.ts

+ 6 - 6
src/components/App.tsx

@@ -174,7 +174,7 @@ import {
   isSomeElementSelected,
 } from "../scene";
 import Scene from "../scene/Scene";
-import { SceneState, ScrollBars } from "../scene/types";
+import { RenderConfig, ScrollBars } from "../scene/types";
 import { getNewZoom } from "../scene/zoom";
 import { findShapeByKey } from "../shapes";
 import {
@@ -1053,8 +1053,10 @@ class App extends React.Component<AppProps, AppState> {
     const cursorButton: {
       [id: string]: string | undefined;
     } = {};
-    const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
-    const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
+    const pointerViewportCoords: RenderConfig["remotePointerViewportCoords"] =
+      {};
+    const remoteSelectedElementIds: RenderConfig["remoteSelectedElementIds"] =
+      {};
     const pointerUsernames: { [id: string]: string } = {};
     const pointerUserStates: { [id: string]: string } = {};
     this.state.collaborators.forEach((user, socketId) => {
@@ -1122,9 +1124,7 @@ class App extends React.Component<AppProps, AppState> {
         shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
         theme: this.state.theme,
         imageCache: this.imageCache,
-      },
-      {
-        renderOptimizations: true,
+        isExporting: false,
         renderScrollbars: !this.isMobile,
       },
     );

+ 62 - 45
src/renderer/renderElement.ts

@@ -22,7 +22,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
 import { Drawable, Options } from "roughjs/bin/core";
 import { RoughSVG } from "roughjs/bin/svg";
 import { RoughGenerator } from "roughjs/bin/generator";
-import { SceneState } from "../scene/types";
+import { RenderConfig } from "../scene/types";
 import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
 import { isPathALoop } from "../math";
 import rough from "roughjs/bin/rough";
@@ -41,10 +41,22 @@ const defaultAppState = getDefaultAppState();
 
 const isPendingImageElement = (
   element: ExcalidrawElement,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
 ) =>
   isInitializedImageElement(element) &&
-  !sceneState.imageCache.has(element.fileId);
+  !renderConfig.imageCache.has(element.fileId);
+
+const shouldResetImageFilter = (
+  element: ExcalidrawElement,
+  renderConfig: RenderConfig,
+) => {
+  return (
+    renderConfig.theme === "dark" &&
+    isInitializedImageElement(element) &&
+    !isPendingImageElement(element, renderConfig) &&
+    renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
+  );
+};
 
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
 
@@ -56,7 +68,7 @@ const getCanvasPadding = (element: ExcalidrawElement) =>
 export interface ExcalidrawElementWithCanvas {
   element: ExcalidrawElement | ExcalidrawTextElement;
   canvas: HTMLCanvasElement;
-  theme: SceneState["theme"];
+  theme: RenderConfig["theme"];
   canvasZoom: Zoom["value"];
   canvasOffsetX: number;
   canvasOffsetY: number;
@@ -65,7 +77,7 @@ export interface ExcalidrawElementWithCanvas {
 const generateElementCanvas = (
   element: NonDeletedExcalidrawElement,
   zoom: Zoom,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
 ): ExcalidrawElementWithCanvas => {
   const canvas = document.createElement("canvas");
   const context = canvas.getContext("2d")!;
@@ -123,22 +135,17 @@ const generateElementCanvas = (
   const rc = rough.canvas(canvas);
 
   // in dark theme, revert the image color filter
-  if (
-    sceneState.theme === "dark" &&
-    isInitializedImageElement(element) &&
-    !isPendingImageElement(element, sceneState) &&
-    sceneState.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
-  ) {
+  if (shouldResetImageFilter(element, renderConfig)) {
     context.filter = IMAGE_INVERT_FILTER;
   }
 
-  drawElementOnCanvas(element, rc, context, sceneState);
+  drawElementOnCanvas(element, rc, context, renderConfig);
   context.restore();
 
   return {
     element,
     canvas,
-    theme: sceneState.theme,
+    theme: renderConfig.theme,
     canvasZoom: zoom.value,
     canvasOffsetX,
     canvasOffsetY,
@@ -185,7 +192,7 @@ const drawElementOnCanvas = (
   element: NonDeletedExcalidrawElement,
   rc: RoughCanvas,
   context: CanvasRenderingContext2D,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
 ) => {
   context.globalAlpha = element.opacity / 100;
   switch (element.type) {
@@ -222,7 +229,7 @@ const drawElementOnCanvas = (
     }
     case "image": {
       const img = isInitializedImageElement(element)
-        ? sceneState.imageCache.get(element.fileId)?.image
+        ? renderConfig.imageCache.get(element.fileId)?.image
         : undefined;
       if (img != null && !(img instanceof Promise)) {
         context.drawImage(
@@ -233,7 +240,7 @@ const drawElementOnCanvas = (
           element.height,
         );
       } else {
-        drawImagePlaceholder(element, context, sceneState.zoom.value);
+        drawImagePlaceholder(element, context, renderConfig.zoom.value);
       }
       break;
     }
@@ -566,21 +573,25 @@ const generateElementShape = (
 
 const generateElementWithCanvas = (
   element: NonDeletedExcalidrawElement,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
 ) => {
-  const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
+  const zoom: Zoom = renderConfig ? renderConfig.zoom : defaultAppState.zoom;
   const prevElementWithCanvas = elementWithCanvasCache.get(element);
   const shouldRegenerateBecauseZoom =
     prevElementWithCanvas &&
     prevElementWithCanvas.canvasZoom !== zoom.value &&
-    !sceneState?.shouldCacheIgnoreZoom;
+    !renderConfig?.shouldCacheIgnoreZoom;
 
   if (
     !prevElementWithCanvas ||
     shouldRegenerateBecauseZoom ||
-    prevElementWithCanvas.theme !== sceneState.theme
+    prevElementWithCanvas.theme !== renderConfig.theme
   ) {
-    const elementWithCanvas = generateElementCanvas(element, zoom, sceneState);
+    const elementWithCanvas = generateElementCanvas(
+      element,
+      zoom,
+      renderConfig,
+    );
 
     elementWithCanvasCache.set(element, elementWithCanvas);
 
@@ -593,7 +604,7 @@ const drawElementFromCanvas = (
   elementWithCanvas: ExcalidrawElementWithCanvas,
   rc: RoughCanvas,
   context: CanvasRenderingContext2D,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
 ) => {
   const element = elementWithCanvas.element;
   const padding = getCanvasPadding(element);
@@ -607,10 +618,10 @@ const drawElementFromCanvas = (
     y2 = Math.ceil(y2);
   }
 
-  const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
-  const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
+  const cx = ((x1 + x2) / 2 + renderConfig.scrollX) * window.devicePixelRatio;
+  const cy = ((y1 + y2) / 2 + renderConfig.scrollY) * window.devicePixelRatio;
 
-  const _isPendingImageElement = isPendingImageElement(element, sceneState);
+  const _isPendingImageElement = isPendingImageElement(element, renderConfig);
 
   const scaleXFactor =
     "scale" in elementWithCanvas.element && !_isPendingImageElement
@@ -647,16 +658,15 @@ export const renderElement = (
   element: NonDeletedExcalidrawElement,
   rc: RoughCanvas,
   context: CanvasRenderingContext2D,
-  renderOptimizations: boolean,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
 ) => {
   const generator = rc.generator;
   switch (element.type) {
     case "selection": {
       context.save();
       context.translate(
-        element.x + sceneState.scrollX,
-        element.y + sceneState.scrollY,
+        element.x + renderConfig.scrollX,
+        element.y + renderConfig.scrollY,
       );
       context.fillStyle = "rgba(0, 0, 255, 0.10)";
       context.fillRect(0, 0, element.width, element.height);
@@ -666,23 +676,23 @@ export const renderElement = (
     case "freedraw": {
       generateElementShape(element, generator);
 
-      if (renderOptimizations) {
+      if (renderConfig.isExporting) {
         const elementWithCanvas = generateElementWithCanvas(
           element,
-          sceneState,
+          renderConfig,
         );
-        drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
+        drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
       } else {
         const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-        const cx = (x1 + x2) / 2 + sceneState.scrollX;
-        const cy = (y1 + y2) / 2 + sceneState.scrollY;
+        const cx = (x1 + x2) / 2 + renderConfig.scrollX;
+        const cy = (y1 + y2) / 2 + renderConfig.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, sceneState);
+        drawElementOnCanvas(element, rc, context, renderConfig);
         context.restore();
       }
 
@@ -696,24 +706,31 @@ export const renderElement = (
     case "image":
     case "text": {
       generateElementShape(element, generator);
-      if (renderOptimizations) {
-        const elementWithCanvas = generateElementWithCanvas(
-          element,
-          sceneState,
-        );
-        drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
-      } else {
+      if (renderConfig.isExporting) {
         const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-        const cx = (x1 + x2) / 2 + sceneState.scrollX;
-        const cy = (y1 + y2) / 2 + sceneState.scrollY;
+        const cx = (x1 + x2) / 2 + renderConfig.scrollX;
+        const cy = (y1 + y2) / 2 + renderConfig.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, sceneState);
+
+        if (shouldResetImageFilter(element, renderConfig)) {
+          context.filter = "none";
+        }
+
+        drawElementOnCanvas(element, rc, context, renderConfig);
         context.restore();
+        // not exporting → optimized rendering (cache & render from element
+        // canvases)
+      } else {
+        const elementWithCanvas = generateElementWithCanvas(
+          element,
+          renderConfig,
+        );
+        drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
       }
       break;
     }

+ 73 - 84
src/renderer/renderScene.ts

@@ -21,7 +21,7 @@ import {
 } from "../element";
 
 import { roundRect } from "./roundRect";
-import { SceneState } from "../scene/types";
+import { RenderConfig } from "../scene/types";
 import {
   getScrollBars,
   SCROLLBAR_COLOR,
@@ -146,12 +146,12 @@ const strokeGrid = (
 const renderLinearPointHandles = (
   context: CanvasRenderingContext2D,
   appState: AppState,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
   element: NonDeleted<ExcalidrawLinearElement>,
 ) => {
   context.save();
-  context.translate(sceneState.scrollX, sceneState.scrollY);
-  context.lineWidth = 1 / sceneState.zoom.value;
+  context.translate(renderConfig.scrollX, renderConfig.scrollY);
+  context.lineWidth = 1 / renderConfig.zoom.value;
 
   LinearElementEditor.getPointsGlobalCoordinates(element).forEach(
     (point, idx) => {
@@ -166,7 +166,7 @@ const renderLinearPointHandles = (
         context,
         point[0],
         point[1],
-        POINT_HANDLE_SIZE / 2 / sceneState.zoom.value,
+        POINT_HANDLE_SIZE / 2 / renderConfig.zoom.value,
       );
     },
   );
@@ -180,31 +180,20 @@ export const renderScene = (
   scale: number,
   rc: RoughCanvas,
   canvas: HTMLCanvasElement,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
   // extra options passed to the renderer
-  {
-    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,
-    renderGrid = true,
-    /** when exporting the behavior is slightly different (e.g. we can't use
-        CSS filters) */
-    isExport = false,
-  }: {
-    renderScrollbars?: boolean;
-    renderSelection?: boolean;
-    renderOptimizations?: boolean;
-    renderGrid?: boolean;
-    isExport?: boolean;
-  } = {},
 ) => {
   if (canvas === null) {
     return { atLeastOneVisibleElement: false };
   }
 
+  const {
+    renderScrollbars = true,
+    renderSelection = true,
+    renderGrid = true,
+    isExporting,
+  } = renderConfig;
+
   const context = canvas.getContext("2d")!;
 
   context.setTransform(1, 0, 0, 1, 0, 0);
@@ -215,22 +204,22 @@ export const renderScene = (
   const normalizedCanvasWidth = canvas.width / scale;
   const normalizedCanvasHeight = canvas.height / scale;
 
-  if (isExport && sceneState.theme === "dark") {
+  if (isExporting && renderConfig.theme === "dark") {
     context.filter = THEME_FILTER;
   }
 
   // Paint background
-  if (typeof sceneState.viewBackgroundColor === "string") {
+  if (typeof renderConfig.viewBackgroundColor === "string") {
     const hasTransparence =
-      sceneState.viewBackgroundColor === "transparent" ||
-      sceneState.viewBackgroundColor.length === 5 || // #RGBA
-      sceneState.viewBackgroundColor.length === 9 || // #RRGGBBA
-      /(hsla|rgba)\(/.test(sceneState.viewBackgroundColor);
+      renderConfig.viewBackgroundColor === "transparent" ||
+      renderConfig.viewBackgroundColor.length === 5 || // #RGBA
+      renderConfig.viewBackgroundColor.length === 9 || // #RRGGBBA
+      /(hsla|rgba)\(/.test(renderConfig.viewBackgroundColor);
     if (hasTransparence) {
       context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
     }
     context.save();
-    context.fillStyle = sceneState.viewBackgroundColor;
+    context.fillStyle = renderConfig.viewBackgroundColor;
     context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
     context.restore();
   } else {
@@ -238,42 +227,46 @@ export const renderScene = (
   }
 
   // Apply zoom
-  const zoomTranslationX = sceneState.zoom.translation.x;
-  const zoomTranslationY = sceneState.zoom.translation.y;
+  const zoomTranslationX = renderConfig.zoom.translation.x;
+  const zoomTranslationY = renderConfig.zoom.translation.y;
   context.save();
   context.translate(zoomTranslationX, zoomTranslationY);
-  context.scale(sceneState.zoom.value, sceneState.zoom.value);
+  context.scale(renderConfig.zoom.value, renderConfig.zoom.value);
 
   // Grid
   if (renderGrid && appState.gridSize) {
     strokeGrid(
       context,
       appState.gridSize,
-      -Math.ceil(zoomTranslationX / sceneState.zoom.value / appState.gridSize) *
+      -Math.ceil(
+        zoomTranslationX / renderConfig.zoom.value / appState.gridSize,
+      ) *
         appState.gridSize +
-        (sceneState.scrollX % appState.gridSize),
-      -Math.ceil(zoomTranslationY / sceneState.zoom.value / appState.gridSize) *
+        (renderConfig.scrollX % appState.gridSize),
+      -Math.ceil(
+        zoomTranslationY / renderConfig.zoom.value / appState.gridSize,
+      ) *
         appState.gridSize +
-        (sceneState.scrollY % appState.gridSize),
-      normalizedCanvasWidth / sceneState.zoom.value,
-      normalizedCanvasHeight / sceneState.zoom.value,
+        (renderConfig.scrollY % appState.gridSize),
+      normalizedCanvasWidth / renderConfig.zoom.value,
+      normalizedCanvasHeight / renderConfig.zoom.value,
     );
   }
 
   // Paint visible elements
   const visibleElements = elements.filter((element) =>
     isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, {
-      zoom: sceneState.zoom,
+      zoom: renderConfig.zoom,
       offsetLeft: appState.offsetLeft,
       offsetTop: appState.offsetTop,
-      scrollX: sceneState.scrollX,
-      scrollY: sceneState.scrollY,
+      scrollX: renderConfig.scrollX,
+      scrollY: renderConfig.scrollY,
     }),
   );
 
   visibleElements.forEach((element) => {
     try {
-      renderElement(element, rc, context, renderOptimizations, sceneState);
+      renderElement(element, rc, context, renderConfig);
     } catch (error: any) {
       console.error(error);
     }
@@ -284,20 +277,14 @@ export const renderScene = (
       appState.editingLinearElement.elementId,
     );
     if (element) {
-      renderLinearPointHandles(context, appState, sceneState, element);
+      renderLinearPointHandles(context, appState, renderConfig, element);
     }
   }
 
   // Paint selection element
   if (selectionElement) {
     try {
-      renderElement(
-        selectionElement,
-        rc,
-        context,
-        renderOptimizations,
-        sceneState,
-      );
+      renderElement(selectionElement, rc, context, renderConfig);
     } catch (error: any) {
       console.error(error);
     }
@@ -307,7 +294,7 @@ export const renderScene = (
     appState.suggestedBindings
       .filter((binding) => binding != null)
       .forEach((suggestedBinding) => {
-        renderBindingHighlight(context, sceneState, suggestedBinding!);
+        renderBindingHighlight(context, renderConfig, suggestedBinding!);
       });
   }
 
@@ -327,12 +314,14 @@ export const renderScene = (
         selectionColors.push(oc.black);
       }
       // remote users
-      if (sceneState.remoteSelectedElementIds[element.id]) {
+      if (renderConfig.remoteSelectedElementIds[element.id]) {
         selectionColors.push(
-          ...sceneState.remoteSelectedElementIds[element.id].map((socketId) => {
-            const { background } = getClientColors(socketId, appState);
-            return background;
-          }),
+          ...renderConfig.remoteSelectedElementIds[element.id].map(
+            (socketId) => {
+              const { background } = getClientColors(socketId, appState);
+              return background;
+            },
+          ),
         );
       }
       if (selectionColors.length) {
@@ -374,37 +363,37 @@ export const renderScene = (
     }
 
     selections.forEach((selection) =>
-      renderSelectionBorder(context, sceneState, selection),
+      renderSelectionBorder(context, renderConfig, selection),
     );
 
     const locallySelectedElements = getSelectedElements(elements, appState);
 
     // Paint resize transformHandles
     context.save();
-    context.translate(sceneState.scrollX, sceneState.scrollY);
+    context.translate(renderConfig.scrollX, renderConfig.scrollY);
     if (locallySelectedElements.length === 1) {
       context.fillStyle = oc.white;
       const transformHandles = getTransformHandles(
         locallySelectedElements[0],
-        sceneState.zoom,
+        renderConfig.zoom,
         "mouse", // when we render we don't know which pointer type so use mouse
       );
       if (!appState.viewModeEnabled) {
         renderTransformHandles(
           context,
-          sceneState,
+          renderConfig,
           transformHandles,
           locallySelectedElements[0].angle,
         );
       }
     } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
-      const dashedLinePadding = 4 / sceneState.zoom.value;
+      const dashedLinePadding = 4 / renderConfig.zoom.value;
       context.fillStyle = oc.white;
       const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
       const initialLineDash = context.getLineDash();
-      context.setLineDash([2 / sceneState.zoom.value]);
+      context.setLineDash([2 / renderConfig.zoom.value]);
       const lineWidth = context.lineWidth;
-      context.lineWidth = 1 / sceneState.zoom.value;
+      context.lineWidth = 1 / renderConfig.zoom.value;
       strokeRectWithRotation(
         context,
         x1 - dashedLinePadding,
@@ -420,11 +409,11 @@ export const renderScene = (
       const transformHandles = getTransformHandlesFromCoords(
         [x1, y1, x2, y2],
         0,
-        sceneState.zoom,
+        renderConfig.zoom,
         "mouse",
         OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
       );
-      renderTransformHandles(context, sceneState, transformHandles, 0);
+      renderTransformHandles(context, renderConfig, transformHandles, 0);
     }
     context.restore();
   }
@@ -433,8 +422,8 @@ export const renderScene = (
   context.restore();
 
   // Paint remote pointers
-  for (const clientId in sceneState.remotePointerViewportCoords) {
-    let { x, y } = sceneState.remotePointerViewportCoords[clientId];
+  for (const clientId in renderConfig.remotePointerViewportCoords) {
+    let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
 
     x -= appState.offsetLeft;
     y -= appState.offsetTop;
@@ -459,14 +448,14 @@ export const renderScene = (
     context.strokeStyle = stroke;
     context.fillStyle = background;
 
-    const userState = sceneState.remotePointerUserStates[clientId];
+    const userState = renderConfig.remotePointerUserStates[clientId];
     if (isOutOfBounds || userState === UserIdleState.AWAY) {
       context.globalAlpha = 0.48;
     }
 
     if (
-      sceneState.remotePointerButton &&
-      sceneState.remotePointerButton[clientId] === "down"
+      renderConfig.remotePointerButton &&
+      renderConfig.remotePointerButton[clientId] === "down"
     ) {
       context.beginPath();
       context.arc(x, y, 15, 0, 2 * Math.PI, false);
@@ -492,7 +481,7 @@ export const renderScene = (
     context.fill();
     context.stroke();
 
-    const username = sceneState.remotePointerUsernames[clientId];
+    const username = renderConfig.remotePointerUsernames[clientId];
 
     let idleState = "";
     if (userState === UserIdleState.AWAY) {
@@ -552,7 +541,7 @@ export const renderScene = (
       elements,
       normalizedCanvasWidth,
       normalizedCanvasHeight,
-      sceneState,
+      renderConfig,
     );
 
     context.save();
@@ -579,7 +568,7 @@ export const renderScene = (
 
 const renderTransformHandles = (
   context: CanvasRenderingContext2D,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
   transformHandles: TransformHandles,
   angle: number,
 ): void => {
@@ -587,7 +576,7 @@ const renderTransformHandles = (
     const transformHandle = transformHandles[key as TransformHandleType];
     if (transformHandle !== undefined) {
       context.save();
-      context.lineWidth = 1 / sceneState.zoom.value;
+      context.lineWidth = 1 / renderConfig.zoom.value;
       if (key === "rotation") {
         fillCircle(
           context,
@@ -615,7 +604,7 @@ const renderTransformHandles = (
 
 const renderSelectionBorder = (
   context: CanvasRenderingContext2D,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
   elementProperties: {
     angle: number;
     elementX1: number;
@@ -630,13 +619,13 @@ const renderSelectionBorder = (
   const elementWidth = elementX2 - elementX1;
   const elementHeight = elementY2 - elementY1;
 
-  const dashedLinePadding = 4 / sceneState.zoom.value;
-  const dashWidth = 8 / sceneState.zoom.value;
-  const spaceWidth = 4 / sceneState.zoom.value;
+  const dashedLinePadding = 4 / renderConfig.zoom.value;
+  const dashWidth = 8 / renderConfig.zoom.value;
+  const spaceWidth = 4 / renderConfig.zoom.value;
 
   context.save();
-  context.translate(sceneState.scrollX, sceneState.scrollY);
-  context.lineWidth = 1 / sceneState.zoom.value;
+  context.translate(renderConfig.scrollX, renderConfig.scrollY);
+  context.lineWidth = 1 / renderConfig.zoom.value;
 
   const count = selectionColors.length;
   for (let index = 0; index < count; ++index) {
@@ -662,7 +651,7 @@ const renderSelectionBorder = (
 
 const renderBindingHighlight = (
   context: CanvasRenderingContext2D,
-  sceneState: SceneState,
+  renderConfig: RenderConfig,
   suggestedBinding: SuggestedBinding,
 ) => {
   const renderHighlight = Array.isArray(suggestedBinding)
@@ -670,7 +659,7 @@ const renderBindingHighlight = (
     : renderBindingHighlightForBindableElement;
 
   context.save();
-  context.translate(sceneState.scrollX, sceneState.scrollY);
+  context.translate(renderConfig.scrollX, renderConfig.scrollY);
   renderHighlight(context, suggestedBinding as any);
 
   context.restore();

+ 17 - 28
src/scene/export.ts

@@ -51,34 +51,23 @@ export const exportToCanvas = async (
     files,
   });
 
-  renderScene(
-    elements,
-    appState,
-    null,
-    scale,
-    rough.canvas(canvas),
-    canvas,
-    {
-      viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
-      scrollX: -minX + exportPadding,
-      scrollY: -minY + exportPadding,
-      zoom: defaultAppState.zoom,
-      remotePointerViewportCoords: {},
-      remoteSelectedElementIds: {},
-      shouldCacheIgnoreZoom: false,
-      remotePointerUsernames: {},
-      remotePointerUserStates: {},
-      theme: appState.exportWithDarkMode ? "dark" : "light",
-      imageCache,
-    },
-    {
-      renderScrollbars: false,
-      renderSelection: false,
-      renderOptimizations: true,
-      renderGrid: false,
-      isExport: true,
-    },
-  );
+  renderScene(elements, appState, null, scale, rough.canvas(canvas), canvas, {
+    viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
+    scrollX: -minX + exportPadding,
+    scrollY: -minY + exportPadding,
+    zoom: defaultAppState.zoom,
+    remotePointerViewportCoords: {},
+    remoteSelectedElementIds: {},
+    shouldCacheIgnoreZoom: false,
+    remotePointerUsernames: {},
+    remotePointerUserStates: {},
+    theme: appState.exportWithDarkMode ? "dark" : "light",
+    imageCache,
+    renderScrollbars: false,
+    renderSelection: false,
+    renderGrid: false,
+    isExporting: true,
+  });
 
   return canvas;
 };

+ 21 - 9
src/scene/types.ts

@@ -1,20 +1,32 @@
 import { ExcalidrawTextElement } from "../element/types";
-import { AppClassProperties, AppState, Zoom } from "../types";
+import { AppClassProperties, AppState } from "../types";
 
-export type SceneState = {
-  scrollX: number;
-  scrollY: number;
-  // null indicates transparent bg
-  viewBackgroundColor: string | null;
-  zoom: Zoom;
-  shouldCacheIgnoreZoom: boolean;
+export type RenderConfig = {
+  // AppState values
+  // ---------------------------------------------------------------------------
+  scrollX: AppState["scrollX"];
+  scrollY: AppState["scrollY"];
+  /** null indicates transparent bg */
+  viewBackgroundColor: AppState["viewBackgroundColor"] | null;
+  zoom: AppState["zoom"];
+  shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
+  theme: AppState["theme"];
+  // collab-related state
+  // ---------------------------------------------------------------------------
   remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
   remotePointerButton?: { [id: string]: string | undefined };
   remoteSelectedElementIds: { [elementId: string]: string[] };
   remotePointerUsernames: { [id: string]: string };
   remotePointerUserStates: { [id: string]: string };
-  theme: AppState["theme"];
+  // extra options passed to the renderer
+  // ---------------------------------------------------------------------------
   imageCache: AppClassProperties["imageCache"];
+  renderScrollbars?: boolean;
+  renderSelection?: boolean;
+  renderGrid?: boolean;
+  /** when exporting the behavior is slightly different (e.g. we can't use
+    CSS filters), and we disable render optimizations for best output */
+  isExporting: boolean;
 };
 
 export type SceneScroll = {