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 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | 2x 7x 7x 7x 7x 7x 7x 7x 7x 7x 43x 43x 43x 43x 43x 43x 43x 43x 43x 43x 43x 43x 43x 43x 434x 43x 18x 18x 18x 31x 31x 7x 18x 18x 20x 20x 23x 20x 18x 18x 18x 18x 18x 18x 8x 8x 8x 8x 18x 18x 18x 18x 18x 18x 18x 43x 18x 43x 43x 43x 17x 15x 15x 2x 43x 17x 2x 1x 1x 5x 2x 2x 12x 1x 43x 43x 43x | /**
* `<SignaturePad />` — drop-in signature input: a container-filling
* canvas wired to the headless ink engine, with an optional on-canvas
* "sign here" guideline, an imperative controller, and edge-only change
* callbacks.
*/
import {
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
type Ref,
} from "react";
import { useIsomorphicLayoutEffect } from "@usefy/use-isomorphic-layout-effect";
import { useLatest } from "@usefy/use-latest";
import { createSignatureEngine, type SignatureEngine } from "./engine/createEngine";
import { isSafeCssColor } from "./ink/svg";
import {
toFinite,
type PNGExportOptions,
type SignatureData,
type SignatureOptions,
type SVGExportOptions,
} from "./types";
import { emptySignatureData, emptyToPNG, emptyToSVG } from "./reactFallbacks";
/**
* Guideline configuration — a horizontal "sign here" baseline drawn
* on-canvas BENEATH the ink and excluded from every export (PNG/SVG/
* JSON) by construction.
*/
/**
* @example
* ```tsx
* <SignaturePad guideline /> // defaults
* <SignaturePad guideline={{ y: 140, inset: 32, color: "#cbd5e1" }} />
* ```
*/
export interface SignatureGuideline {
/** Baseline y in CSS px. @default 75% of the canvas height */
y?: number;
/** Horizontal inset from both edges, px. @default 24 */
inset?: number;
/** Line color (sanitized). @default "#94a3b8" */
color?: string;
}
const DEFAULT_GUIDELINE_COLOR = "#94a3b8";
/**
* Imperative handle exposed through {@link SignaturePadProps.controllerRef}.
* The controller object is referentially stable for the component's
* lifetime; calls made before the canvas has mounted (or after unmount)
* are safe no-ops — reads return minimal empty artifacts.
*
* @example
* ```tsx
* const pad = useRef<SignaturePadController>(null);
* <SignaturePad controllerRef={pad} />
* <button onClick={() => pad.current?.clear()}>Clear</button>
* ```
*/
export interface SignaturePadController {
clear(): void;
undo(): void;
redo(): void;
isEmpty(): boolean;
toPNG(opts?: PNGExportOptions): Promise<{ dataURL: string; blob: Blob }>;
toSVG(opts?: SVGExportOptions): string;
toJSON(): SignatureData;
fromJSON(data: SignatureData): void;
}
/**
* Props for {@link SignaturePad}. Extends every ink option (SPEC §3.6).
*
* @example
* ```tsx
* <SignaturePad
* penColor="#0f172a"
* guideline
* onChange={({ isEmpty }) => setCanSubmit(!isEmpty)}
* />
* ```
*/
export interface SignaturePadProps extends SignatureOptions {
/** Receives the imperative {@link SignaturePadController}. */
controllerRef?: Ref<SignaturePadController>;
/** Render a stored signature (applied once, on mount). */
defaultValue?: SignatureData;
/** Disable input; rendering/exports/restore keep working. @default false */
readOnly?: boolean;
/**
* Baseline "sign here" guideline, drawn under the ink, never exported.
* `true` for defaults or a {@link SignatureGuideline} object.
* @default false
*/
guideline?: boolean | SignatureGuideline;
/** Fires when a stroke begins. */
onBegin?: () => void;
/** Fires when a stroke ends. */
onEnd?: () => void;
/**
* Fires on state edges only (stroke end / undo / redo / clear /
* fromJSON) — never per point.
*/
onChange?: (state: { isEmpty: boolean; strokeCount: number }) => void;
/** Extra class for the canvas element. */
className?: string;
/** Extra inline styles, merged over the built-in canvas styles. */
style?: CSSProperties;
/**
* Accessible name base. The live state (", empty" / ", signed") is
* appended and updated on change edges.
* @default "Signature input area"
*/
"aria-label"?: string;
}
/** Draw the guideline underlay (CSS-px coordinates). */
function drawGuideline(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
config: SignatureGuideline,
): void {
const inset = Math.max(0, toFinite(config.inset, 24));
const y = toFinite(config.y, Math.round(height * 0.75));
const color =
typeof config.color === "string" && isSafeCssColor(config.color)
? config.color
: DEFAULT_GUIDELINE_COLOR;
ctx.beginPath();
ctx.moveTo(inset, y);
ctx.lineTo(width - inset, y);
ctx.lineWidth = 1;
ctx.strokeStyle = color;
ctx.stroke();
}
/**
* Signature input component.
*
* - Renders a **container-filling canvas** in place (no portal) — SSR
* renders the inert canvas markup, hydration-safe; the engine only
* exists client-side.
* - **Zero React renders while ink is flowing** — state updates only on
* engine edges (stroke end / undo / redo / clear / restore).
* - **Ink survives option changes**: changing ink props (e.g.
* `penColor`) recreates the engine but carries the **committed**
* strokes over exactly (strokes are self-contained), so existing ink
* keeps its captured style and new strokes pick up the new options.
* Two costs, by design: history resets, and a stroke that is
* IN PROGRESS at the moment the props change is discarded (the new
* engine never saw its pointer-down).
* - `guideline` draws beneath the ink via the engine's underlay seam and
* is excluded from PNG/SVG/JSON exports by construction.
* - StrictMode-safe: the dev double-mount leaves exactly one engine and
* zero orphaned listeners/observers.
* - A11y: `role="img"` with a state-reflecting `aria-label`
* ("…, empty" / "…, signed") updated on change edges (SPEC §8). For a
* keyboard-accessible alternative, pair with a typed-signature
* fallback (README recipe).
*
* @example
* ```tsx
* import { SignaturePad, type SignaturePadController } from "@usefy/signature-pad";
* import { useRef, useState } from "react";
*
* function ConsentForm() {
* const pad = useRef<SignaturePadController>(null);
* const [signed, setSigned] = useState(false);
* return (
* <div style={{ height: 200 }}>
* <SignaturePad
* guideline
* controllerRef={pad}
* onChange={({ isEmpty }) => setSigned(!isEmpty)}
* />
* <button disabled={!signed} onClick={async () => {
* const { blob } = await pad.current!.toPNG({ background: "#fff" });
* await submit(blob);
* }}>
* Sign & submit
* </button>
* </div>
* );
* }
* ```
*/
export function SignaturePad(props: SignaturePadProps): ReactNode {
const {
controllerRef,
defaultValue,
readOnly = false,
guideline = false,
onBegin,
onEnd,
onChange,
className,
style,
"aria-label": ariaLabel = "Signature input area",
penColor,
minWidth,
maxWidth,
velocityFilterWeight,
minDistance,
pressure,
acceptPointerTypes,
dpr,
background,
} = props;
const canvasElRef = useRef<HTMLCanvasElement | null>(null);
const engineRef = useRef<SignatureEngine | null>(null);
const [padState, setPadState] = useState({ isEmpty: true, strokeCount: 0 });
const onBeginRef = useLatest(onBegin);
const onEndRef = useLatest(onEnd);
const onChangeRef = useLatest(onChange);
const guidelineRef = useLatest(guideline);
const readOnlyRef = useLatest(readOnly);
/** Applied once, on first engine creation (SPEC §3.5). */
const defaultValueRef = useRef<SignatureData | undefined>(defaultValue);
/** Ink carried across engine recreations (option changes, StrictMode). */
const carryRef = useRef<SignatureData | null>(null);
/**
* True while an INTERNAL fromJSON runs (carry-over / defaultValue) —
* suppresses the consumer `onChange` for restores that only re-apply
* state the consumer already knows.
*/
const internalRestoreRef = useRef(false);
const options: SignatureOptions = {
penColor,
minWidth,
maxWidth,
velocityFilterWeight,
minDistance,
pressure,
acceptPointerTypes,
dpr,
background,
};
const optionsRef = useLatest(options);
// Value-identity key: recreate the engine only when an option VALUE
// changes (an inline acceptPointerTypes array must not churn engines).
const optionsKey = JSON.stringify(options, (_k, v: unknown) => (v === undefined ? null : v));
// Engine lifecycle.
useIsomorphicLayoutEffect(() => {
const canvas = canvasElRef.current;
Iif (!canvas) return;
const engine = createSignatureEngine(canvas, {
...optionsRef.current,
readOnly: readOnlyRef.current,
renderUnderlay: (ctx, width, height) => {
const config = guidelineRef.current;
if (!config) return;
drawGuideline(ctx, width, height, config === true ? {} : config);
},
});
engineRef.current = engine;
const offChange = engine.onChange((e) => {
const next = { isEmpty: e.isEmpty, strokeCount: e.strokeCount };
// Value-bail: no-op updates must not re-render (house rule).
setPadState((prev) =>
prev.isEmpty === next.isEmpty && prev.strokeCount === next.strokeCount ? prev : next,
);
if (!internalRestoreRef.current) onChangeRef.current?.(next);
});
const offBegin = engine.onBegin(() => onBeginRef.current?.());
const offEnd = engine.onEnd(() => onEndRef.current?.());
// Restore: carried ink from a previous engine (option change /
// StrictMode remount) wins; otherwise the one-shot defaultValue.
const restore = carryRef.current ?? defaultValueRef.current;
carryRef.current = null;
defaultValueRef.current = undefined;
if (restore && restore.strokes.length > 0) {
internalRestoreRef.current = true;
try {
engine.fromJSON(restore);
} finally {
internalRestoreRef.current = false;
}
}
return () => {
offChange();
offBegin();
offEnd();
// Carry the ink into the next engine (self-contained strokes make
// this lossless). On real unmount the ref is simply dropped.
carryRef.current = engine.toJSON();
engine.destroy();
engineRef.current = null;
};
}, [optionsKey]);
// readOnly is reactive (input attach/detach only — no engine churn).
useEffect(() => {
engineRef.current?.setReadOnly(readOnly);
}, [readOnly]);
// Guideline changes redraw in place (resize() forces a full replay).
// (guideline is destructure-defaulted, so stringify always returns a
// string.) First-mount guarded: the creation replay already drew it.
const guidelineKey = JSON.stringify(guideline);
const guidelineMountedRef = useRef(false);
useEffect(() => {
if (!guidelineMountedRef.current) {
guidelineMountedRef.current = true;
return;
}
engineRef.current?.resize();
}, [guidelineKey]);
// Stable imperative controller (safe no-ops before/after the engine).
const controller = useMemo<SignaturePadController>(
() => ({
clear: () => {
engineRef.current?.clear();
},
undo: () => {
engineRef.current?.undo();
},
redo: () => {
engineRef.current?.redo();
},
isEmpty: () => (engineRef.current ? engineRef.current.isEmpty : true),
toPNG: (opts?: PNGExportOptions) =>
engineRef.current ? engineRef.current.toPNG(opts) : emptyToPNG(opts),
toSVG: (opts?: SVGExportOptions) =>
engineRef.current ? engineRef.current.toSVG(opts) : emptyToSVG(opts),
toJSON: () => (engineRef.current ? engineRef.current.toJSON() : emptySignatureData()),
fromJSON: (data: SignatureData) => {
engineRef.current?.fromJSON(data);
},
}),
[],
);
useImperativeHandle(controllerRef, () => controller, [controller]);
const canvasStyle: CSSProperties = {
display: "block",
width: "100%",
height: "100%",
// touch-action is enforced by the engine on mount; setting it in the
// markup too keeps the SSR-rendered canvas scroll-safe pre-hydration.
touchAction: "none",
...style,
};
return (
<canvas
ref={canvasElRef}
role="img"
aria-label={`${ariaLabel}${padState.isEmpty ? ", empty" : ", signed"}`}
data-usefy-signature-pad=""
className={className}
style={canvasStyle}
/>
);
}
|