Add landing page for kjol, documentation
This commit is contained in:
265
go/webui/signaturepad.go
Normal file
265
go/webui/signaturepad.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// Port of FormSignaturePad (web/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-xs 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-xs 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)
|
||||
}
|
||||
Reference in New Issue
Block a user