From 4a201ec70dd3b02c119b64138dbf761ba55c4beb Mon Sep 17 00:00:00 2001 From: Loki Date: Sat, 10 Oct 2026 08:58:57 +0800 Subject: [PATCH] feat: signature-pad, draw or upload a handwritten signature Black ink on white paper in every theme, since signatures end up on printed documents; exported as a transparent PNG cropped to the strokes. SignaturePreview shows a saved one on a page. DESIGN.md notes the one color exception. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 2 + app/_docs/demos/index.ts | 2 + app/_docs/demos/signature-pad.tsx | 30 ++ app/_docs/docs.ts | 7 + registry.json | 18 ++ .../blocks/signature-pad/signature-pad.tsx | 291 ++++++++++++++++++ 6 files changed, 350 insertions(+) create mode 100644 app/_docs/demos/signature-pad.tsx create mode 100644 registry/winlab/blocks/signature-pad/signature-pad.tsx diff --git a/DESIGN.md b/DESIGN.md index 9569c92..8294fcc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -132,6 +132,8 @@ Charts use the same colors, not chart-specific tokens: A chart that needs more than two series to be told apart by color is split into small multiples or shown as a table. No Tailwind palette colors and no color literals. +One exception: a handwritten signature is black ink on white paper in every theme, because it ends up on printed documents. `signature-pad` keeps both colors inside its canvas and preview; nothing else may use them. + Draw charts with `chart` (recharts underneath). Its config takes only these colors, so a chart cannot drift from the table above. Bars have 4px rounded ends at the top, lines are 2px, the grid is horizontal only, and two or more series always come with a legend so color is never the only label. Values in the tooltip and on axes use the same units the page does (NT$, 次). Focus follows the component's own color. `--ring` is a neutral gray, so inputs, selects and outline or ghost buttons show a gray focus ring; the primary button shows a primary ring and the destructive button a destructive one. Ultramarine marks the main action, not every focused control. diff --git a/app/_docs/demos/index.ts b/app/_docs/demos/index.ts index 82ac3df..49208dd 100644 --- a/app/_docs/demos/index.ts +++ b/app/_docs/demos/index.ts @@ -36,6 +36,7 @@ import { Demo as Avatar } from "@/app/_docs/demos/avatar" import { Demo as Tooltip } from "@/app/_docs/demos/tooltip" import { Demo as Separator } from "@/app/_docs/demos/separator" import { Demo as Collapsible } from "@/app/_docs/demos/collapsible" +import { Demo as SignaturePad } from "@/app/_docs/demos/signature-pad" import { Demo as Skeleton } from "@/app/_docs/demos/skeleton" import { Demo as Sonner } from "@/app/_docs/demos/sonner" import { Demo as FocusDemo } from "@/app/_docs/demos/focus" @@ -82,6 +83,7 @@ export const demos: Record = { tooltip: Tooltip, separator: Separator, collapsible: Collapsible, + "signature-pad": SignaturePad, skeleton: Skeleton, sonner: Sonner, focus: FocusDemo, diff --git a/app/_docs/demos/signature-pad.tsx b/app/_docs/demos/signature-pad.tsx new file mode 100644 index 0000000..4974ee5 --- /dev/null +++ b/app/_docs/demos/signature-pad.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + SignaturePad, + SignaturePreview, +} from "@/registry/winlab/blocks/signature-pad/signature-pad" +import { Button } from "@/registry/winlab/ui/button" + +export function Demo() { + const [signature, setSignature] = React.useState(null) + return ( +
+ {signature ? ( + + ) : ( +

還沒有簽名

+ )} + {signature ? "重新簽名" : "簽名"}} + onSave={(dataUrl) => { + setSignature(dataUrl) + toast.success("已儲存簽名") + }} + /> +
+ ) +} diff --git a/app/_docs/docs.ts b/app/_docs/docs.ts index 56f659e..4243125 100644 --- a/app/_docs/docs.ts +++ b/app/_docs/docs.ts @@ -139,6 +139,13 @@ export const docs: Doc[] = [ description: "拖放、貼上或點選檔案,送出前可以檢查和移除。", item: "file-upload", }, + { + group: "區塊", + slug: "signature-pad", + title: "簽名板", + description: "手寫或上傳簽名,任何主題都是白紙黑字,存成裁好邊的透明 PNG。", + item: "signature-pad", + }, { group: "元件", slug: "button", diff --git a/registry.json b/registry.json index 02c2e5c..d7f4d36 100644 --- a/registry.json +++ b/registry.json @@ -1003,6 +1003,24 @@ } ] }, + { + "name": "signature-pad", + "type": "registry:block", + "title": "Signature Pad", + "description": "Draw or upload a handwritten signature in a dialog; black ink on white paper in every theme, exported as a cropped transparent PNG.", + "registryDependencies": [ + "https://ui.winlab.tw/r/utils.json", + "https://ui.winlab.tw/r/button.json", + "https://ui.winlab.tw/r/dialog.json", + "https://ui.winlab.tw/r/tabs.json" + ], + "files": [ + { + "path": "registry/winlab/blocks/signature-pad/signature-pad.tsx", + "type": "registry:component" + } + ] + }, { "name": "focus", "type": "registry:block", diff --git a/registry/winlab/blocks/signature-pad/signature-pad.tsx b/registry/winlab/blocks/signature-pad/signature-pad.tsx new file mode 100644 index 0000000..3bb221a --- /dev/null +++ b/registry/winlab/blocks/signature-pad/signature-pad.tsx @@ -0,0 +1,291 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/registry/winlab/lib/utils" +import { Button } from "@/registry/winlab/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/registry/winlab/ui/dialog" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/registry/winlab/ui/tabs" + +// A signature is ink on paper in every theme: it ends up on printed +// documents, so it never follows dark mode. These two are the only colors +// in the block, and they never reach the page's UI. +const INK = "black" +const PAPER = "white" +const HEIGHT = 200 + +type Point = { x: number; y: number } + +/** PNG of the strokes alone on a transparent background, cropped to them. */ +function exportStrokes(strokes: Point[][], width: number) { + const points = strokes.flat() + if (points.length === 0) return null + const pad = 8 + const left = Math.max(0, Math.min(...points.map((p) => p.x)) - pad) + const top = Math.max(0, Math.min(...points.map((p) => p.y)) - pad) + const right = Math.min(width, Math.max(...points.map((p) => p.x)) + pad) + const bottom = Math.min(HEIGHT, Math.max(...points.map((p) => p.y)) + pad) + const scale = 2 + const canvas = document.createElement("canvas") + canvas.width = Math.ceil((right - left) * scale) + canvas.height = Math.ceil((bottom - top) * scale) + const ctx = canvas.getContext("2d") + if (!ctx) return null + ctx.scale(scale, scale) + ctx.translate(-left, -top) + drawStrokes(ctx, strokes) + return canvas.toDataURL("image/png") +} + +function drawStrokes(ctx: CanvasRenderingContext2D, strokes: Point[][]) { + ctx.strokeStyle = INK + ctx.fillStyle = INK + ctx.lineWidth = 2.5 + ctx.lineCap = "round" + ctx.lineJoin = "round" + for (const stroke of strokes) { + if (stroke.length === 1) { + ctx.beginPath() + ctx.arc(stroke[0].x, stroke[0].y, 1.25, 0, Math.PI * 2) + ctx.fill() + continue + } + ctx.beginPath() + stroke.forEach((point, index) => + index === 0 ? ctx.moveTo(point.x, point.y) : ctx.lineTo(point.x, point.y) + ) + ctx.stroke() + } +} + +// Draw with a finger, pen or mouse on white paper. +function DrawPad({ onChange }: { onChange: (dataUrl: string | null) => void }) { + const box = React.useRef(null) + const canvas = React.useRef(null) + const strokes = React.useRef([]) + const [width, setWidth] = React.useState(0) + const [empty, setEmpty] = React.useState(true) + + const redraw = React.useCallback(() => { + const node = canvas.current + const ctx = node?.getContext("2d") + if (!node || !ctx) return + const ratio = window.devicePixelRatio || 1 + ctx.setTransform(ratio, 0, 0, ratio, 0, 0) + ctx.fillStyle = PAPER + ctx.fillRect(0, 0, node.width, node.height) + drawStrokes(ctx, strokes.current) + }, []) + + React.useEffect(() => { + const node = box.current + if (!node) return + const observer = new ResizeObserver(([entry]) => + setWidth(Math.floor(entry.contentRect.width)) + ) + observer.observe(node) + return () => observer.disconnect() + }, []) + + React.useEffect(redraw, [width, redraw]) + + function point(event: React.PointerEvent): Point { + const rect = event.currentTarget.getBoundingClientRect() + return { x: event.clientX - rect.left, y: event.clientY - rect.top } + } + + function finish() { + setEmpty(strokes.current.length === 0) + onChange(exportStrokes(strokes.current, width)) + } + + const ratio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1 + return ( +
+
+ { + event.currentTarget.setPointerCapture(event.pointerId) + strokes.current.push([point(event)]) + redraw() + }} + onPointerMove={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + strokes.current.at(-1)?.push(point(event)) + redraw() + }} + onPointerUp={finish} + onPointerCancel={finish} + /> +
+ +
+ ) +} + +// A saved signature on its white paper, for showing it on a page. +function SignaturePreview({ + src, + className, +}: { + src: string + className?: string +}) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + 簽名 +
+ ) +} + +// Pick a PNG or JPEG of a signature, previewed on the same white paper. +function UploadPad({ + onChange, +}: { + onChange: (dataUrl: string | null) => void +}) { + const [preview, setPreview] = React.useState(null) + return ( +
+ { + const file = event.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => { + const dataUrl = String(reader.result) + setPreview(dataUrl) + onChange(dataUrl) + } + reader.readAsDataURL(file) + }} + /> + {preview && } +
+ ) +} + +// 簽名: draw or upload a signature, then save it. onSave receives a PNG or +// JPEG data URL; throw to keep the dialog open (report the error yourself). +function SignaturePad({ + trigger, + title = "簽名", + onSave, +}: { + trigger: React.ReactElement + title?: string + onSave: (dataUrl: string) => void | Promise +}) { + const [open, setOpen] = React.useState(false) + const [tab, setTab] = React.useState("draw") + const [drawn, setDrawn] = React.useState(null) + const [uploaded, setUploaded] = React.useState(null) + const [pending, startTransition] = React.useTransition() + const value = tab === "draw" ? drawn : uploaded + + function save() { + if (!value) return + startTransition(async () => { + try { + await onSave(value) + setOpen(false) + } catch (error) { + console.error(error) + } + }) + } + + return ( + { + if (pending) return + setOpen(next) + if (next) { + setDrawn(null) + setUploaded(null) + } + }} + > + + + + {title} + + setTab(String(next))} + className="flex flex-col gap-4" + > + + 手寫 + 上傳圖片 + + + {open && } + + + + + + + } + > + 取消 + + + + + + ) +} + +export { SignaturePad, SignaturePreview }