fix: export scale quality regression (#4316)

This commit is contained in:
David Luzar 2021-11-25 14:05:22 +01:00 committed by GitHub
parent f9d2d537a2
commit 8ff159e76e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 179 additions and 172 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,32 @@
import { ExcalidrawTextElement } from "../element/types"; import { ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState, Zoom } from "../types"; import { AppClassProperties, AppState } from "../types";
export type SceneState = { export type RenderConfig = {
scrollX: number; // AppState values
scrollY: number; // ---------------------------------------------------------------------------
// null indicates transparent bg scrollX: AppState["scrollX"];
viewBackgroundColor: string | null; scrollY: AppState["scrollY"];
zoom: Zoom; /** null indicates transparent bg */
shouldCacheIgnoreZoom: boolean; viewBackgroundColor: AppState["viewBackgroundColor"] | null;
zoom: AppState["zoom"];
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
theme: AppState["theme"];
// collab-related state
// ---------------------------------------------------------------------------
remotePointerViewportCoords: { [id: string]: { x: number; y: number } }; remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
remotePointerButton?: { [id: string]: string | undefined }; remotePointerButton?: { [id: string]: string | undefined };
remoteSelectedElementIds: { [elementId: string]: string[] }; remoteSelectedElementIds: { [elementId: string]: string[] };
remotePointerUsernames: { [id: string]: string }; remotePointerUsernames: { [id: string]: string };
remotePointerUserStates: { [id: string]: string }; remotePointerUserStates: { [id: string]: string };
theme: AppState["theme"]; // extra options passed to the renderer
// ---------------------------------------------------------------------------
imageCache: AppClassProperties["imageCache"]; 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 = { export type SceneScroll = {