blob: 1aaee3d793cd70b111267a9d87ca11af8f0d1035 [file] [edit]
// 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.
/**
* VirtualCanvas - A Mithril Component for Virtual Canvas Rendering
*
* This module provides a Mithril component that acts as a scrolling container
* for tall and/or wide content. It overlays a floating canvas on top of its
* content rendered inside it, which stays in the viewport of scrolling
* container element as the user scrolls, allowing for rendering of large-scale
* visualizations which would be too large for a normal HTML canvas element.
*
* Key Features:
* - Supports horizontal, vertical, or both axes scrolling, moving the canvas
* while the user scrolls to keep it in the viewport.
* - Automatically handles canvas resizing using resize observers, including
* scaling for high DPI displays.
* - Calls a callback whenever the canvas needs to be redrawn.
*/
import './virtual_overlay_canvas.scss';
import m from 'mithril';
import {DisposableStack} from '../base/disposable_stack';
import {findRef, toHTMLElement} from '../base/dom_utils';
import type {Rect2D, Size2D} from '../base/geom';
import {ensureExists} from '../base/assert';
import {VirtualCanvas} from '../base/virtual_canvas';
import {WebGLRenderer} from '../base/gl/webgl_renderer';
import {Canvas2DRenderer} from '../base/canvas2d_renderer';
import type {Renderer} from '../base/renderer';
import type {HTMLAttrs} from './common';
const CANVAS_CONTAINER_REF = 'canvas-container';
const CANVAS_OVERDRAW_PX = 300;
const CANVAS_TOLERANCE_PX = 100;
export interface VirtualOverlayCanvasDrawContext {
// Canvas rendering context.
readonly ctx: CanvasRenderingContext2D;
// The virtual canvas DOM element.
readonly dom: Element;
// The size of the virtual canvas element.
readonly virtualCanvasSize: Size2D;
// The rect of the actual canvas W.R.T to the virtual canvas element.
// Includes overdraw area for smooth scrolling.
readonly canvasRect: Rect2D;
// The rect of the visible viewport W.R.T to the virtual canvas element.
// Does not include overdraw area.
readonly viewportRect: Rect2D;
// Renderer for drawing primitives. This is either a WebGLRenderer (when
// WebGL is enabled and available) or a Canvas2DRenderer fallback.
// The renderer is reused across frames - do not store references to it.
readonly renderer: Renderer;
}
export type Overflow = 'hidden' | 'visible' | 'auto';
// Imperative handle for controlling a VirtualOverlayCanvas. Handed to the
// parent via the `onMount` callback.
export interface VirtualOverlayCanvasApi {
// Redraw the canvas synchronously.
redrawCanvas(): void;
// Set the scroll position of the scrolling container. Either axis may be
// omitted to leave it unchanged. The browser clamps to the valid range.
scrollTo(opts: {x?: number; y?: number}): void;
}
export interface VirtualOverlayCanvasAttrs extends HTMLAttrs {
// Additional class names applied to the root element.
readonly className?: string;
// Overflow rules for the horizontal axis.
readonly overflowX?: Overflow;
// Overflow rules for the vertical axis.
readonly overflowY?: Overflow;
// Called when the canvas needs to be repainted due to a layout shift or
// or resize.
onCanvasRedraw?(ctx: VirtualOverlayCanvasDrawContext): void;
// When true the canvas will not be redrawn on mithril update cycles. Disable
// this if you want to manage the canvas redraw cycle yourself (i.e. possibly
// using raf.addCanvasRedrawCallback()) and want to avoid double redraws.
// Default: false.
readonly disableCanvasRedrawOnMithrilUpdates?: boolean;
// Called when the canvas is mounted. The passed api object exposes
// imperative methods for controlling the canvas. Any returned disposable
// will be disposed of when the component is removed.
onMount?(api: VirtualOverlayCanvasApi): Disposable | void;
// Override styles from base interface, only allowing object type styles
// rather than strings.
style?: Partial<CSSStyleDeclaration>;
// Enable a second canvas for WebGL rendering. When enabled, webglCanvas and
// webglCtx will be provided in the draw context.
// Default: false.
readonly enableWebGL?: boolean;
}
function getScrollAxesFromOverflow(x: Overflow, y: Overflow) {
if (x === 'auto' && y === 'auto') {
return 'both';
} else if (x === 'auto') {
return 'x';
} else if (y === 'auto') {
return 'y';
} else {
//
return 'none';
}
}
// This mithril component acts as scrolling container for tall and/or wide
// content. Adds a virtually scrolling canvas over the top of any child elements
// rendered inside it.
export class VirtualOverlayCanvas
implements m.ClassComponent<VirtualOverlayCanvasAttrs>
{
readonly trash = new DisposableStack();
private ctx?: CanvasRenderingContext2D;
private virtualCanvas?: VirtualCanvas;
private attrs?: VirtualOverlayCanvasAttrs;
private webglCanvas?: HTMLCanvasElement;
private webglRenderer?: WebGLRenderer;
private dom?: Element;
view({attrs, children}: m.CVnode<VirtualOverlayCanvasAttrs>) {
this.attrs = attrs;
const {
overflowX = 'visible',
overflowY = 'visible',
style = {},
...rest
} = attrs;
return m(
'.pf-virtual-overlay-canvas', // The scrolling container
{
className: attrs.className,
style: {
...style,
overflowX,
overflowY,
},
...rest,
},
m(
'.pf-virtual-overlay-canvas__content', // Container for scrolling element, used for sizing the canvas
children,
// Put canvas container after content so it appears on top. An actual
// canvas element will be created inside here by the
// VirtualCanvasHelper.
m('.pf-virtual-overlay-canvas__canvas-container', {
ref: CANVAS_CONTAINER_REF,
}),
),
);
}
oncreate({attrs, dom}: m.CVnodeDOM<VirtualOverlayCanvasAttrs>) {
this.dom = dom;
const canvasContainerElement = toHTMLElement(
ensureExists(findRef(dom, CANVAS_CONTAINER_REF)),
);
const {overflowX = 'visible', overflowY = 'visible'} = attrs;
// Create the virtual canvas inside the canvas container element. We assume
// the scrolling container is the root level element of this component so we
// can just use `dom`.
const virtualCanvas = new VirtualCanvas(canvasContainerElement, dom, {
overdrawPx: CANVAS_OVERDRAW_PX,
tolerancePx: CANVAS_TOLERANCE_PX,
overdrawAxes: getScrollAxesFromOverflow(overflowX, overflowY),
});
this.trash.use(virtualCanvas);
this.virtualCanvas = virtualCanvas;
// Create the canvas rendering context
this.ctx = ensureExists(virtualCanvas.canvasElement.getContext('2d'));
// Create WebGL canvas if enabled
if (attrs.enableWebGL) {
this.webglCanvas = document.createElement('canvas');
this.webglCanvas.style.position = 'absolute';
this.webglCanvas.style.pointerEvents = 'none';
// Insert before the 2D canvas so WebGL renders underneath
canvasContainerElement.insertBefore(
this.webglCanvas,
virtualCanvas.canvasElement,
);
this.trash.defer(() => {
if (this.webglCanvas?.parentElement) {
this.webglCanvas.parentElement.removeChild(this.webglCanvas);
}
});
const webglCtx = this.webglCanvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: true,
antialias: true,
});
if (webglCtx) {
this.webglRenderer = new WebGLRenderer(this.ctx, webglCtx);
}
}
// When the container resizes, we might need to resize the canvas. This can
// be slow so we don't want to do it every render cycle. VirtualCanvas will
// tell us when we need to do this.
virtualCanvas.setCanvasResizeListener((canvas, width, height) => {
const dpr = window.devicePixelRatio;
canvas.width = width * dpr;
canvas.height = height * dpr;
// Resize WebGL canvas to match
if (this.webglCanvas && this.webglRenderer) {
this.webglCanvas.width = width * dpr;
this.webglCanvas.height = height * dpr;
this.webglCanvas.style.width = `${width}px`;
this.webglCanvas.style.height = `${height}px`;
this.webglRenderer.gl.viewport(0, 0, width * dpr, height * dpr);
}
});
// Manually sync WebGL canvas size since the initial ResizeObserver callback
// fired before the listener was set (during VirtualCanvas construction).
if (this.webglCanvas && this.webglRenderer) {
const canvasRect = virtualCanvas.canvasRect;
const dpr = window.devicePixelRatio;
this.webglCanvas.width = canvasRect.width * dpr;
this.webglCanvas.height = canvasRect.height * dpr;
this.webglCanvas.style.width = `${canvasRect.width}px`;
this.webglCanvas.style.height = `${canvasRect.height}px`;
this.webglRenderer.gl.viewport(
0,
0,
canvasRect.width * dpr,
canvasRect.height * dpr,
);
}
// Whenever the canvas changes size or moves around (e.g. when scrolling),
// we'll need to trigger a re-render to keep canvas content aligned with the
// DOM elements underneath.
virtualCanvas.setLayoutShiftListener(() => {
// Keep WebGL canvas in sync with 2D canvas position
if (this.webglCanvas) {
const canvasRect = virtualCanvas.canvasRect;
this.webglCanvas.style.transform = `translate(${canvasRect.left}px, ${canvasRect.top}px)`;
}
this.redrawCanvas();
});
const scrollEl = toHTMLElement(dom);
const api: VirtualOverlayCanvasApi = {
redrawCanvas: this.redrawCanvas.bind(this),
scrollTo: ({x, y}) => {
if (x !== undefined) scrollEl.scrollLeft = x;
if (y !== undefined) scrollEl.scrollTop = y;
},
};
const disposable = attrs.onMount?.(api);
disposable && this.trash.use(disposable);
!attrs.disableCanvasRedrawOnMithrilUpdates && this.redrawCanvas();
}
onupdate({attrs}: m.CVnodeDOM<VirtualOverlayCanvasAttrs>) {
!attrs.disableCanvasRedrawOnMithrilUpdates && this.redrawCanvas();
}
onremove() {
this.trash.dispose();
}
private redrawCanvas() {
const ctx = ensureExists(this.ctx);
const virtualCanvas = ensureExists(this.virtualCanvas);
const attrs = ensureExists(this.attrs);
const containerElement = ensureExists(this.dom);
// Create the appropriate renderer: WebGLRenderer if available, otherwise
// Canvas2DRenderer as fallback.
const renderer: Renderer = this.webglRenderer ?? new Canvas2DRenderer(ctx);
// Reset & clear canvas
renderer.resetTransform();
renderer.clear();
// Offse by the virtual canvas position and scale by device pixel ratio
const dpr = window.devicePixelRatio;
using _transform = renderer.pushTransform({
scaleX: dpr,
scaleY: dpr,
});
using _scale = renderer.pushTransform({
offsetX: -virtualCanvas.canvasRect.left,
offsetY: -virtualCanvas.canvasRect.top,
});
// Call the user-provided draw callback to render into the canvas
attrs.onCanvasRedraw?.({
dom: containerElement,
ctx,
virtualCanvasSize: virtualCanvas.size,
canvasRect: virtualCanvas.canvasRect,
viewportRect: virtualCanvas.viewportRect,
renderer,
});
// Also flush WebGL draw calls (this is distinct from the flush above).
this.webglRenderer?.gl.flush();
}
}