• 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 {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