Add fonts, autotable, autotable examples
This commit is contained in:
295
go/webui/position.go
Normal file
295
go/webui/position.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// The floating-position engine: given where the trigger is, how big the panel is,
|
||||
// and how big the viewport is, work out where to put the panel. Pure math over
|
||||
// structs — no DOM, no browser — so it tests natively and runs identically on the
|
||||
// server (where it simply never gets called, because nothing is open during SSR).
|
||||
//
|
||||
// This replaces the hand-rolled positioning that the TSX kit had in four divergent
|
||||
// copies (Floating.tsx, Tutorial.tsx, Menu's Submenu, DatePicker's dropdown). It
|
||||
// deliberately FIXES three flaws in the original rather than reproducing them:
|
||||
//
|
||||
// 1. The original shifted on BOTH axes, so a tall panel that did not fit got
|
||||
// clamped up into its own trigger — covering the thing you clicked. Shift here
|
||||
// is cross-axis only (what floating-ui does): the main axis is the flip's job.
|
||||
// 2. The original had no arrow offset, so a shifted panel kept its arrow pinned to
|
||||
// its own centre and the arrow visibly detached from the trigger. Here the
|
||||
// arrow tracks the trigger's centre, clamped to stay on the panel.
|
||||
// 3. The original could not tell a caller how much room there actually was, so a
|
||||
// long menu near the viewport edge just overflowed. MaxHeight / MaxWidth report
|
||||
// the space available on the main axis so the panel can scroll instead.
|
||||
//
|
||||
// All coordinates are viewport coordinates (getBoundingClientRect + position:fixed
|
||||
// speak the same language), so there is no scroll compensation anywhere. Adding
|
||||
// scrollX/scrollY here would be a bug, not a fix.
|
||||
|
||||
// Placement values: a base side, optionally with a cross-axis alignment.
|
||||
const (
|
||||
PlacementTop = "top"
|
||||
PlacementTopStart = "top-start"
|
||||
PlacementTopEnd = "top-end"
|
||||
PlacementBottom = "bottom"
|
||||
PlacementBottomStart = "bottom-start"
|
||||
PlacementBottomEnd = "bottom-end"
|
||||
PlacementLeft = "left"
|
||||
PlacementLeftStart = "left-start"
|
||||
PlacementLeftEnd = "left-end"
|
||||
PlacementRight = "right"
|
||||
PlacementRightStart = "right-start"
|
||||
PlacementRightEnd = "right-end"
|
||||
)
|
||||
|
||||
// PositionOptions tunes ComputePosition. The zero value is not useful; use
|
||||
// DefaultPositionOptions and override.
|
||||
type PositionOptions struct {
|
||||
Placement string // one of the Placement* constants; default bottom-start
|
||||
Offset float64 // gap between trigger and panel, in px
|
||||
Flip bool // move to the opposite side when this side does not fit
|
||||
Shift bool // slide along the cross axis to stay in the viewport
|
||||
Padding float64 // keep this far from the viewport edge (flip fit-check + shift clamp)
|
||||
|
||||
// ArrowSize is the arrow's full width/height in px. Zero means no arrow, and
|
||||
// ArrowOffset is left at 0.
|
||||
ArrowSize float64
|
||||
// ArrowPadding keeps the arrow this far from the panel's corners, so it never
|
||||
// pokes out of a rounded edge.
|
||||
ArrowPadding float64
|
||||
}
|
||||
|
||||
// DefaultPositionOptions matches the TSX defaults (bottom-start, 4px offset, flip
|
||||
// and shift on, 8px viewport padding).
|
||||
func DefaultPositionOptions() PositionOptions {
|
||||
return PositionOptions{
|
||||
Placement: PlacementBottomStart,
|
||||
Offset: 4,
|
||||
Flip: true,
|
||||
Shift: true,
|
||||
Padding: 8,
|
||||
ArrowPadding: 4,
|
||||
}
|
||||
}
|
||||
|
||||
// Position is where to put the panel, in viewport coordinates.
|
||||
type Position struct {
|
||||
Top, Left float64
|
||||
|
||||
// Placement is the RESOLVED placement — after any flip. This is consumer-visible:
|
||||
// an arrow must be drawn on the side opposite the resolved base, not the
|
||||
// requested one, or a flipped tooltip points away from what it describes.
|
||||
Placement string
|
||||
|
||||
// ArrowOffset is the arrow's centre along the panel's cross axis, measured from
|
||||
// the panel's own top-left: an x for top/bottom placements, a y for left/right.
|
||||
// Zero when ArrowSize is 0.
|
||||
ArrowOffset float64
|
||||
|
||||
// MaxHeight / MaxWidth are the space available on the main axis at the resolved
|
||||
// placement, for a panel that should scroll rather than overflow. Only the
|
||||
// main-axis one is set; the other is 0, meaning unconstrained.
|
||||
MaxHeight, MaxWidth float64
|
||||
}
|
||||
|
||||
// ComputePosition places `floating` against `trigger` inside `vp`.
|
||||
//
|
||||
// The caller must have measured a real, laid-out panel: to know where a panel goes
|
||||
// you must first know how big it is, which means rendering it (invisibly) and
|
||||
// measuring it. See Floating.reposition, which does exactly that dance.
|
||||
func ComputePosition(trigger, floating wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) Position {
|
||||
if o.Placement == "" {
|
||||
o.Placement = PlacementBottomStart
|
||||
}
|
||||
base, align := splitPlacement(o.Placement)
|
||||
|
||||
top, left := mainAxis(base, trigger, floating, o.Offset)
|
||||
crossAxis(base, align, trigger, floating, &top, &left)
|
||||
|
||||
if o.Flip {
|
||||
base = flip(base, top, left, trigger, floating, vp, o, &top, &left)
|
||||
}
|
||||
if o.Shift {
|
||||
shiftCrossAxis(base, floating, vp, o.Padding, &top, &left)
|
||||
}
|
||||
|
||||
pos := Position{Top: top, Left: left, Placement: joinPlacement(base, align)}
|
||||
pos.MaxHeight, pos.MaxWidth = available(base, trigger, vp, o)
|
||||
if o.ArrowSize > 0 {
|
||||
pos.ArrowOffset = arrowOffset(base, trigger, floating, top, left, o)
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
// mainAxis positions the panel on the axis it is offset along — the only axis the
|
||||
// base side controls.
|
||||
func mainAxis(base string, t, f wasmruntime.Rect, offset float64) (top, left float64) {
|
||||
switch base {
|
||||
case "top":
|
||||
top = t.Top() - f.Height - offset
|
||||
case "bottom":
|
||||
top = t.Bottom() + offset
|
||||
case "left":
|
||||
left = t.Left() - f.Width - offset
|
||||
case "right":
|
||||
left = t.Right() + offset
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// crossAxis aligns the panel across the base side: start/center/end.
|
||||
func crossAxis(base, align string, t, f wasmruntime.Rect, top, left *float64) {
|
||||
if base == "top" || base == "bottom" {
|
||||
switch align {
|
||||
case "start":
|
||||
*left = t.Left()
|
||||
case "end":
|
||||
*left = t.Right() - f.Width
|
||||
default:
|
||||
*left = t.CenterX() - f.Width/2
|
||||
}
|
||||
return
|
||||
}
|
||||
switch align {
|
||||
case "start":
|
||||
*top = t.Top()
|
||||
case "end":
|
||||
*top = t.Bottom() - f.Height
|
||||
default:
|
||||
*top = t.CenterY() - f.Height/2
|
||||
}
|
||||
}
|
||||
|
||||
// flip moves the panel to the opposite side when it does not fit on this one — but
|
||||
// only if the opposite side actually fits. If neither side fits we keep the
|
||||
// original: flipping into an equally bad position just makes it harder to predict.
|
||||
// The cross-axis alignment is preserved (bottom-start flips to top-start).
|
||||
func flip(base string, top, left float64, t, f wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions, outTop, outLeft *float64) string {
|
||||
pad := o.Padding
|
||||
fits := func(start, size, limit float64) bool { return start >= pad && start+size <= limit-pad }
|
||||
|
||||
switch base {
|
||||
case "bottom":
|
||||
if !fits(top, f.Height, vp.Height) {
|
||||
if alt := t.Top() - f.Height - o.Offset; fits(alt, f.Height, vp.Height) {
|
||||
*outTop = alt
|
||||
return "top"
|
||||
}
|
||||
}
|
||||
case "top":
|
||||
if !fits(top, f.Height, vp.Height) {
|
||||
if alt := t.Bottom() + o.Offset; fits(alt, f.Height, vp.Height) {
|
||||
*outTop = alt
|
||||
return "bottom"
|
||||
}
|
||||
}
|
||||
case "right":
|
||||
if !fits(left, f.Width, vp.Width) {
|
||||
if alt := t.Left() - f.Width - o.Offset; fits(alt, f.Width, vp.Width) {
|
||||
*outLeft = alt
|
||||
return "left"
|
||||
}
|
||||
}
|
||||
case "left":
|
||||
if !fits(left, f.Width, vp.Width) {
|
||||
if alt := t.Right() + o.Offset; fits(alt, f.Width, vp.Width) {
|
||||
*outLeft = alt
|
||||
return "right"
|
||||
}
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// shiftCrossAxis slides the panel along the CROSS axis to keep it on screen. It
|
||||
// deliberately does not touch the main axis: clamping there is what let the
|
||||
// original push a panel on top of its own trigger.
|
||||
func shiftCrossAxis(base string, f wasmruntime.Rect, vp wasmruntime.Size, pad float64, top, left *float64) {
|
||||
if base == "top" || base == "bottom" {
|
||||
*left = clamp(*left, pad, vp.Width-f.Width-pad)
|
||||
return
|
||||
}
|
||||
*top = clamp(*top, pad, vp.Height-f.Height-pad)
|
||||
}
|
||||
|
||||
// available reports the room between the trigger and the viewport edge on the
|
||||
// resolved side, so a panel too big for it can scroll instead of overflowing.
|
||||
func available(base string, t wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) (maxHeight, maxWidth float64) {
|
||||
gap := o.Offset + o.Padding
|
||||
switch base {
|
||||
case "top":
|
||||
return max(t.Top()-gap, 0), 0
|
||||
case "bottom":
|
||||
return max(vp.Height-t.Bottom()-gap, 0), 0
|
||||
case "left":
|
||||
return 0, max(t.Left()-gap, 0)
|
||||
case "right":
|
||||
return 0, max(vp.Width-t.Right()-gap, 0)
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// arrowOffset points the arrow at the trigger's centre, in panel-local coordinates,
|
||||
// clamped so it stays on the panel even when shift has slid the panel away from the
|
||||
// trigger. When the trigger is entirely off past the panel's edge the arrow sits at
|
||||
// the clamp limit — visibly at the corner, which is the honest answer.
|
||||
func arrowOffset(base string, t, f wasmruntime.Rect, top, left float64, o PositionOptions) float64 {
|
||||
half := o.ArrowSize / 2
|
||||
if base == "top" || base == "bottom" {
|
||||
lo := o.ArrowPadding + half
|
||||
hi := f.Width - o.ArrowPadding - half
|
||||
return clamp(t.CenterX()-left, lo, max(lo, hi))
|
||||
}
|
||||
lo := o.ArrowPadding + half
|
||||
hi := f.Height - o.ArrowPadding - half
|
||||
return clamp(t.CenterY()-top, lo, max(lo, hi))
|
||||
}
|
||||
|
||||
func splitPlacement(p string) (base, align string) {
|
||||
base, align, found := strings.Cut(p, "-")
|
||||
if !found {
|
||||
align = "center"
|
||||
}
|
||||
switch base {
|
||||
case "top", "bottom", "left", "right":
|
||||
default:
|
||||
base = "bottom"
|
||||
}
|
||||
return base, align
|
||||
}
|
||||
|
||||
func joinPlacement(base, align string) string {
|
||||
if align == "" || align == "center" {
|
||||
return base
|
||||
}
|
||||
return base + "-" + align
|
||||
}
|
||||
|
||||
// OppositeSide is the side an arrow lives on: a panel placed above its trigger has
|
||||
// its arrow on the bottom edge, pointing down at it.
|
||||
func OppositeSide(placement string) string {
|
||||
base, _ := splitPlacement(placement)
|
||||
switch base {
|
||||
case "top":
|
||||
return "bottom"
|
||||
case "bottom":
|
||||
return "top"
|
||||
case "left":
|
||||
return "right"
|
||||
default:
|
||||
return "left"
|
||||
}
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi float64) float64 {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user