blob: 403561443571867ed61d49ecbddc7f60a75b305b [file] [log] [blame]
// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Canvases have limits on their maximum size (which is determined by the
* system). Usually, this limit is fairly large, but can be as small as
* 4096x4096px on some machines.
*
* If we need a super large canvas, we need to use a different approach.
*
* Unless the user has a huge monitor, most of the time any sufficiently large
* canvas will overflow it's container, so we assume this container is set to
* scroll so that the user can actually see all of the canvas. We can take
* advantage of the fact that users may only see a small portion of the canvas
* at a time. So, if we position a small floating canvas element over the
* viewport of the scrolling container, we can approximate a huge canvas using a
* much smaller one.
*
* Given a target element and it's scrolling container, VirtualCanvas turns an
* empty HTML element into a "virtual" canvas with virtually unlimited size
* using the "floating" canvas technique described above.
*/
import {Disposable, Trash} from '../base/disposable';
import {
Rect,
Size,
expandRect,
intersectRects,
rebaseRect,
rectSize,
} from '../base/geom';
export type LayoutShiftListener = (
canvas: HTMLCanvasElement,
rect: Rect,
) => void;
export type CanvasResizeListener = (
canvas: HTMLCanvasElement,
width: number,
height: number,
) => void;
export interface VirtualCanvasOpts {
// How much buffer to add above and below the visible window.
overdrawPx: number;
// If true, the canvas will remain within the bounds on the target element at
// all times.
//
// If false, the canvas is allowed to overflow the bounds of the target
// element to avoid resizing unnecessarily.
avoidOverflowingContainer: boolean;
}
export class VirtualCanvas implements Disposable {
private readonly _trash = new Trash();
private readonly _canvasElement: HTMLCanvasElement;
private readonly _targetElement: HTMLElement;
// Describes the offset of the canvas w.r.t. the "target" container
private _canvasRect: Rect;
private _layoutShiftListener?: LayoutShiftListener;
private _canvasResizeListener?: CanvasResizeListener;
/**
* @param targetElement The element to turn into a virtual canvas. The
* dimensions of this element are used to size the canvas, so ensure this
* element is sized appropriately.
* @param containerElement The scrolling container to be used for determining
* the size and position of the canvas. The targetElement should be a child of
* this element.
* @param opts Setup options for the VirtualCanvas.
*/
constructor(
targetElement: HTMLElement,
containerElement: Element,
opts?: Partial<VirtualCanvasOpts>,
) {
const {overdrawPx = 100, avoidOverflowingContainer} = opts ?? {};
// Returns what the canvas rect should look like
const getCanvasRect = () => {
const containerRect = containerElement.getBoundingClientRect();
const targetElementRect = targetElement.getBoundingClientRect();
// Calculate the intersection of the container's viewport and the target
const intersection = intersectRects(containerRect, targetElementRect);
// Pad the intersection by the overdraw amount
const intersectionExpanded = expandRect(intersection, overdrawPx);
// Intersect with the original target rect unless we want to avoid resizes
const canvasTargetRect = avoidOverflowingContainer
? intersectRects(intersectionExpanded, targetElementRect)
: intersectionExpanded;
return rebaseRect(
canvasTargetRect,
targetElementRect.x,
targetElementRect.y,
);
};
const updateCanvas = () => {
let repaintRequired = false;
const canvasRect = getCanvasRect();
const canvasRectSize = rectSize(canvasRect);
const canvasRectPrev = this._canvasRect;
const canvasRectPrevSize = rectSize(canvasRectPrev);
this._canvasRect = canvasRect;
if (
canvasRectPrevSize.width !== canvasRectSize.width ||
canvasRectPrevSize.height !== canvasRectSize.height
) {
// Canvas needs to change size, update its size
canvas.style.width = `${canvasRectSize.width}px`;
canvas.style.height = `${canvasRectSize.height}px`;
this._canvasResizeListener?.(
canvas,
canvasRectSize.width,
canvasRectSize.height,
);
repaintRequired = true;
}
if (
canvasRectPrev.left !== canvasRect.left ||
canvasRectPrev.top !== canvasRect.top
) {
// Canvas needs to move, update the transform
canvas.style.transform = `translate(${canvasRect.left}px, ${canvasRect.top}px)`;
repaintRequired = true;
}
repaintRequired && this._layoutShiftListener?.(canvas, canvasRect);
};
containerElement.addEventListener('scroll', updateCanvas, {
passive: true,
});
this._trash.addCallback(() =>
containerElement.removeEventListener('scroll', updateCanvas),
);
// Resize observer callbacks are called once immediately
const resizeObserver = new ResizeObserver(() => {
updateCanvas();
});
resizeObserver.observe(containerElement);
resizeObserver.observe(targetElement);
this._trash.addCallback(() => {
resizeObserver.disconnect();
});
// Ensures the canvas doesn't change the size of the target element
targetElement.style.overflow = 'hidden';
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
targetElement.appendChild(canvas);
this._trash.addCallback(() => {
targetElement.removeChild(canvas);
});
this._canvasElement = canvas;
this._targetElement = targetElement;
this._canvasRect = {
left: 0,
top: 0,
bottom: 0,
right: 0,
};
}
/**
* Set the callback that gets called when the canvas element is moved or
* resized, thus, invalidating the contents, and should be re-painted.
*
* @param cb The new callback.
*/
setLayoutShiftListener(cb: LayoutShiftListener) {
this._layoutShiftListener = cb;
}
/**
* Set the callback that gets called when the canvas element is resized. This
* might be a good opportunity to update the size of the canvas' draw buffer.
*
* @param cb The new callback.
*/
setCanvasResizeListener(cb: CanvasResizeListener) {
this._canvasResizeListener = cb;
}
/**
* The floating canvas element.
*/
get canvasElement(): HTMLCanvasElement {
return this._canvasElement;
}
/**
* The target element, i.e. the one passed to our constructor.
*/
get targetElement(): HTMLElement {
return this._targetElement;
}
/**
* The size of the target element, aka the size of the virtual canvas.
*/
get size(): Size {
return {
width: this._targetElement.clientWidth,
height: this._targetElement.clientHeight,
};
}
/**
* Returns the rect of the floating canvas with respect to the target element.
* This will need to be subtracted from any drawing operations to get the
* right alignment within the virtual canvas.
*/
get canvasRect(): Rect {
return this._canvasRect;
}
/**
* The size of the floating canvas.
*/
get canvasSize(): Size {
return rectSize(this._canvasRect);
}
/**
* Stop listening to DOM events.
*/
dispose(): void {
this._trash.dispose();
}
/**
* Return true if a rect overlaps the floating canvas.
* @param rect The rect to test.
* @returns true if rect overlaps, false otherwise.
*/
overlapsCanvas(rect: Rect): boolean {
const c = this._canvasRect;
const y = rect.top < c.bottom && rect.bottom > c.top;
const x = rect.left < c.right && rect.right > c.left;
return x && y;
}
}