All files / confetti/src/shapes textShape.ts

100% Statements 35/35
91.3% Branches 21/23
100% Functions 2/2
100% Lines 32/32

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                                                                  2x     2x             2x                                           18x 18x 18x 18x 18x   18x     12x 11x     10x             12x     12x 12x 12x   9x 9x 9x   9x   12x 12x 12x 12x   12x 12x   12x 12x 12x 12x   12x             12x        
/**
 * `textShape` — emoji/text confetti. The text is rasterized ONCE to an
 * offscreen canvas sprite (at device-pixel-ratio resolution so it stays
 * sharp on retina screens) and drawn via the sprite cache thereafter, with
 * full per-particle rotation/tumble/scalar.
 *
 * SSR-safe: nothing touches `document` until the sprite is first needed
 * inside a browser draw loop.
 */
import { toFinite, type SpriteEntry, type SpriteShape } from "../types";
 
/**
 * Options for {@link textShape}.
 *
 * @example
 * ```ts
 * textShape("🎉", { size: 24 });
 * textShape("W", { size: 18, color: "#4f8ef7", fontWeight: 700 });
 * ```
 */
export interface TextShapeOptions {
  /** Font size in CSS px at `scalar: 1`. Default `14`. */
  size?: number;
  /** CSS font-family. Default: an emoji-capable system stack. */
  fontFamily?: string;
  /** Fill color for plain text (color emoji glyphs ignore it). Default `"#000000"`. */
  color?: string;
  /** CSS font-weight. Default `"normal"`. */
  fontWeight?: string | number;
}
 
/** Emoji-capable default font stack. */
const DEFAULT_FONT_FAMILY =
  '"Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", system-ui, sans-serif';
 
/** Raster resolution is capped here (device px per CSS px), like the engine's DPR cap. */
const MAX_RASTER_DPR = 2;
 
/**
 * Cap on the scalar-driven raster headroom. A `scalar: 50` particle must
 * not allocate a 50×-resolution bitmap — beyond ×4 the memory cost
 * outweighs any visible sharpness gain.
 */
const MAX_SCALAR_HEADROOM = 4;
 
/**
 * Create an emoji/text confetti shape. The glyph rasters once per shape
 * (cached by key) — a burst of 500 "🎉" costs one raster and 500
 * `drawImage` calls per frame.
 *
 * @param text - The text to render — typically a single emoji or character.
 * @param options - Size/font/color, see {@link TextShapeOptions}.
 * @returns A {@link SpriteShape} usable anywhere a `ConfettiShape` goes.
 *
 * @example
 * ```ts
 * import { fireConfetti, textShape } from "@usefy/confetti";
 *
 * fireConfetti({ shapes: [textShape("🎉"), textShape("🎊", { size: 20 })] });
 * ```
 */
export function textShape(
  text: string,
  options: TextShapeOptions = {},
): SpriteShape {
  const size = Math.max(1, toFinite(options.size, 14));
  const fontFamily = options.fontFamily ?? DEFAULT_FONT_FAMILY;
  const color = options.color ?? "#000000";
  const fontWeight = options.fontWeight ?? "normal";
  let cached: SpriteEntry | null = null;
 
  return {
    key: `usefy-text:${text}|${size}|${fontWeight}|${fontFamily}|${color}`,
    rasterize(scalar: number): SpriteEntry | null {
      if (cached) return cached;
      if (typeof document === "undefined") return null; // SSR — retry client-side
 
      const dpr =
        typeof window !== "undefined" && window.devicePixelRatio
          ? Math.min(window.devicePixelRatio, MAX_RASTER_DPR)
          : 1;
      // Device px per CSS px, with headroom when the first particle is
      // enlarged (scalar > 1) so upscaling never blurs — capped at ×4 so a
      // huge scalar can't allocate a giant bitmap.
      const resolution =
        Math.max(1, dpr) *
        Math.min(Math.max(1, toFinite(scalar, 1)), MAX_SCALAR_HEADROOM);
 
      const canvas = document.createElement("canvas");
      const ctx = canvas.getContext("2d");
      if (!ctx) return null;
 
      const font = `${fontWeight} ${size * resolution}px ${fontFamily}`;
      ctx.font = font;
      const metrics = ctx.measureText(text);
      const ascent =
        metrics.actualBoundingBoxAscent ?? size * resolution * 0.8;
      const descent =
        metrics.actualBoundingBoxDescent ?? size * resolution * 0.25;
      const widthDev = Math.max(1, Math.ceil(metrics.width || size * resolution));
      const heightDev = Math.max(1, Math.ceil(ascent + descent));
      const pad = Math.ceil(resolution); // ~1 CSS px guard against clipping
 
      canvas.width = widthDev + pad * 2;
      canvas.height = heightDev + pad * 2;
      // Resizing the canvas reset its state — set the text state again.
      ctx.font = font;
      ctx.textBaseline = "alphabetic";
      ctx.fillStyle = color;
      ctx.fillText(text, pad, pad + ascent);
 
      cached = {
        source: canvas,
        // Logical (CSS px) size at scalar 1 — the renderer applies each
        // particle's own scalar on top.
        width: canvas.width / resolution,
        height: canvas.height / resolution,
      };
      return cached;
    },
  };
}