All files / confetti/src/shapes imageShape.ts

100% Statements 35/35
97.14% Branches 34/35
100% Functions 3/3
100% Lines 29/29

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                                                                  2x   2x                                               15x 15x 15x 15x 15x   15x   15x     16x 15x   13x 7x           6x 6x 6x 1x   6x   12x   4x 4x 16x 16x 16x 2x   4x 4x   4x 4x        
/**
 * `imageShape` — image confetti (logos, avatars, stickers).
 *
 * Async decode is handled through the sprite cache's null-retry contract:
 * until the image has loaded, `rasterize` returns `null` and the particle
 * simply isn't drawn — no placeholder flash, no throw. Load errors are a
 * silent permanent skip (confetti must never take the page down).
 *
 * SSR-safe: no `Image` construction at import time, and `rasterize`
 * returns `null` in environments without one.
 */
import { toFinite, type SpriteEntry, type SpriteShape } from "../types";
 
/**
 * Options for {@link imageShape}.
 *
 * Provide `width` and/or `height` in CSS px at `scalar: 1`; a missing
 * dimension is derived from the image's natural aspect ratio. With neither
 * given, the image is sized to {@link DEFAULT_IMAGE_HEIGHT} px tall.
 *
 * @example
 * ```ts
 * imageShape("/logo.svg", { height: 16 });
 * ```
 */
export interface ImageShapeOptions {
  /** Drawn width in CSS px at `scalar: 1`. */
  width?: number;
  /** Drawn height in CSS px at `scalar: 1`. */
  height?: number;
}
 
/** Default drawn height (CSS px at `scalar: 1`) when no size is given. */
export const DEFAULT_IMAGE_HEIGHT = 12;
 
let anonymousElementCounter = 0;
 
/**
 * Create an image confetti shape from a URL or an existing
 * `HTMLImageElement`. Loading starts lazily on the first draw that needs
 * the sprite; particles are skipped (not delayed, not errored) until the
 * image is ready.
 *
 * @param src - Image URL, or an `HTMLImageElement` you manage yourself.
 * @param options - Target size, see {@link ImageShapeOptions}.
 * @returns A {@link SpriteShape} usable anywhere a `ConfettiShape` goes.
 *
 * @example
 * ```ts
 * import { fireConfetti, imageShape } from "@usefy/confetti";
 *
 * fireConfetti({ shapes: [imageShape("/logo.png", { height: 14 })] });
 * ```
 */
export function imageShape(
  src: string | HTMLImageElement,
  options: ImageShapeOptions = {},
): SpriteShape {
  const isElement =
    typeof HTMLImageElement !== "undefined" && src instanceof HTMLImageElement;
  const url = isElement ? src.src : (src as string);
  let img: HTMLImageElement | null = isElement ? src : null;
  let failed = false;
  let cached: SpriteEntry | null = null;
 
  const identity = url || `element#${++anonymousElementCounter}`;
 
  return {
    key: `usefy-image:${identity}|${options.width ?? ""}x${options.height ?? ""}`,
    rasterize(): SpriteEntry | null {
      if (cached) return cached;
      if (failed) return null; // permanent silent skip
 
      if (!img) {
        if (typeof Image === "undefined") return null; // SSR — retry client-side
        // Note: each imageShape() call owns one Image, so two shapes made
        // from the same URL decode it twice. Bounded and deliberate — the
        // browser's HTTP cache dedupes the network fetch, the sprite cache
        // dedupes per shape key, and sharing decode state across factory
        // calls would need module-global bookkeeping for a rare case.
        img = new Image();
        img.decoding = "async";
        img.addEventListener("error", () => {
          failed = true;
        });
        img.src = url;
      }
      if (!img.complete || !img.naturalWidth) return null; // still decoding
 
      const naturalW = img.naturalWidth;
      const naturalH = img.naturalHeight || naturalW;
      let width = toFinite(options.width, NaN);
      let height = toFinite(options.height, NaN);
      if (Number.isNaN(width) && Number.isNaN(height)) {
        height = DEFAULT_IMAGE_HEIGHT;
      }
      if (Number.isNaN(width)) width = (height * naturalW) / naturalH;
      if (Number.isNaN(height)) height = (width * naturalH) / naturalW;
 
      cached = { source: img, width, height };
      return cached;
    },
  };
}