266 lines
8.4 KiB
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)
|
|
}
|