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
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
@ -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) {
|
||||||
|
@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
@ -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(
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user