1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15/** 16 * Canvases have limits on their maximum size (which is determined by the 17 * system). Usually, this limit is fairly large, but can be as small as 18 * 4096x4096px on some machines. 19 * 20 * If we need a super large canvas, we need to use a different approach. 21 * 22 * Unless the user has a huge monitor, most of the time any sufficiently large 23 * canvas will overflow it's container, so we assume this container is set to 24 * scroll so that the user can actually see all of the canvas. We can take 25 * advantage of the fact that users may only see a small portion of the canvas 26 * at a time. So, if we position a small floating canvas element over the 27 * viewport of the scrolling container, we can approximate a huge canvas using a 28 * much smaller one. 29 * 30 * Given a target element and it's scrolling container, VirtualCanvas turns an 31 * empty HTML element into a "virtual" canvas with virtually unlimited size 32 * using the "floating" canvas technique described above. 33 */ 34 35import {DisposableStack} from './disposable_stack'; 36import {Bounds2D, Rect2D, Size2D} from './geom'; 37 38export type LayoutShiftListener = ( 39 canvas: HTMLCanvasElement, 40 rect: Rect2D, 41) => void; 42 43export type CanvasResizeListener = ( 44 canvas: HTMLCanvasElement, 45 width: number, 46 height: number, 47) => void; 48 49export interface VirtualCanvasOpts { 50 // How much buffer to add around the visible window in the scrollable axes. 51 // The larger this number, the more we can scroll before triggering a move and 52 // update which reduces thrashing when scrolling quickly, but the more canvas 53 // will need to be drawn each render cycle. 54 readonly overdrawPx: number; 55 56 // This figure controls how close we can get to the edge of the drawn canvas 57 // before moving it and triggering a redraw. If 0, we can get all the way to 58 // the edge of the canvas before moving it. Larger values will result in more 59 // frequent redraws but less chance of seeing blank bits of canvas when 60 // scrolling quickly. 61 readonly tolerancePx?: number; 62 63 // Which axes should we overdraw? Typically we only want to overdraw in the 64 // axes we expect to scroll in. So if we only expect the container to be 65 // vertically scrolled, choose 'y'. 66 readonly overdrawAxes?: 'none' | 'x' | 'y' | 'both'; 67} 68 69export class VirtualCanvas implements Disposable { 70 private readonly _trash = new DisposableStack(); 71 private readonly _canvasElement: HTMLCanvasElement; 72 private readonly _targetElement: HTMLElement; 73 74 // Describes the offset of the canvas w.r.t. the "target" container 75 private _canvasRect: Rect2D; 76 private _viewportLimits: Rect2D; 77 private _layoutShiftListener?: LayoutShiftListener; 78 private _canvasResizeListener?: CanvasResizeListener; 79 private _dpr?: number; 80 81 /** 82 * @param targetElement The element to turn into a virtual canvas. The 83 * dimensions of this element are used to size the canvas, so ensure this 84 * element is sized appropriately. 85 * @param containerElement The scrolling container to be used for determining 86 * the size and position of the canvas. The targetElement should be a child of 87 * this element. 88 * @param opts Setup options for the VirtualCanvas. 89 */ 90 constructor( 91 targetElement: HTMLElement, 92 containerElement: Element, 93 opts?: Partial<VirtualCanvasOpts>, 94 ) { 95 const { 96 overdrawPx = 200, 97 tolerancePx = 100, 98 overdrawAxes: scrollAxes = 'none', 99 } = opts ?? {}; 100 101 const viewportOversize = overdrawPx - tolerancePx; 102 103 // Returns the rect of the container's viewport W.R.T the target element. 104 function getViewportRect() { 105 const containerRect = new Rect2D( 106 containerElement.getBoundingClientRect(), 107 ); 108 const targetElementRect = targetElement.getBoundingClientRect(); 109 110 // Calculate the intersection of the container's viewport and the target 111 const intersection = containerRect.intersect(targetElementRect); 112 113 return intersection.reframe(targetElementRect); 114 } 115 116 const getCanvasRect = () => { 117 const viewport = getViewportRect(); 118 119 if (this._viewportLimits.contains(viewport)) { 120 return this._canvasRect; 121 } else { 122 const canvasRect = viewport.expand({ 123 height: scrollAxes === 'both' || scrollAxes === 'y' ? overdrawPx : 0, 124 width: scrollAxes === 'both' || scrollAxes === 'x' ? overdrawPx : 0, 125 }); 126 127 this._viewportLimits = viewport.expand({ 128 height: 129 scrollAxes === 'both' || scrollAxes === 'y' ? viewportOversize : 0, 130 width: 131 scrollAxes === 'both' || scrollAxes === 'x' ? viewportOversize : 0, 132 }); 133 134 return canvasRect; 135 } 136 }; 137 138 const updateCanvas = () => { 139 let repaintRequired = false; 140 141 const canvasRect = getCanvasRect(); 142 const canvasRectPrev = this._canvasRect; 143 this._canvasRect = canvasRect; 144 145 if ( 146 canvasRectPrev.width !== canvasRect.width || 147 canvasRectPrev.height !== canvasRect.height || 148 devicePixelRatio !== this._dpr 149 ) { 150 this._dpr = devicePixelRatio; 151 152 // Canvas needs to change size, update its size 153 canvas.style.width = `${canvasRect.width}px`; 154 canvas.style.height = `${canvasRect.height}px`; 155 this._canvasResizeListener?.( 156 canvas, 157 canvasRect.width, 158 canvasRect.height, 159 ); 160 repaintRequired = true; 161 } 162 163 if ( 164 canvasRectPrev.left !== canvasRect.left || 165 canvasRectPrev.top !== canvasRect.top 166 ) { 167 // Canvas needs to move, update the transform 168 canvas.style.transform = `translate(${canvasRect.left}px, ${canvasRect.top}px)`; 169 repaintRequired = true; 170 } 171 172 repaintRequired && this._layoutShiftListener?.(canvas, canvasRect); 173 }; 174 175 containerElement.addEventListener('scroll', updateCanvas, { 176 passive: true, 177 }); 178 this._trash.defer(() => 179 containerElement.removeEventListener('scroll', updateCanvas), 180 ); 181 182 // Resize observer callbacks are called once immediately after registration 183 const resizeObserver = new ResizeObserver((_cb) => { 184 updateCanvas(); 185 }); 186 187 resizeObserver.observe(containerElement); 188 resizeObserver.observe(targetElement); 189 this._trash.defer(() => { 190 resizeObserver.disconnect(); 191 }); 192 193 // Ensures the canvas doesn't change the size of the target element 194 targetElement.style.overflow = 'hidden'; 195 196 const canvas = document.createElement('canvas'); 197 canvas.style.position = 'absolute'; 198 targetElement.appendChild(canvas); 199 this._trash.defer(() => { 200 targetElement.removeChild(canvas); 201 }); 202 203 this._canvasElement = canvas; 204 this._targetElement = targetElement; 205 this._canvasRect = new Rect2D({ 206 left: 0, 207 top: 0, 208 bottom: 0, 209 right: 0, 210 }); 211 this._viewportLimits = this._canvasRect; 212 } 213 214 /** 215 * Set the callback that gets called when the canvas element is moved or 216 * resized, thus, invalidating the contents, and should be re-painted. 217 * 218 * @param cb The new callback. 219 */ 220 setLayoutShiftListener(cb: LayoutShiftListener) { 221 this._layoutShiftListener = cb; 222 } 223 224 /** 225 * Set the callback that gets called when the canvas element is resized. This 226 * might be a good opportunity to update the size of the canvas' draw buffer. 227 * 228 * @param cb The new callback. 229 */ 230 setCanvasResizeListener(cb: CanvasResizeListener) { 231 this._canvasResizeListener = cb; 232 } 233 234 /** 235 * The floating canvas element. 236 */ 237 get canvasElement(): HTMLCanvasElement { 238 return this._canvasElement; 239 } 240 241 /** 242 * The target element, i.e. the one passed to our constructor. 243 */ 244 get targetElement(): HTMLElement { 245 return this._targetElement; 246 } 247 248 /** 249 * The size of the target element, aka the size of the virtual canvas. 250 */ 251 get size(): Size2D { 252 return { 253 width: this._targetElement.clientWidth, 254 height: this._targetElement.clientHeight, 255 }; 256 } 257 258 /** 259 * Returns the rect of the floating canvas with respect to the target element. 260 * This will need to be subtracted from any drawing operations to get the 261 * right alignment within the virtual canvas. 262 */ 263 get canvasRect(): Rect2D { 264 return this._canvasRect; 265 } 266 267 /** 268 * Stop listening to DOM events. 269 */ 270 [Symbol.dispose]() { 271 this._trash.dispose(); 272 } 273 274 /** 275 * Return true if a rect overlaps the floating canvas. 276 * @param rect The rect to test. 277 * @returns true if rect overlaps, false otherwise. 278 */ 279 overlapsCanvas(rect: Bounds2D): boolean { 280 const c = this._canvasRect; 281 const y = rect.top < c.bottom && rect.bottom > c.top; 282 const x = rect.left < c.right && rect.right > c.left; 283 return x && y; 284 } 285} 286