{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"c-signature-pad-4","type":"registry:block","title":"Signing workspace","description":"Ink colors, line weights, undo, redo, replay and PNG or SVG download in one signing card.","dependencies":["cn"],"registryDependencies":["@reui/badge","button","card","dropdown-menu","separator","@reui/signature-pad"],"files":[{"path":"c-signature-pad-4.tsx","type":"registry:block","content":"\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { Badge } from \"@/components/reui/badge\"\nimport {\n  SignaturePad,\n  SignaturePadArea,\n  SignaturePadClear,\n  SignaturePadGuide,\n  SignaturePadPlaceholder,\n  SignaturePadRedo,\n  SignaturePadUndo,\n  type SignaturePadApi,\n  type SignaturePadStroke,\n} from \"@/components/reui/signature-pad\"\nimport { cn } from \"cn\"\n\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardAction,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\n/* `undefined` follows the theme's text color, so it stays legible in dark mode. */\nconst INKS = [\n  { label: \"Default\", color: undefined, swatch: \"bg-foreground\" },\n  { label: \"Blue\", color: \"#2563eb\", swatch: \"bg-blue-600\" },\n  { label: \"Green\", color: \"#059669\", swatch: \"bg-emerald-600\" },\n]\n\nconst WEIGHTS = [\n  { label: \"Fine\", min: 0.5, max: 1.8, sample: 1 },\n  { label: \"Medium\", min: 0.8, max: 3.2, sample: 1.75 },\n  { label: \"Bold\", min: 1.6, max: 5.5, sample: 2.75 },\n]\n\nfunction Swatch({ className }: { className: string }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"ring-foreground/15 size-3.5 shrink-0 rounded-full ring-1 ring-inset\",\n        className\n      )}\n    />\n  )\n}\n\nfunction Stroke({ width }: { width: number }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 24 12\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeWidth={width}\n      className=\"w-5 shrink-0\"\n    >\n      <path d=\"M2 8c3-5 6-5 9-1s7 4 11-3\" />\n    </svg>\n  )\n}\n\nfunction download(href: string, name: string) {\n  const link = document.createElement(\"a\")\n  link.href = href\n  link.download = name\n  link.click()\n}\n\nfunction DownloadMenuIcon({ format }: { format: \"png\" | \"svg\" }) {\n  return format === \"png\" ? (\n    <IconPlaceholder\n      lucide=\"ImageIcon\"\n      tabler=\"IconPhoto\"\n      hugeicons=\"Image01Icon\"\n      phosphor=\"ImageIcon\"\n      remixicon=\"RiImageLine\"\n      aria-hidden=\"true\"\n    />\n  ) : (\n    <IconPlaceholder\n      lucide=\"PenToolIcon\"\n      tabler=\"IconVectorBezier\"\n      hugeicons=\"PenTool03Icon\"\n      phosphor=\"PenNibIcon\"\n      remixicon=\"RiPenNibLine\"\n      aria-hidden=\"true\"\n    />\n  )\n}\n\nconst DOWNLOADS = [\n  { format: \"png\", label: \"PNG image\" },\n  { format: \"svg\", label: \"SVG vector\" },\n] as const\n\nexport function Pattern() {\n  const [ink, setInk] = useState(INKS[0])\n  const [weight, setWeight] = useState(WEIGHTS[1])\n  const [strokes, setStrokes] = useState<SignaturePadStroke[]>([])\n  /* While set, the pad shows this partial copy instead of the real value. */\n  const [replay, setReplay] = useState<SignaturePadStroke[] | null>(null)\n  const [signedAt, setSignedAt] = useState<string | null>(null)\n  const frame = useRef(0)\n  const padRef = useRef<SignaturePadApi>(null)\n\n  useEffect(() => () => cancelAnimationFrame(frame.current), [])\n\n  const playBack = () => {\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return\n    const total = strokes.reduce((sum, stroke) => sum + stroke.points.length, 0)\n    const perFrame = Math.max(2, Math.ceil(total / 80))\n    let shown = 0\n    const step = () => {\n      shown += perFrame\n      let left = shown\n      const partial: SignaturePadStroke[] = []\n      for (const stroke of strokes) {\n        if (left <= 0) break\n        partial.push(\n          left >= stroke.points.length\n            ? stroke\n            : { ...stroke, points: stroke.points.slice(0, left) }\n        )\n        left -= stroke.points.length\n      }\n      if (shown < total) {\n        setReplay(partial)\n        frame.current = requestAnimationFrame(step)\n      } else {\n        setReplay(null)\n      }\n    }\n    frame.current = requestAnimationFrame(step)\n  }\n\n  const locked = signedAt !== null || replay !== null\n\n  /* Through apiRef, so a menu item outside the pad's parts can export it.\n     Paper-white PNG, so the file reads the same on any page. */\n  const save = (format: \"png\" | \"svg\") => {\n    const value = padRef.current?.serialize(\n      format,\n      format === \"png\" ? { background: \"#ffffff\" } : undefined\n    )\n    if (value) download(value, `signature.${format}`)\n  }\n\n  return (\n    <Card className=\"mx-auto w-full max-w-lg\">\n      <CardHeader>\n        <CardTitle>Statement of work</CardTitle>\n        <CardDescription>\n          <span className=\"flex items-center gap-2\">\n            Harbor & Pine Design\n            <span\n              aria-hidden=\"true\"\n              className=\"bg-muted-foreground/40 size-1 shrink-0 rounded-full\"\n            />\n            SOW-2041\n          </span>\n        </CardDescription>\n        <CardAction>\n          {/* Up here, away from Sign statement, so a double click on it\n              cannot land on Edit and reopen what was just signed. */}\n          <div className=\"flex items-center gap-2\">\n            {signedAt && (\n              <Button\n                variant=\"ghost\"\n                size=\"xs\"\n                onClick={() => {\n                  setSignedAt(null)\n                  requestAnimationFrame(() => padRef.current?.focus())\n                }}\n              >\n                Edit\n              </Button>\n            )}\n            <Badge variant={signedAt ? \"success-light\" : \"secondary\"}>\n              {signedAt ? \"Signed\" : \"Draft\"}\n            </Badge>\n          </div>\n        </CardAction>\n      </CardHeader>\n      <CardContent>\n        <SignaturePad\n          apiRef={padRef}\n          /* Replay swaps in a partial copy; readOnly keeps a stray stroke\n             from landing in it and being committed as the real value. */\n          value={replay ?? strokes}\n          onValueChange={setStrokes}\n          color={ink.color}\n          minWidth={weight.min}\n          maxWidth={weight.max}\n          readOnly={locked}\n        >\n          <SignaturePadArea aria-label=\"Client signature\" className=\"h-44\">\n            {/* Raised off the edge so the name below the line has room. */}\n            <SignaturePadGuide className=\"bottom-11\" />\n            <SignaturePadPlaceholder />\n            <p\n              aria-hidden=\"true\"\n              className=\"text-muted-foreground pointer-events-none absolute start-6 bottom-4 text-xs\"\n            >\n              Daniel Reyes, Client\n            </p>\n          </SignaturePadArea>\n          {/* One ghost row: each trigger shows its current choice and names\n              it in the label, so the menus need no visible caption. */}\n          <div className=\"flex items-center gap-1 pt-1\">\n            <DropdownMenu>\n              <DropdownMenuTrigger\n                render={\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon-sm\"\n                    aria-label={`Ink color, ${ink.label}`}\n                    disabled={locked}\n                  />\n                }\n              >\n                <Swatch className={ink.swatch} />\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"start\">\n                <DropdownMenuGroup>\n                  <DropdownMenuLabel>Ink color</DropdownMenuLabel>\n                  <DropdownMenuRadioGroup\n                    value={ink.label}\n                    onValueChange={(value) => {\n                      const option = INKS.find((item) => item.label === value)\n                      if (option) setInk(option)\n                    }}\n                  >\n                    {INKS.map((option) => (\n                      /* Base UI keeps a radio menu open by default. */\n                      <DropdownMenuRadioItem\n                        key={option.label}\n                        value={option.label}\n                        closeOnClick\n                      >\n                        <Swatch className={option.swatch} />\n                        {option.label}\n                      </DropdownMenuRadioItem>\n                    ))}\n                  </DropdownMenuRadioGroup>\n                </DropdownMenuGroup>\n              </DropdownMenuContent>\n            </DropdownMenu>\n            <DropdownMenu>\n              <DropdownMenuTrigger\n                render={\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon-sm\"\n                    aria-label={`Line weight, ${weight.label}`}\n                    disabled={locked}\n                  />\n                }\n              >\n                <Stroke width={weight.sample} />\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"start\">\n                <DropdownMenuGroup>\n                  <DropdownMenuLabel>Line weight</DropdownMenuLabel>\n                  <DropdownMenuRadioGroup\n                    value={weight.label}\n                    onValueChange={(value) => {\n                      const option = WEIGHTS.find(\n                        (item) => item.label === value\n                      )\n                      if (option) setWeight(option)\n                    }}\n                  >\n                    {WEIGHTS.map((option) => (\n                      <DropdownMenuRadioItem\n                        key={option.label}\n                        value={option.label}\n                        closeOnClick\n                      >\n                        <Stroke width={option.sample} />\n                        {option.label}\n                      </DropdownMenuRadioItem>\n                    ))}\n                  </DropdownMenuRadioGroup>\n                </DropdownMenuGroup>\n              </DropdownMenuContent>\n            </DropdownMenu>\n            <Separator orientation=\"vertical\" className=\"mx-1 my-auto h-4\" />\n            <SignaturePadUndo variant=\"ghost\" />\n            <SignaturePadRedo variant=\"ghost\" />\n            <Button\n              variant=\"ghost\"\n              size=\"icon-sm\"\n              aria-label=\"Replay signature\"\n              disabled={strokes.length === 0 || locked}\n              onClick={playBack}\n            >\n              <IconPlaceholder\n                lucide=\"PlayIcon\"\n                tabler=\"IconPlayerPlay\"\n                hugeicons=\"PlayIcon\"\n                phosphor=\"PlayIcon\"\n                remixicon=\"RiPlayLine\"\n                aria-hidden=\"true\"\n              />\n            </Button>\n            <SignaturePadClear variant=\"ghost\" />\n            <DropdownMenu>\n              <DropdownMenuTrigger\n                render={\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon-sm\"\n                    aria-label=\"Download signature\"\n                    className=\"ms-auto\"\n                    disabled={strokes.length === 0 || replay !== null}\n                  />\n                }\n              >\n                <IconPlaceholder\n                  lucide=\"DownloadIcon\"\n                  tabler=\"IconDownload\"\n                  hugeicons=\"Download04Icon\"\n                  phosphor=\"DownloadSimpleIcon\"\n                  remixicon=\"RiDownloadLine\"\n                  aria-hidden=\"true\"\n                />\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\">\n                <DropdownMenuGroup>\n                  <DropdownMenuLabel>Download as</DropdownMenuLabel>\n                  {DOWNLOADS.map((option) => (\n                    <DropdownMenuItem\n                      key={option.format}\n                      onClick={() => save(option.format)}\n                    >\n                      <DownloadMenuIcon format={option.format} />\n                      {option.label}\n                    </DropdownMenuItem>\n                  ))}\n                </DropdownMenuGroup>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n        </SignaturePad>\n      </CardContent>\n      <CardFooter>\n        {signedAt ? (\n          <p\n            ref={(node) => node?.focus()}\n            tabIndex={-1}\n            role=\"status\"\n            className=\"text-muted-foreground ms-auto flex items-center gap-1.5 text-sm outline-none\"\n          >\n            <IconPlaceholder\n              lucide=\"CircleCheckIcon\"\n              tabler=\"IconCircleCheck\"\n              hugeicons=\"CheckmarkCircle02Icon\"\n              phosphor=\"CheckCircleIcon\"\n              remixicon=\"RiCheckboxCircleLine\"\n              aria-hidden=\"true\"\n              className=\"text-success size-4\"\n            />\n            Signed {signedAt}\n          </p>\n        ) : (\n          <Button\n            className=\"ms-auto\"\n            disabled={strokes.length === 0 || replay !== null}\n            onClick={() =>\n              setSignedAt(\n                new Intl.DateTimeFormat(\"en-US\", {\n                  dateStyle: \"medium\",\n                  timeStyle: \"short\",\n                }).format(new Date())\n              )\n            }\n          >\n            Sign statement\n          </Button>\n        )}\n      </CardFooter>\n    </Card>\n  )\n}","target":"components/examples/c-signature-pad-4.tsx"}],"meta":{"order":4,"label":"Signing Workspace"}}