mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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:
parent
d39c7d4421
commit
5256096d76
13 changed files with 269 additions and 114 deletions
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue