All files / qr-scanner/src Viewfinder.tsx

86.66% Statements 26/30
95.45% Branches 21/22
75% Functions 3/4
85.18% Lines 23/27

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        1x                                               1x     98x 98x 98x         98x   98x 98x 98x 98x 98x 98x   98x 50x 50x 50x         50x 2x                                   98x   7x   98x 28x   98x                                                                                                                        
"use client";
 
import { useEffect, useId, useRef } from "react";
import { useReducedMotion } from "@usefy/use-reduced-motion";
import { cornersToPolygon, mapCorners, type ElementGeometry } from "./utils";
import type { QRScanResult } from "./types";
 
/**
 * The viewfinder: corner brackets, a sweeping line, and a highlight that
 * tracks whatever the decoder found.
 *
 * All SVG and inline styles — no stylesheet ships with this package — and the
 * sweep is driven by the Web Animations API rather than CSS keyframes, which
 * is what lets a component with no CSS file animate at all.
 */
 
export interface ViewfinderProps {
  /** The most recent decode, drawn as a highlight quad. */
  result: QRScanResult | null;
  /** How the video's pixels map into the element (see {@link mapCorners}). */
  geometry: ElementGeometry | null;
  /** Accent used for the brackets, the sweep and the highlight. */
  accent: string;
  /** Whether the sweep should run at all (paused scanners hold still). */
  active: boolean;
}
 
/** Fraction of the shorter side the viewfinder square occupies. */
const FRAME_RATIO = 0.68;
 
export function Viewfinder({ result, geometry, accent, active }: ViewfinderProps): React.ReactNode {
  const reducedMotion = useReducedMotion();
  const sweepRef = useRef<SVGRectElement | null>(null);
  const animationRef = useRef<Animation | null>(null);
  // `url(#…)` resolves document-wide to the *first* matching element, so a
  // hard-coded id means a second scanner on the page (a preview beside a modal,
  // two demos on one product page) renders the first one's cutout geometry —
  // a dim rectangle with the hole in the wrong place.
  const maskId = `usefy-qr-viewfinder-${useId()}`;
 
  const width = geometry?.elementWidth ?? 0;
  const height = geometry?.elementHeight ?? 0;
  const side = Math.min(width, height) * FRAME_RATIO;
  const left = (width - side) / 2;
  const top = (height - side) / 2;
  const corner = Math.max(12, side * 0.12);
 
  useEffect(() => {
    const element = sweepRef.current;
    animationRef.current?.cancel();
    animationRef.current = null;
 
    // Under reduced motion the sweep does not slow down — it stops, and the
    // brackets alone carry the "aim here" message. A slowed sweep is still
    // motion, and the preference is not "less", it is "none".
    if (!element || !active || reducedMotion || side <= 0) return;
    Eif (typeof element.animate !== "function") return;
 
    animationRef.current = element.animate(
      [
        { transform: `translateY(0px)`, opacity: 0 },
        { transform: `translateY(${side * 0.12}px)`, opacity: 1, offset: 0.15 },
        { transform: `translateY(${side * 0.88}px)`, opacity: 1, offset: 0.85 },
        { transform: `translateY(${side}px)`, opacity: 0 },
      ],
      { duration: 2400, iterations: Infinity, easing: "cubic-bezier(0.4, 0, 0.2, 1)" },
    );
 
    return () => {
      animationRef.current?.cancel();
      animationRef.current = null;
    };
  }, [active, reducedMotion, side]);
 
  if (!geometry || side <= 0) return null;
 
  const highlight = result && geometry ? mapCorners(result, geometry) : null;
 
  const bracket = (x: number, y: number, dx: number, dy: number): string =>
    `M ${x + dx * corner} ${y} L ${x} ${y} L ${x} ${y + dy * corner}`;
 
  return (
    <svg
      aria-hidden="true"
      width={width}
      height={height}
      viewBox={`0 0 ${width} ${height}`}
      style={{
        position: "absolute",
        inset: 0,
        width: "100%",
        height: "100%",
        pointerEvents: "none",
      }}
    >
      {/* Everything outside the viewfinder is dimmed, so the eye goes to the
          square without the square needing a heavy border. */}
      <mask id={maskId}>
        <rect x={0} y={0} width={width} height={height} fill="white" />
        <rect x={left} y={top} width={side} height={side} rx={side * 0.06} fill="black" />
      </mask>
      <rect
        x={0}
        y={0}
        width={width}
        height={height}
        fill="rgba(0, 0, 0, 0.45)"
        mask={`url(#${maskId})`}
      />
 
      <g stroke={accent} strokeWidth={3} strokeLinecap="round" fill="none">
        <path d={bracket(left, top, 1, 1)} />
        <path d={bracket(left + side, top, -1, 1)} />
        <path d={bracket(left, top + side, 1, -1)} />
        <path d={bracket(left + side, top + side, -1, -1)} />
      </g>
 
      <rect
        ref={sweepRef}
        x={left + 4}
        y={top}
        width={side - 8}
        height={2}
        rx={1}
        fill={accent}
        opacity={0}
      />
 
      {highlight && (
        <polygon
          points={cornersToPolygon(highlight)}
          fill={accent}
          fillOpacity={0.18}
          stroke={accent}
          strokeWidth={3}
          strokeLinejoin="round"
        />
      )}
    </svg>
  );
}