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 '../base/disposable_stack'; 36import {Bounds2D, Rect2D, Size2D} from '../base/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 above and below the visible window. 51 overdrawPx: number; 52 53 // If true, the canvas will remain within the bounds on the target element at 54 // all times. 55 // 56 // If false, the canvas is allowed to overflow the bounds of the target 57 // element to avoid resizing unnecessarily. 58 avoidOverflowingContainer: boolean; 59} 60 61export class VirtualCanvas implements Disposable { 62 private readonly _trash = new DisposableStack(); 63 private readonly _canvasElement: HTMLCanvasElement; 64 private readonly _targetElement: HTMLElement; 65 66 // Describes the offset of the canvas w.r.t. the "target" container 67 private _canvasRect: Rect2D; 68 private _layoutShiftListener?: LayoutShiftListener; 69 private _canvasResizeListener?: CanvasResizeListener; 70 71 /** 72 * @param targetElement The element to turn into a virtual canvas. The 73 * dimensions of this element are used to size the canvas, so ensure this 74 * element is sized appropriately. 75 * @param containerElement The scrolling container to be used for determining 76 * the size and position of the canvas. The targetElement should be a child of 77 * this element. 78 * @param opts Setup options for the VirtualCanvas. 79 */ 80 constructor( 81 targetElement: HTMLElement, 82 containerElement: Element, 83 opts?: Partial<VirtualCanvasOpts>, 84 ) { 85 const {overdrawPx = 100, avoidOverflowingContainer} = opts ?? {}; 86 87 // Returns what the canvas rect should look like 88 const getCanvasRect = () => { 89 const containerRect = new Rect2D( 90 containerElement.getBoundingClientRect(), 91 ); 92 const targetElementRect = targetElement.getBoundingClientRect(); 93 94 // Calculate the intersection of the container's viewport and the target 95 const intersection = containerRect.intersect(targetElementRect); 96 97 // Pad the intersection by the overdraw amount 98 const intersectionExpanded = intersection.expand(overdrawPx); 99 100 // Intersect with the original target rect unless we want to avoid resizes 101 const canvasTargetRect = avoidOverflowingContainer 102 ? intersectionExpanded.intersect(targetElementRect) 103 : intersectionExpanded; 104 105 return canvasTargetRect.reframe(targetElementRect); 106 }; 107 108 const updateCanvas = () => { 109 let repaintRequired = false; 110 111 const canvasRect = getCanvasRect(); 112 const canvasRectPrev = this._canvasRect; 113 this._canvasRect = canvasRect; 114 115 if ( 116 canvasRectPrev.width !== canvasRect.width || 117 canvasRectPrev.height !== canvasRect.height 118 ) { 119 // Canvas needs to change size, update its size 120 canvas.style.width = `${canvasRect.width}px`; 121 canvas.style.height = `${canvasRect.height}px`; 122 this._canvasResizeListener?.( 123 canvas, 124 canvasRect.width, 125 canvasRect.height, 126 ); 127 repaintRequired = true; 128 } 129 130 if ( 131 canvasRectPrev.left !== canvasRect.left || 132 canvasRectPrev.top !== canvasRect.top 133 ) { 134 // Canvas needs to move, update the transform 135 canvas.style.transform = `translate(${canvasRect.left}px, ${canvasRect.top}px)`; 136 repaintRequired = true; 137 } 138 139 repaintRequired && this._layoutShiftListener?.(canvas, canvasRect); 140 }; 141 142 containerElement.addEventListener('scroll', updateCanvas, { 143 passive: true, 144 }); 145 this._trash.defer(() => 146 containerElement.removeEventListener('scroll', updateCanvas), 147 ); 148 149 // Resize observer callbacks are called once immediately 150 const resizeObserver = new ResizeObserver(() => { 151 updateCanvas(); 152 }); 153 154 resizeObserver.observe(containerElement); 155 resizeObserver.observe(targetElement); 156 this._trash.defer(() => { 157 resizeObserver.disconnect(); 158 }); 159 160 // Ensures the canvas doesn't change the size of the target element 161 targetElement.style.overflow = 'hidden'; 162 163 const canvas = document.createElement('canvas'); 164 canvas.style.position = 'absolute'; 165 targetElement.appendChild(canvas); 166 this._trash.defer(() => { 167 targetElement.removeChild(canvas); 168 }); 169 170 this._canvasElement = canvas; 171 this._targetElement = targetElement; 172 this._canvasRect = new Rect2D({ 173 left: 0, 174 top: 0, 175 bottom: 0, 176 right: 0, 177 }); 178 } 179 180 /** 181 * Set the callback that gets called when the canvas element is moved or 182 * resized, thus, invalidating the contents, and should be re-painted. 183 * 184 * @param cb The new callback. 185 */ 186 setLayoutShiftListener(cb: LayoutShiftListener) { 187 this._layoutShiftListener = cb; 188 } 189 190 /** 191 * Set the callback that gets called when the canvas element is resized. This 192 * might be a good opportunity to update the size of the canvas' draw buffer. 193 * 194 * @param cb The new callback. 195 */ 196 setCanvasResizeListener(cb: CanvasResizeListener) { 197 this._canvasResizeListener = cb; 198 } 199 200 /** 201 * The floating canvas element. 202 */ 203 get canvasElement(): HTMLCanvasElement { 204 return this._canvasElement; 205 } 206 207 /** 208 * The target element, i.e. the one passed to our constructor. 209 */ 210 get targetElement(): HTMLElement { 211 return this._targetElement; 212 } 213 214 /** 215 * The size of the target element, aka the size of the virtual canvas. 216 */ 217 get size(): Size2D { 218 return { 219 width: this._targetElement.clientWidth, 220 height: this._targetElement.clientHeight, 221 }; 222 } 223 224 /** 225 * Returns the rect of the floating canvas with respect to the target element. 226 * This will need to be subtracted from any drawing operations to get the 227 * right alignment within the virtual canvas. 228 */ 229 get canvasRect(): Rect2D { 230 return this._canvasRect; 231 } 232 233 /** 234 * Stop listening to DOM events. 235 */ 236 [Symbol.dispose]() { 237 this._trash.dispose(); 238 } 239 240 /** 241 * Return true if a rect overlaps the floating canvas. 242 * @param rect The rect to test. 243 * @returns true if rect overlaps, false otherwise. 244 */ 245 overlapsCanvas(rect: Bounds2D): boolean { 246 const c = this._canvasRect; 247 const y = rect.top < c.bottom && rect.bottom > c.top; 248 const x = rect.left < c.right && rect.right > c.left; 249 return x && y; 250 } 251} 252