package webui import ( "strconv" "strings" "kjol/wasmruntime" "kjol/wasmruntime/vdom" ) // Port of FormSignaturePad (jsruntime/uikit/Forms.tsx) — freehand signing. // // Two deliberate departures from the TSX. // // It draws into an SVG, not a . 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(``) b.WriteString(sigPaths(strokes)) b.WriteString(``) 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 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(``) } 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) }