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>
This commit is contained in:
Christopher Chedeau 2020-02-19 08:25:01 -08:00 committed by GitHub
parent d39c7d4421
commit 5256096d76
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 269 additions and 114 deletions

View File

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

View File

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

View File

@ -36,6 +36,10 @@ export function newElement(
seed: randomSeed(), seed: randomSeed(),
shape: null as Drawable | Drawable[] | null, shape: null as Drawable | Drawable[] | null,
points: [] as Point[], 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; return element;
} }
@ -48,6 +52,7 @@ export function newTextElement(
const metrics = measureText(text, font); const metrics = measureText(text, font);
const textElement: ExcalidrawTextElement = { const textElement: ExcalidrawTextElement = {
...element, ...element,
shape: null,
type: "text", type: "text",
text: text, text: text,
font: font, font: font,

View File

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

View File

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

View File

@ -1,18 +1,111 @@
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { isTextElement } from "../element/typeChecks"; 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 { RoughCanvas } from "roughjs/bin/canvas";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { Point } from "roughjs/bin/geometry"; import { Point } from "roughjs/bin/geometry";
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 { 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( function generateElement(
element: ExcalidrawElement, element: ExcalidrawElement,
generator: RoughGenerator, generator: RoughGenerator,
sceneState?: SceneState,
) { ) {
if (!element.shape) { if (!element.shape) {
element.canvas = null;
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
element.shape = generator.rectangle( element.shape = generator.rectangle(
@ -32,6 +125,7 @@ function generateElement(
seed: element.seed, seed: element.seed,
}, },
); );
break; break;
case "diamond": { case "diamond": {
const [ const [
@ -115,18 +209,64 @@ function generateElement(
} }
break; 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( export function renderElement(
element: ExcalidrawElement, element: ExcalidrawElement,
rc: RoughCanvas, rc: RoughCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderOptimizations: boolean,
sceneState: SceneState,
) { ) {
const generator = rc.generator; const generator = rc.generator;
switch (element.type) { switch (element.type) {
case "selection": { case "selection": {
context.translate(
element.x + sceneState.scrollX,
element.y + sceneState.scrollY,
);
const fillStyle = context.fillStyle; 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);
@ -136,39 +276,24 @@ export function renderElement(
case "rectangle": case "rectangle":
case "diamond": case "diamond":
case "ellipse": case "ellipse":
generateElement(element, generator);
context.globalAlpha = element.opacity / 100;
rc.draw(element.shape as Drawable);
context.globalAlpha = 1;
break;
case "line": case "line":
case "arrow": { case "arrow":
generateElement(element, generator); case "text": {
context.globalAlpha = element.opacity / 100; generateElement(element, generator, sceneState);
(element.shape as Drawable[]).forEach(shape => rc.draw(shape));
context.globalAlpha = 1; 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; break;
} }
default: { default: {
if (isTextElement(element)) { throw new Error(`Unimplemented type ${element.type}`);
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}`);
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,15 @@ body {
color: var(--text-color-primary); 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 { .container {
display: flex; display: flex;
position: fixed; position: fixed;

View File

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