All files / modules x-resize-observer.ts

98.38% Statements 61/62
86.66% Branches 13/15
100% Functions 8/8
98.24% Lines 56/57

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168        94x   94x   94x                             94x                                                         69x       69x         69x   69x   69x   69x 69x     410x 662x 662x 662x 300x   150x             150x     300x     362x     410x           410x 91x 91x 91x 91x     91x         69x 69x 69x           69x 182x 182x 182x         69x 7x   7x 31x       69x 170x 170x     186x 35x     151x 151x       69x 41x   41x 50x 50x 50x       69x 1x 1x 1x 1x        
/**
 * @module Modules/XResizeObserver
 */
 
import * as MH from "@lisn/globals/minification-helpers";
 
import { logWarn, logError } from "@lisn/utils/log";
 
import debug from "@lisn/debug/debug";
 
export type XResizeObserverCallback = (
  entries: ResizeObserverEntry[],
  observer: XResizeObserver,
) => void;
 
/**
 * {@link XResizeObserver} is an extension of
 * {@link https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver | ResizeObserver}
 * - observes both border box and content box size changes
 * - can skip the initial callback that happens shortly after setting up via
 *   {@link observeLater}
 * - can debounce the callback
 */
export class XResizeObserver {
  /**
   * Like {@link https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe | ResizeObserver:observe} except it accepts multiple targets.
   */
  readonly observe: (...targets: Element[]) => void;
 
  /**
   * Like {@link observe} but it ignores the initial almost immediate callback
   * and only calls the callback on a subsequent resize.
   *
   * If the target is already being observed, nothing is done.
   */
  readonly observeLater: (...targets: Element[]) => void;
 
  /**
   * Like {@link https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/unobserve | ResizeObserver:unobserve} except it accepts multiple targets.
   */
  readonly unobserve: (...targets: Element[]) => void;
 
  /**
   * Like {@link https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/disconnect | ResizeObserver:disconnect}.
   */
  readonly disconnect: () => void;
 
  /**
   * @param debounceWindow Debounce the handler so that it's called at most
   *                       every `debounceWindow` ms.
   */
  constructor(callback: XResizeObserverCallback, debounceWindow?: number) {
    const logger = debug ? new debug.Logger({ name: "XResizeObserver" }) : null;
 
    // Keep the latest ResizeObserverEntry for each target during the
    // debounceWindow. Short-lived, so ok to use a Map.
    const buffer = MH.newMap<Element, ResizeObserverEntry>();
 
    // Since internally we have two observers, one for border box, one for
    // content box, we will get called initially twice for a target. So we keep
    // a counter of 1 or 2 for how many more calls to ignore.
    const targetsToSkip = MH.newWeakMap<Element, 1 | 2>();
 
    let observedTargets = MH.newWeakSet<Element>();
 
    debounceWindow ??= 0;
 
    let timer: ReturnType<typeof setTimeout> | null = null;
    const resizeHandler = (entries: ResizeObserverEntry[]) => {
      // Override entries for previous targets, but keep entries whose targets
      // were not resized in this round
      for (const entry of entries) {
        const target = MH.targetOf(entry);
        const skipNum = targetsToSkip.get(target);
        if (skipNum !== undefined) {
          if (skipNum === 2) {
            // expect one more call
            targetsToSkip.set(target, 1);
          } else {
            // done
            /* istanbul ignore next */
            if (skipNum !== 1) {
              logError(MH.bugError(`# targetsToSkip is ${skipNum}`));
            }
            MH.deleteKey(targetsToSkip, target);
          }
 
          continue;
        }
 
        buffer.set(target, entry);
      }
 
      debug: logger?.debug9(
        `Got ${entries.length} new entries. ` +
          `Have ${buffer.size} unique-target entries`,
        entries,
      );
 
      if (!timer && MH.sizeOf(buffer)) {
        timer = MH.setTimer(() => {
          if (MH.sizeOf(buffer)) {
            callback([...buffer.values()], this);
            buffer.clear();
          }
 
          timer = null;
        }, debounceWindow);
      }
    };
 
    const borderObserver = MH.newResizeObserver(resizeHandler);
    const contentObserver = MH.newResizeObserver(resizeHandler);
    Iif (!borderObserver || !contentObserver) {
      logWarn(
        "This browser does not support ResizeObserver. Some features won't work.",
      );
    }
 
    const observeTarget = (target: Element) => {
      observedTargets.add(target);
      borderObserver?.observe(target, { box: "border-box" });
      contentObserver?.observe(target);
    };
 
    // --------------------
 
    this.observe = (...targets) => {
      debug: logger?.debug10("Observing targets", targets);
 
      for (const target of targets) {
        observeTarget(target);
      }
    };
 
    this.observeLater = (...targets) => {
      debug: logger?.debug10("Observing targets (later)", targets);
      for (const target of targets) {
        // Only skip them if not already observed, otherwise the initial
        // (almost) immediate callback won't happen anyway.
        if (observedTargets.has(target)) {
          continue;
        }
 
        targetsToSkip.set(target, 2);
        observeTarget(target);
      }
    };
 
    this.unobserve = (...targets) => {
      debug: logger?.debug10("Unobserving targets", targets);
 
      for (const target of targets) {
        MH.deleteKey(observedTargets, target);
        borderObserver?.unobserve(target);
        contentObserver?.unobserve(target);
      }
    };
 
    this.disconnect = () => {
      debug: logger?.debug10("Disconnecting");
      observedTargets = MH.newWeakSet();
      borderObserver?.disconnect();
      contentObserver?.disconnect();
    };
  }
}