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