Files
kjol/go/webui/signaturepad.go

266 lines
8.4 KiB
Go

package webui
import (
"strconv"
"strings"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of FormSignaturePad (jsruntime/uikit/Forms.tsx) — freehand signing.
//
// Two deliberate departures from the TSX.
//
// It draws into an SVG, not a <canvas>. The component's OUTPUT is SVG — the TSX kept
// strokes in memory, painted them onto a canvas for the user, and serialized a separate
// SVG string for the caller. That is two renderers for one drawing, and they can
// disagree. Here the SVG the user is looking at IS the value the caller gets, so it
// cannot be wrong. It also means a stored signature renders on the SERVER: the same
// markup, no client needed to see it.
//
// And the in-flight stroke never touches a signal. A pointer moves sixty times a second,
// and a signal write re-renders the whole page; the live stroke is written straight at
// the element with SetHTML, and only the FINISHED stroke is committed. Without that,
// signing your name would re-render the document a few hundred times.
//
// Create it once, alongside your signals — never inside a render.
type SignaturePad struct {
svgRef *vdom.Ref
// strokes are the finished ones: committed, re-rendered, part of the value.
strokes *vdom.Signal[[]sigStroke]
// live is the stroke being drawn right now. NOT a signal — see above.
live sigStroke
drawing bool
unsubs []wasmruntime.Unsub
onChange func(svg string)
width float64
height float64
}
type sigPoint struct{ X, Y float64 }
type sigStroke []sigPoint
// SignaturePadOptions configures NewSignaturePad.
type SignaturePadOptions struct {
// Width and Height are the SVG's coordinate space (its viewBox), not its size on
// screen — the element scales to its container and the strokes scale with it.
// Default 600x120.
Width, Height float64
// OnChange receives the signature as an SVG document, or "" when it is cleared.
OnChange func(svg string)
}
// NewSignaturePad creates a signature pad.
func NewSignaturePad(o SignaturePadOptions) *SignaturePad {
if o.Width <= 0 {
o.Width = 600
}
if o.Height <= 0 {
o.Height = 120
}
return &SignaturePad{
svgRef: vdom.NewRef(),
strokes: vdom.NewSignal([]sigStroke{}),
onChange: o.OnChange,
width: o.Width,
height: o.Height,
}
}
// SignaturePadProps configures a render.
type SignaturePadProps struct {
Class string
Hint string // shown while empty; default "Sign above"
ClearText string // default "Clear"
Disabled bool
HideFooter bool // no hint / Clear button — for showing a signature back, read-only
}
// IsEmpty reports whether anything has been drawn.
func (s *SignaturePad) IsEmpty() bool { return len(s.strokes.Get()) == 0 }
// SVG is the signature as a standalone SVG document — the value to store. It is "" when
// the pad is empty, so an empty pad is an empty string rather than a blank drawing.
func (s *SignaturePad) SVG() string {
strokes := s.strokes.Get()
if len(strokes) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 `)
b.WriteString(sigNum(s.width) + " " + sigNum(s.height))
b.WriteString(`" width="` + sigNum(s.width) + `" height="` + sigNum(s.height) + `">`)
b.WriteString(sigPaths(strokes))
b.WriteString(`</svg>`)
return b.String()
}
// Clear empties the pad.
func (s *SignaturePad) Clear() {
s.endDrag()
s.live = nil
s.strokes.Set([]sigStroke{})
if s.onChange != nil {
s.onChange("")
}
}
// Dispose removes any listeners left behind by an interrupted drag.
func (s *SignaturePad) Dispose() { s.endDrag() }
// Render draws the pad.
func (s *SignaturePad) Render(p SignaturePadProps) *vdom.VNode {
svgMods := []vdom.Mod{
vdom.WithRef(s.svgRef),
vdom.Attr("viewBox", "0 0 "+sigNum(s.width)+" "+sigNum(s.height)),
// touch-none: without it, drawing on a phone scrolls the page instead.
vdom.Attr("class", "block h-auto w-full cursor-crosshair touch-none"),
vdom.Attr("role", "img"),
vdom.Raw(sigPaths(s.strokes.Get())),
}
if !p.Disabled {
svgMods = append(svgMods, vdom.OnEvent(vdom.EVENT_POINTERDOWN, s.onDown))
}
kids := []*vdom.VNode{vdom.Svg(svgMods...)}
if !p.HideFooter {
hint := ""
if s.IsEmpty() {
hint = pick(p.Hint, "Sign above")
}
clear := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "text-ss text-ink-muted hover:text-ink disabled:opacity-50"),
vdom.Text(pick(p.ClearText, "Clear")),
}
if p.Disabled || s.IsEmpty() {
clear = append(clear, vdom.Attr("disabled", "disabled"))
} else {
clear = append(clear, vdom.On(vdom.EVENT_CLICK, s.Clear))
}
kids = append(kids, vdom.Div(
vdom.Attr("class", "flex items-center justify-between border-t border-line bg-surface-muted px-2 py-1"),
vdom.Span(vdom.Attr("class", "text-ss italic text-ink-faint"), vdom.Text(hint)),
vdom.Button(clear...),
))
}
mods := []vdom.Mod{vdom.Attr("class", cx("overflow-hidden rounded-default border border-line-strong bg-surface", p.Class))}
for _, k := range kids {
mods = append(mods, k)
}
return vdom.Div(mods...)
}
// ---- drawing ------------------------------------------------------------
func (s *SignaturePad) onDown(e vdom.Event) {
e.PreventDefault()
s.endDrag() // a previous drag that never got its pointerup (alt-tab, say)
s.drawing = true
s.live = sigStroke{s.point(e)}
// The listeners go on the DOCUMENT, not the element. Drag off the edge of the pad
// and the stroke should follow the cursor and finish when you let go — with element
// listeners the pointer simply escapes and the stroke is left half-drawn.
s.unsubs = append(s.unsubs,
wasmruntime.OnDocument(vdom.EVENT_POINTERMOVE, false, s.onMove),
wasmruntime.OnDocument(vdom.EVENT_POINTERUP, false, s.onUp),
wasmruntime.OnDocument(vdom.EVENT_POINTERCANCEL, false, s.onUp),
)
}
func (s *SignaturePad) onMove(e vdom.Event) {
if !s.drawing {
return
}
s.live = append(s.live, s.point(e))
// Imperative. This runs on every pointer move; a signal here would re-render the
// entire application between one pixel of ink and the next.
wasmruntime.SetHTML(s.svgRef, sigPaths(append(append([]sigStroke{}, s.strokes.Get()...), s.live)))
}
func (s *SignaturePad) onUp(vdom.Event) {
if !s.drawing {
return
}
s.endDrag()
// A stroke of one point is a click, not a mark. Dropping it keeps a stray tap from
// counting as a signature.
if len(s.live) >= 2 {
s.strokes.Set(append(append([]sigStroke{}, s.strokes.Get()...), s.live))
if s.onChange != nil {
s.onChange(s.SVG())
}
}
s.live = nil
}
func (s *SignaturePad) endDrag() {
s.drawing = false
for _, u := range s.unsubs {
u()
}
s.unsubs = nil
}
// point maps a pointer's viewport coordinates into the SVG's coordinate space. The
// element is fluid and the viewBox is fixed, so the two differ by whatever the browser
// scaled the SVG to — measuring is the only way to know.
func (s *SignaturePad) point(e vdom.Event) sigPoint {
r := wasmruntime.Measure(s.svgRef)
if r.Width == 0 || r.Height == 0 {
return sigPoint{}
}
return sigPoint{
X: (float64(e.ClientX()) - r.X) * (s.width / r.Width),
Y: (float64(e.ClientY()) - r.Y) * (s.height / r.Height),
}
}
// ---- serialization ------------------------------------------------------
// sigPaths renders strokes as SVG <path> elements, smoothed.
//
// Straight lines between raw pointer samples look like a seismograph, not handwriting.
// Each segment is a quadratic curve THROUGH the sampled point and ending at the midpoint
// of the next one, which is the standard trick for turning a polyline into something
// that reads as a pen stroke.
func sigPaths(strokes []sigStroke) string {
var b strings.Builder
for _, st := range strokes {
if len(st) < 2 {
continue
}
b.WriteString(`<path d="M`)
b.WriteString(sigNum(st[0].X) + "," + sigNum(st[0].Y))
if len(st) == 2 {
b.WriteString(" L" + sigNum(st[1].X) + "," + sigNum(st[1].Y))
} else {
for i := 1; i < len(st)-1; i++ {
mx := (st[i].X + st[i+1].X) / 2
my := (st[i].Y + st[i+1].Y) / 2
b.WriteString(" Q" + sigNum(st[i].X) + "," + sigNum(st[i].Y) + "," + sigNum(mx) + "," + sigNum(my))
}
last := st[len(st)-1]
b.WriteString(" L" + sigNum(last.X) + "," + sigNum(last.Y))
}
b.WriteString(`" fill="none" stroke="#1a1a2e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`)
}
return b.String()
}
// sigNum formats a coordinate to one decimal, without a trailing ".0" — the markup is
// the value the caller stores, and there is no reason to store 600.0 as six bytes.
func sigNum(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}