Refactor ExcalidrawElement (#874)

* Get rid of isSelected, canvas, canvasZoom, canvasOffsetX and canvasOffsetY on ExcalidrawElement.

* Fix most unit tests. Fix cmd a. Fix alt drag

* Focus on paste

* shift select should include previously selected items

* Fix last test

* Move this.shape out of ExcalidrawElement and into a WeakMap
This commit is contained in:
Pete Hunt 2020-03-08 10:20:55 -07:00 committed by GitHub
parent 8ecb4201db
commit ccbbdb75a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 416 additions and 306 deletions

View file

@ -16,12 +16,26 @@ import rough from "roughjs/bin/rough";
const CANVAS_PADDING = 20;
function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
export interface ExcalidrawElementWithCanvas {
element: ExcalidrawElement | ExcalidrawTextElement;
canvas: HTMLCanvasElement;
canvasZoom: number;
canvasOffsetX: number;
canvasOffsetY: number;
}
function generateElementCanvas(
element: ExcalidrawElement,
zoom: number,
): ExcalidrawElementWithCanvas {
const canvas = document.createElement("canvas");
var context = canvas.getContext("2d")!;
const context = canvas.getContext("2d")!;
const isLinear = /\b(arrow|line)\b/.test(element.type);
let canvasOffsetX = 0;
let canvasOffsetY = 0;
if (isLinear) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
canvas.width =
@ -29,18 +43,15 @@ function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
canvas.height =
distance(y1, y2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
element.canvasOffsetX =
canvasOffsetX =
element.x > x1
? Math.floor(distance(element.x, x1)) * window.devicePixelRatio
: 0;
element.canvasOffsetY =
canvasOffsetY =
element.y > y1
? Math.floor(distance(element.y, y1)) * window.devicePixelRatio
: 0;
context.translate(
element.canvasOffsetX * zoom,
element.canvasOffsetY * zoom,
);
context.translate(canvasOffsetX * zoom, canvasOffsetY * zoom);
} else {
canvas.width =
element.width * window.devicePixelRatio * zoom + CANVAS_PADDING * 2;
@ -53,9 +64,8 @@ function generateElementCanvas(element: ExcalidrawElement, zoom: number) {
const rc = rough.canvas(canvas);
drawElementOnCanvas(element, rc, context);
element.canvas = canvas;
element.canvasZoom = zoom;
context.translate(-CANVAS_PADDING, -CANVAS_PADDING);
return { element, canvas, canvasZoom: zoom, canvasOffsetX, canvasOffsetY };
}
function drawElementOnCanvas(
@ -68,12 +78,14 @@ function drawElementOnCanvas(
case "rectangle":
case "diamond":
case "ellipse": {
rc.draw(element.shape as Drawable);
rc.draw(getShapeForElement(element) as Drawable);
break;
}
case "arrow":
case "line": {
(element.shape as Drawable[]).forEach(shape => rc.draw(shape));
(getShapeForElement(element) as Drawable[]).forEach(shape =>
rc.draw(shape),
);
break;
}
default: {
@ -99,32 +111,44 @@ function drawElementOnCanvas(
context.globalAlpha = 1;
}
const elementWithCanvasCache = new WeakMap<
ExcalidrawElement,
ExcalidrawElementWithCanvas
>();
const shapeCache = new WeakMap<
ExcalidrawElement,
Drawable | Drawable[] | null
>();
export function getShapeForElement(element: ExcalidrawElement) {
return shapeCache.get(element);
}
export function invalidateShapeForElement(element: ExcalidrawElement) {
shapeCache.delete(element);
}
function generateElement(
element: ExcalidrawElement,
generator: RoughGenerator,
sceneState?: SceneState,
) {
if (!element.shape) {
element.canvas = null;
let shape = shapeCache.get(element) || null;
if (!shape) {
switch (element.type) {
case "rectangle":
element.shape = generator.rectangle(
0,
0,
element.width,
element.height,
{
stroke: element.strokeColor,
fill:
element.backgroundColor === "transparent"
? undefined
: element.backgroundColor,
fillStyle: element.fillStyle,
strokeWidth: element.strokeWidth,
roughness: element.roughness,
seed: element.seed,
},
);
shape = generator.rectangle(0, 0, element.width, element.height, {
stroke: element.strokeColor,
fill:
element.backgroundColor === "transparent"
? undefined
: element.backgroundColor,
fillStyle: element.fillStyle,
strokeWidth: element.strokeWidth,
roughness: element.roughness,
seed: element.seed,
});
break;
case "diamond": {
@ -138,7 +162,7 @@ function generateElement(
leftX,
leftY,
] = getDiamondPoints(element);
element.shape = generator.polygon(
shape = generator.polygon(
[
[topX, topY],
[rightX, rightY],
@ -160,7 +184,7 @@ function generateElement(
break;
}
case "ellipse":
element.shape = generator.ellipse(
shape = generator.ellipse(
element.width / 2,
element.height / 2,
element.width,
@ -195,12 +219,12 @@ function generateElement(
// curve is always the first element
// this simplifies finding the curve for an element
element.shape = [generator.curve(points, options)];
shape = [generator.curve(points, options)];
// add lines only in arrow
if (element.type === "arrow") {
const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
element.shape.push(
const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element, shape);
shape.push(
...[
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
@ -211,19 +235,22 @@ function generateElement(
}
case "text": {
// just to ensure we don't regenerate element.canvas on rerenders
element.shape = [];
shape = [];
break;
}
}
shapeCache.set(element, shape);
}
const zoom = sceneState ? sceneState.zoom : 1;
if (!element.canvas || element.canvasZoom !== zoom) {
generateElementCanvas(element, zoom);
const prevElementWithCanvas = elementWithCanvasCache.get(element);
if (!prevElementWithCanvas || prevElementWithCanvas.canvasZoom !== zoom) {
return generateElementCanvas(element, zoom);
}
return prevElementWithCanvas;
}
function drawElementFromCanvas(
element: ExcalidrawElement | ExcalidrawTextElement,
elementWithCanvas: ExcalidrawElementWithCanvas,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
sceneState: SceneState,
@ -234,17 +261,19 @@ function drawElementFromCanvas(
-CANVAS_PADDING / sceneState.zoom,
);
context.drawImage(
element.canvas!,
elementWithCanvas.canvas!,
Math.floor(
-element.canvasOffsetX +
(Math.floor(element.x) + sceneState.scrollX) * window.devicePixelRatio,
-elementWithCanvas.canvasOffsetX +
(Math.floor(elementWithCanvas.element.x) + sceneState.scrollX) *
window.devicePixelRatio,
),
Math.floor(
-element.canvasOffsetY +
(Math.floor(element.y) + sceneState.scrollY) * window.devicePixelRatio,
-elementWithCanvas.canvasOffsetY +
(Math.floor(elementWithCanvas.element.y) + sceneState.scrollY) *
window.devicePixelRatio,
),
element.canvas!.width / sceneState.zoom,
element.canvas!.height / sceneState.zoom,
elementWithCanvas.canvas!.width / sceneState.zoom,
elementWithCanvas.canvas!.height / sceneState.zoom,
);
context.translate(
CANVAS_PADDING / sceneState.zoom,
@ -279,10 +308,10 @@ export function renderElement(
case "line":
case "arrow":
case "text": {
generateElement(element, generator, sceneState);
const elementWithCanvas = generateElement(element, generator, sceneState);
if (renderOptimizations) {
drawElementFromCanvas(element, rc, context, sceneState);
drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
} else {
const offsetX = Math.floor(element.x + sceneState.scrollX);
const offsetY = Math.floor(element.y + sceneState.scrollY);
@ -316,7 +345,7 @@ export function renderElementToSvg(
case "diamond":
case "ellipse": {
generateElement(element, generator);
const node = rsvg.draw(element.shape as Drawable);
const node = rsvg.draw(getShapeForElement(element) as Drawable);
const opacity = element.opacity / 100;
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
@ -334,7 +363,7 @@ export function renderElementToSvg(
generateElement(element, generator);
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
const opacity = element.opacity / 100;
(element.shape as Drawable[]).forEach(shape => {
(getShapeForElement(element) as Drawable[]).forEach(shape => {
const node = rsvg.draw(shape);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);