Infinite Canvas
One of the features that makes Excalidraw stand out is its “infinite canvas”, which is a feature that allows users to continue expanding the canvas while the actual size, that is the width and the height, of the canvas element stays the same.
By simply scrolling left, right, up, or down, we get more space on the canvas to work with. This allows Excalidraw to exceed the limitations of physical whiteboards and makes it easy for users to work with large scenes.
How can we implement this? Let’s first take a look at a very simple solution in vanilla JS.
Simple Implementation of Infinite Canvas
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite Canvas</title>
</head>
<body>
<canvas id="canvas">Infinite Canvas</canvas>
<script>
const canvas = document.getElementById('canvas')
canvas.width = window.innerWidth
canvas.height = window.innerHeight
const context = canvas.getContext('2d')
let canvasScroll = {
x: 0,
y: 0
}
class Scene {
elements = [
{
type: 'rect',
x: canvas.width / 2,
y: canvas.height / 2,
width: 200,
height: 200,
color: 'rgba(55, 55, 10, 0.3)',
},
{
type: 'rect',
x: 100,
y: 300,
width: 200,
height: 200,
color: 'rgba(155, 200, 10, 0.5)',
}
]
updateScene (offset) {
for (let element of this.elements) {
element.x -= offset.x
element.y -= offset.y
}
this.renderScene()
}
renderScene () {
context.clearRect(0, 0, canvas.width, canvas.height)
for (let element of this.elements) {
if (element.type === 'rect') {
context.fillStyle = element.color
context.fillRect(
element.x,
element.y,
element.width,
element.height
)
} else {
console.error(`type: ${element.type} is not supported`)
}
}
}
}
const scene = new Scene()
scene.renderScene()
canvas.addEventListener('wheel', e => {
e.preventDefault()
canvasScroll.x = e.deltaX
canvasScroll.y = e.deltaY
scene.updateScene(canvasScroll)
})
</script>
</body>
</html>
We attach a wheel event to canvas, which updates the canvasScroll object using deltaX
and deltaY
of wheel events. canvasScroll
is then fed into scene.updateScene
, which uses the offset to update each element’s position. After that, we simply render the elements using their new positions. Voila, we have created a simple infinite canvas.
Infinite Canvas in Excalidraw
Of course, the implementation of infinite canvas in Excalidraw is more complex. However, the basic logic is roughly the same.
- Canvas is attached a wheel event listener
- This wheel event listener updates the scroll offsets
- The scroll offsets are used to update each element’s position on the canvas when we render them
All right, let’s go.
Excalidraw has a type called SceneState
.
export type SceneState = {
scrollX: FlooredNumber;
scrollY: FlooredNumber;
// null indicates transparent bg
viewBackgroundColor: string | null;
zoom: number;
};
SceneState
consists of current scroll offset, the background of the canvas, and the canvas zoom, which is represented by a number. The app state stores the scene state separately as individual properties. Then when value of type SceneState
is expected, we can put these values together as an object of type SceneState
.
For example, when we call renderScene
, we need to provide a SceneState
.
componentDidUpdate() {
const atLeastOneVisibleElement = renderScene(
elements,
this.state.selectionElement,
this.rc!,
this.canvas!,
{
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor,
zoom: this.state.zoom,
},
{
renderOptimizations: true,
},
);
// ...
}
Scroll offset and zoom which are kept in app state are updated by event handlers.
Let’s take a look at handleWheel
event handler, which is attached to the canvas
element
public render() {
// ...
return (
<div className="container">
<LayerUI
canvas={this.canvas}
// ...
/>
<main>
<canvas
id="canvas"
ref={canvas => {
if (canvas !== null) {
this.canvas = canvas;
this.rc = rough.canvas(this.canvas);
this.canvas.addEventListener("wheel", this.handleWheel);
// ...
}
// ...
}}
// ...
/>
</main>
</div>
)
}
Here’s the definition of handleWheel
private handleWheel = (event: WheelEvent) => {
event.preventDefault();
const { deltaX, deltaY } = event;
if (event[KEYS.META]) {
const sign = Math.sign(deltaY);
const MAX_STEP = 10;
let delta = Math.abs(deltaY);
if (delta > MAX_STEP) {
delta = MAX_STEP;
}
delta *= sign;
this.setState(({ zoom }) => ({
zoom: getNormalizedZoom(zoom - delta / 100),
}));
return;
}
this.setState(({ zoom, scrollX, scrollY }) => ({
scrollX: normalizeScroll(scrollX - deltaX / zoom),
scrollY: normalizeScroll(scrollY - deltaY / zoom),
}));
};
When the command key is pressed, we can control zooming via wheel events. Otherwise, we are setting scrollX
and scrollY
to app state.
normalizeScroll
basically floors a number to get “normalization”.
A tiny detail worth noting here is that scrollX and scrollY stores the accumulated scroll amount
this.setState(({ zoom, scrollX, scrollY }) => ({
scrollX: normalizeScroll(**scrollX - deltaX / zoom**),
scrollY: normalizeScroll(**scrollY - deltaY / zoom**),
}));
When app state changes, componentDidUpdate
will be called to trigger renderScene
.
componentDidUpdate() {
const atLeastOneVisibleElement = renderScene(
elements,
this.state.selectionElement,
this.rc!,
this.canvas!,
{
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor,
zoom: this.state.zoom,
},
{
renderOptimizations: true,
},
);
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside: scrolledOutside });
}
this.saveDebounced();
if (history.isRecording()) {
history.pushEntry(this.state, elements);
history.skipRecording();
}
}
Here’s the relevant bits of renderScene
export function renderScene(
elements: readonly ExcalidrawElement[],
selectionElement: ExcalidrawElement | null,
rc: RoughCanvas,
canvas: HTMLCanvasElement,
sceneState: SceneState,
// extra options, currently passed by export helper
{
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,
}: {
renderScrollbars?: boolean;
renderSelection?: boolean;
renderOptimizations?: boolean;
} = {},
): boolean {
// ...
const visibleElements = elements.filter(element =>
isVisibleElement(
element,
normalizedCanvasWidth,
normalizedCanvasHeight,
sceneState,
),
);
applyZoom(context);
visibleElements.forEach(element => {
renderElement(element, rc, context, renderOptimizations, sceneState);
});
resetZoom(context);
// ...
}
As we scroll around, some elements might be out of scene. For those are not visible, there’s no need to render them. For each visible element, we call renderElement
to render it, again providing the necessary parameters including SceneState
.
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);
context.fillStyle = fillStyle;
break;
}
case "rectangle":
case "diamond":
case "ellipse":
case "line":
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: {
throw new Error(`Unimplemented type ${element.type}`);
}
}
}
For element that’s not a selection element, we first call generateElement
, which uses rough’s generator to generate the given element’s shape if it hasn’t been generated already. In addition, generateElement
also generates the element’s canvas. We’ll talk more about this in render optimizations.
If renderOptimizations
flag is turned on, we will call drawElementFromCanvas
to render the element. In either case, we first translate the canvas origin, then we render the element, and then we translate the origin back to where it was.
export function renderElement(
element: ExcalidrawElement,
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderOptimizations: boolean,
sceneState: SceneState,
) {
// ...
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);
}
// ...
}
That’s it! Excalidraw uses an accumulated scrollX
and scrollY
to translate the context. This way, we are never updating the element’s x
and y
positions when we scroll around. x
and y
are only updated when we actually move the element around.
As a reference, we can update the simple infinite canvas implementation as follows
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite Canvas</title>
<style>
</style>
</head>
<body>
<canvas id="canvas">Infinite Canvas</canvas>
<script>
const canvas = document.getElementById('canvas')
canvas.width = window.innerWidth
canvas.height = window.innerHeight
const context = canvas.getContext('2d')
let canvasScroll = {
x: 0,
y: 0
}
class Scene {
elements = [
{
type: 'rect',
x: 150,
y: 150,
width: 200,
height: 200,
color: 'rgba(55, 55, 10, 0.3)',
},
{
type: 'rect',
x: 250,
y: 300,
width: 200,
height: 200,
color: 'rgba(155, 200, 10, 0.5)',
}
]
renderScene (offset) {
context.clearRect(0, 0, canvas.width, canvas.height)
for (let element of this.elements) {
if (element.type === 'rect') {
const offsetX = Math.floor(element.x + offset.x)
const offsetY = Math.floor(element.x + offset.y)
context.translate(offsetX, offsetY)
context.fillStyle = element.color
context.fillRect(
element.x,
element.y,
element.width,
element.height
)
context.translate(-offsetX, -offsetY)
} else {
console.error(`type: ${element.type} is not supported`)
}
}
}
}
const scene = new Scene()
scene.renderScene(canvasScroll)
canvas.addEventListener('wheel', e => {
e.preventDefault()
canvasScroll.x -= e.deltaX
canvasScroll.y -= e.deltaY
scene.renderScene(canvasScroll)
})
</script>
</body>
</html>