Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 1da91c033c738ab74538d13d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/SignaturePad.tsx
// Draw or type a signature; returns a trimmed transparent PNG data URL.
import { useEffect, useRef, useState } from "react";
import { Modal } from "./ui";
const INK = "#14244f";
function trimToPng(src: HTMLCanvasElement): string | null {
const ctx = src.getContext("2d")!;
const { width, height } = src;
const data = ctx.getImageData(0, 0, width, height).data;
let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (data[(y * width + x) * 4 + 3] > 8) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) return null;
const pad = 6;
const w = maxX - minX + 1 + pad * 2;
const h = maxY - minY + 1 + pad * 2;
const scale = Math.min(1, 900 / w);
const out = document.createElement("canvas");
out.width = Math.ceil(w * scale);
out.height = Math.ceil(h * scale);
const octx = out.getContext("2d")!;
octx.scale(scale, scale);
octx.drawImage(src, minX - pad, minY - pad, w, h, 0, 0, w, h);
return out.toDataURL("image/png");
}
export function SignaturePad({ open, kind, name, onDone, onClose }: {
open: boolean; kind: "signature" | "initials"; name: string; onDone: (png: string) => void; onClose: () => void;
}) {
const initials = name.split(/\s+/).filter(Boolean).map((w) => w[0]?.toUpperCase()).join("").slice(0, 3);
const [mode, setMode] = useState<"draw" | "type">("draw");
const [typed, setTyped] = useState(kind === "initials" ? initials : name);
const [empty, setEmpty] = useState(true);
const canvas = useRef<HTMLCanvasElement>(null);
const last = useRef<{ x: number; y: number } | null>(null);
const drawing = useRef(false);
useEffect(() => { if (open) setTyped(kind === "initials" ? initials : name); }, [open, kind]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const c = canvas.current;
if (!open || mode !== "draw" || !c) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
// Layout size, not the on-screen box: the sheet may still be sliding or scaling in.
c.width = Math.round(c.offsetWidth * dpr);
c.height = Math.round(c.offsetHeight * dpr);
const ctx = c.getContext("2d")!;
ctx.scale(dpr, dpr);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = INK;
ctx.lineWidth = 2.4;
setEmpty(true);
}, [open, mode, kind]);
const point = (e: React.PointerEvent) => {
const c = canvas.current!;
const r = c.getBoundingClientRect();
return { x: (e.clientX - r.left) * (c.offsetWidth / r.width), y: (e.clientY - r.top) * (c.offsetHeight / r.height) };
};
const down = (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
drawing.current = true;
last.current = point(e);
};
const move = (e: React.PointerEvent) => {
if (!drawing.current || !last.current) return;
const p = point(e);
const ctx = canvas.current!.getContext("2d")!;
const mid = { x: (last.current.x + p.x) / 2, y: (last.current.y + p.y) / 2 };
ctx.beginPath();
ctx.moveTo(last.current.x, last.current.y);
ctx.quadraticCurveTo(last.current.x, last.current.y, mid.x, mid.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
last.current = p;
setEmpty(false);
};
const up = () => { drawing.current = false; last.current = null; };
const clear = () => {
const c = canvas.current!;
c.getContext("2d")!.clearRect(0, 0, c.width, c.height);
setEmpty(true);
};
const typedPng = () => {
const c = document.createElement("canvas");
const size = kind === "initials" ? 110 : 96;
c.width = 1400;
c.height = 220;
const ctx = c.getContext("2d")!;
ctx.fillStyle = INK;
ctx.font = `italic 380 ${size}px "Newsreader Variable", Georgia, serif`;
ctx.textBaseline = "middle";
ctx.fillText(typed.trim(), 20, 115);
return trimToPng(c);
};
const done = () => {
const png = mode === "draw" ? trimToPng(canvas.current!) : typedPng();
if (png) onDone(png);
};
const ready = mode === "draw" ? !empty : typed.trim().length > 0;
return (
<Modal open={open} title={kind === "initials" ? "Your initials" : "Your signature"} onClose={onClose} footer={<>
<button className="btn btn-line" type="button" onClick={mode === "draw" ? clear : onClose} disabled={mode === "draw" && empty}>{mode === "draw" ? "Clear" : "Cancel"}</button>
<button className="btn" type="button" onClick={done} disabled={!ready}>{kind === "initials" ? "Use these initials" : "Use this signature"}</button>
</>}>
<div className="stack">
<div className="segmented segmented-2" role="radiogroup" aria-label="How to sign">
<button type="button" role="radio" aria-checked={mode === "draw"} onClick={() => setMode("draw")}>Draw</button>
<button type="button" role="radio" aria-checked={mode === "type"} onClick={() => setMode("type")}>Type</button>
</div>
{mode === "draw" ? (
<div className="sigpad">
<canvas ref={canvas} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up} onPointerLeave={up} aria-label="Draw your signature here" />
<span className="sigpad-line" aria-hidden="true" />
{empty && <span className="sigpad-hint"><span className="on-fine">Sign with your mouse or trackpad</span><span className="on-coarse">Sign with your finger</span></span>}
</div>
) : (
<>
<label className="field"><span>{kind === "initials" ? "Initials" : "Full name"}</span>
<input className="input" data-autofocus value={typed} onChange={(e) => setTyped(e.target.value)} maxLength={kind === "initials" ? 4 : 60} autoFocus />
</label>
<div className="sigpad sigpad-typed" aria-hidden="true"><span>{typed || " "}</span></div>
</>
)}
</div>
</Modal>
);
}
Change history
Quality pass: phone signing, edit signers, copy to send again
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync