All files / src/features/Merchants/MerchantSidePanel/Modals/Signature generateSignature.ts

86.66% Statements 26/30
75% Branches 9/12
33.33% Functions 1/3
89.28% Lines 25/28

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    4x                 4x 4x 4x                         4x 4x                                               8x 8x   6x 6x 6x   6x 6x   6x   6x 1x 1x                     6x 6x   6x 6x       6x   6x 6x   6x    
import { dataURLtoFile } from "@utils/assets";
 
export const SIGNATURE_FONTS = [
  { id: 1, name: "Allison" },
  { id: 2, name: "MsMadi" },
  { id: 3, name: "Fuggles" },
  { id: 4, name: "ReenieBeanie" },
] as const;
 
export type SignatureFontName = (typeof SIGNATURE_FONTS)[number]["name"];
 
export const SIGNATURE_WIDTH = 400;
export const SIGNATURE_HEIGHT = 200;
export const SIGNATURE_FONT_SIZE = 40;
 
/**
 * A signature plate is an artefact that gets stored, re-rendered on light
 * surfaces, and embedded in the agreement PDF, so its colours are FIXED rather
 * than taken from the active theme.
 *
 * This deliberately does not read `palette.background.paper` / `text.primary`
 * as the original inline implementation did. `background.paper` is not defined
 * in the v2 palette, so it fell through to the MUI default — meaning anyone
 * signing in dark mode produced near-white text on a #121212 plate, which then
 * rendered on a light agreement panel. Values below are the light-theme tokens.
 */
const INK = "#292928"; // tokens.light.colour.text.primary
const PLATE = "#ffffff";
 
/**
 * Renders `name` as a signature PNG and returns it as an uploadable File.
 *
 * Fonts are declared in index.css via @font-face, but a declared face is not
 * necessarily a *loaded* one, and measuring against an unloaded face silently
 * falls back to a default font — which throws off the horizontal centring.
 *
 * `ensureFontLoaded` controls whether we wait for the face:
 *  - the interactive picker does NOT need it, because its suggestion tiles have
 *    already rendered the name in all four faces on screen, so the browser has
 *    loaded them before the user can click;
 *  - the auto-generate path DOES need it, because nothing has rendered that face
 *    beforehand.
 * It defaults to false so callers never pay for a FontFaceSet round-trip (or a
 * non-resolving one, as in jsdom) unless they actually need it.
 */
export async function generateSignatureFile(
  name: string,
  fontName: SignatureFontName,
  fileName = "signature.png",
  ensureFontLoaded = false,
): Promise<File | null> {
  const text = name.trim();
  if (!text) return null;
 
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");
  Iif (!context) return null;
 
  canvas.width = SIGNATURE_WIDTH;
  canvas.height = SIGNATURE_HEIGHT;
 
  const font = `${SIGNATURE_FONT_SIZE}px ${fontName}`;
 
  if (ensureFontLoaded) {
    const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
    Iif (fonts && typeof fonts.load === "function") {
      // Bounded: a browser that never settles the FontFaceSet promise must not
      // be able to stall signing. Rendering with a fallback face is a worse
      // signature, but a stuck modal is a worse bug.
      await Promise.race([
        fonts.load(font, text).catch(() => undefined),
        new Promise((resolve) => setTimeout(resolve, 500)),
      ]);
    }
  }
 
  context.fillStyle = PLATE;
  context.fillRect(0, 0, canvas.width, canvas.height);
 
  context.fillStyle = INK;
  context.font = font;
  // The original set no textBaseline, so the default "alphabetic" put the
  // baseline at the vertical midpoint and the glyphs sat in the upper half of
  // the plate. "middle" centres the text box instead.
  context.textBaseline = "middle";
 
  const textWidth = context.measureText(text).width;
  context.fillText(text, (canvas.width - textWidth) / 2, canvas.height / 2);
 
  return dataURLtoFile(canvas.toDataURL("image/png"), fileName);
}