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