Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions app/_docs/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -82,6 +83,7 @@ export const demos: Record<string, ComponentType> = {
tooltip: Tooltip,
separator: Separator,
collapsible: Collapsible,
"signature-pad": SignaturePad,
skeleton: Skeleton,
sonner: Sonner,
focus: FocusDemo,
Expand Down
30 changes: 30 additions & 0 deletions app/_docs/demos/signature-pad.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)
return (
<div className="flex flex-col items-center gap-6">
{signature ? (
<SignaturePreview src={signature} className="w-64" />
) : (
<p className="text-muted-foreground">還沒有簽名</p>
)}
<SignaturePad
trigger={<Button>{signature ? "重新簽名" : "簽名"}</Button>}
onSave={(dataUrl) => {
setSignature(dataUrl)
toast.success("已儲存簽名")
}}
/>
</div>
)
}
7 changes: 7 additions & 0 deletions app/_docs/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
291 changes: 291 additions & 0 deletions registry/winlab/blocks/signature-pad/signature-pad.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const canvas = React.useRef<HTMLCanvasElement>(null)
const strokes = React.useRef<Point[][]>([])
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<HTMLCanvasElement>): 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 (
<div className="flex flex-col gap-2">
<div
ref={box}
className="h-50 overflow-hidden rounded-control border border-border"
>
<canvas
ref={canvas}
width={width * ratio}
height={HEIGHT * ratio}
aria-label="簽名區"
className="size-full touch-none"
onPointerDown={(event) => {
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}
/>
</div>
<Button
type="button"
variant="ghost"
className="self-start"
disabled={empty}
onClick={() => {
strokes.current = []
redraw()
finish()
}}
>
清除
</Button>
</div>
)
}

// A saved signature on its white paper, for showing it on a page.
function SignaturePreview({
src,
className,
}: {
src: string
className?: string
}) {
return (
<div
data-slot="signature-preview"
className={cn(
"flex h-24 items-center justify-center rounded-control border border-border bg-(--paper) p-2 [--paper:white]",
className
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt="簽名" className="max-h-full max-w-full" />
</div>
)
}

// 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<string | null>(null)
return (
<div className="flex flex-col gap-2">
<input
type="file"
accept="image/png,image/jpeg"
aria-label="選擇簽名圖片"
className="text-body file:mr-4 file:h-10 file:rounded-control file:border file:border-border file:bg-background file:px-4 file:font-medium"
onChange={(event) => {
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 && <SignaturePreview src={preview} className="h-50" />}
</div>
)
}

// 簽名: 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<void>
}) {
const [open, setOpen] = React.useState(false)
const [tab, setTab] = React.useState("draw")
const [drawn, setDrawn] = React.useState<string | null>(null)
const [uploaded, setUploaded] = React.useState<string | null>(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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (pending) return
setOpen(next)
if (next) {
setDrawn(null)
setUploaded(null)
}
}}
>
<DialogTrigger render={trigger} />
<DialogContent showCloseButton={false}>
<DialogHeader className="pr-0">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<Tabs
value={tab}
onValueChange={(next) => setTab(String(next))}
className="flex flex-col gap-4"
>
<TabsList>
<TabsTrigger value="draw">手寫</TabsTrigger>
<TabsTrigger value="upload">上傳圖片</TabsTrigger>
</TabsList>
<TabsContent value="draw">
{open && <DrawPad onChange={setDrawn} />}
</TabsContent>
<TabsContent value="upload">
<UploadPad onChange={setUploaded} />
</TabsContent>
</Tabs>
<DialogFooter>
<DialogClose
disabled={pending}
render={<Button type="button" variant="outline" />}
>
取消
</DialogClose>
<Button disabled={!value || pending} onClick={save}>
{pending ? "儲存中…" : "儲存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

export { SignaturePad, SignaturePreview }
Loading