• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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