296 lines
9.7 KiB
Go
296 lines
9.7 KiB
Go
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
|
|
}
|