xref: /aosp_15_r20/external/perfetto/ui/src/frontend/virtual_canvas.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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