773 lines
28 KiB
Go
773 lines
28 KiB
Go
package webui
|
|
|
|
import (
|
|
"strconv"
|
|
|
|
"kjol/vdom"
|
|
"kjol/wasmruntime"
|
|
)
|
|
|
|
// Port of web/uikit/Tutorial.tsx — a guided tour: it dims the page, cuts a
|
|
// spotlight hole around the step's target element, and floats a popover card with
|
|
// step navigation.
|
|
//
|
|
// Tutorial is an ENGINE, not a pure function. It owns everything the tour needs to
|
|
// know that a render cannot: where the target is (a measurement), how big the card
|
|
// is (another measurement), which frame the animation is on, and which timers are in
|
|
// flight. Create it ONCE, outside your render function:
|
|
//
|
|
// tour := webui.NewTutorial(webui.TutorialOptions{Steps: []webui.TutorialStep{
|
|
// {Title: "Filters", Target: "#filters", Placement: webui.PlacementRight,
|
|
// Content: func() *vdom.VNode { return Text("Narrow the table down here.") }},
|
|
// {Title: "Export", Target: "#export-btn",
|
|
// Content: func() *vdom.VNode { return Text("…then export what is left.") }},
|
|
// }})
|
|
//
|
|
// return func() *vdom.VNode {
|
|
// return Div(page(), tour.StartButton(0, ""), tour.Render())
|
|
// }
|
|
//
|
|
// How a step is staged, and why in that order:
|
|
//
|
|
// 1. The step's Target is a CSS selector — QuerySelector finds an element the tour
|
|
// does not own — resolved after a short delay, so a route transition or a layout
|
|
// settling does not hand us a stale box.
|
|
// 2. The target is measured and smooth-scrolled to the centre of the viewport.
|
|
// 3. The spotlight is a `position: fixed` box sized to that rect plus padding, with
|
|
// `box-shadow: 0 0 0 9999px rgba(0,0,0,0.5)`: the enormous spread IS the page
|
|
// dimming, and the box is the hole in it. Because its geometry is written with
|
|
// SetStyle onto an element that declares `transition: all 100ms`, the hole
|
|
// ANIMATES from one target to the next instead of teleporting.
|
|
// 4. The card is placed with ComputePosition (the same engine the floating layer
|
|
// uses — no hand-rolled second copy) and revealed. It re-positions on scroll
|
|
// (capture, because scroll does not bubble) and resize.
|
|
//
|
|
// Nothing here is browser-only in the Go sense: every host call stubs out natively,
|
|
// so on the server the tour is simply never active, Render emits an empty portal, and
|
|
// no measurement, listener or timer exists. There is no black screen to SSR.
|
|
type Tutorial struct {
|
|
opts TutorialOptions
|
|
|
|
active *vdom.Signal[bool]
|
|
// index is the live step; displayed is the step currently PAINTED. They differ
|
|
// only during a step transition, which double-buffers the card: the content is
|
|
// swapped at the midpoint of the cross-fade.
|
|
index *vdom.Signal[int]
|
|
displayed *vdom.Signal[int]
|
|
// placement is the RESOLVED placement (after ComputePosition may have flipped it).
|
|
// The arrow's side is a class/style decision, so it has to go through a render —
|
|
// hence a signal, written only when it actually changes.
|
|
placement *vdom.Signal[string]
|
|
showArrow *vdom.Signal[bool]
|
|
// spotlight is true when the step's target resolved AND has been measured; false
|
|
// means the flat-dim fallback (also the state a step with no target renders, and
|
|
// the only state the server can produce).
|
|
spotlight *vdom.Signal[bool]
|
|
|
|
popoverRef *vdom.Ref
|
|
contentRef *vdom.Ref
|
|
overlayRef *vdom.Ref
|
|
|
|
// Everything below is plain state, deliberately NOT signals: it feeds imperative
|
|
// style writes that run on every scroll frame, and a signal write there would
|
|
// re-render the whole app per frame.
|
|
rect wasmruntime.Rect
|
|
radius float64
|
|
positioned bool
|
|
transitioning bool
|
|
revealed bool // the overlay's dim has faded in on the current overlay node
|
|
firstAppearance bool
|
|
|
|
unsubs []wasmruntime.Unsub
|
|
targetTimer int
|
|
stepTimer int
|
|
arrowTimer int
|
|
}
|
|
|
|
// TutorialStep is one stop on the tour.
|
|
type TutorialStep struct {
|
|
Title string
|
|
// Content is called on every render, so content that reads signals stays live.
|
|
Content func() *vdom.VNode
|
|
// Target is a CSS selector for the element to spotlight. Empty means the step has
|
|
// no anchor: the page dims flat and the card centres in the viewport.
|
|
Target string
|
|
// Placement is one of the Placement* constants; default bottom. It may be flipped
|
|
// if the card does not fit on that side.
|
|
Placement string
|
|
// Offset is the gap between target and card in px; default 16.
|
|
Offset float64
|
|
|
|
OnEnter func()
|
|
OnLeave func()
|
|
}
|
|
|
|
// TutorialOptions configures a Tutorial. The zero value is usable (no steps).
|
|
type TutorialOptions struct {
|
|
Steps []TutorialStep
|
|
// SpotlightPadding is how far the cutout is inflated past the target; default 8.
|
|
SpotlightPadding float64
|
|
// OnEnd fires after the tour ends, however it ended (Finish, ✕, Escape, a click
|
|
// on the dim, or End()).
|
|
OnEnd func()
|
|
// Class is appended to the popover card.
|
|
Class string
|
|
}
|
|
|
|
// TSX constants: _TUTORIAL_ANIMATION_DURATION, the popover's default offset, and the
|
|
// beat we wait before measuring a target (long enough for a route transition or a
|
|
// layout to settle, short enough not to be seen).
|
|
const (
|
|
tutorialAnimMS = 100
|
|
tutorialHalfMS = tutorialAnimMS / 2
|
|
tutorialSettleMS = 50
|
|
tutorialTargetMS = 50
|
|
tutorialDefaultPad = 8.0
|
|
tutorialDefaultOff = 16.0
|
|
tutorialViewportPad = 16.0
|
|
// tutorialFallbackRadius is the TSX's initial borderRadius, used when
|
|
// --radius-default is unreadable (and on the server).
|
|
tutorialFallbackRadius = 3.2
|
|
)
|
|
|
|
// Declared styles. Each is CONSTANT across renders, which is what lets the imperative
|
|
// writes below survive reconciliation (the reconciler only re-sets an attribute whose
|
|
// declared value changed). They encode the pre-measurement state — the state the
|
|
// server renders and the browser paints for one frame before the first measurement.
|
|
const (
|
|
// The card: laid out (so it can be measured) but not painted, and faded/scaled
|
|
// down ready for the enter animation. No top/left transition yet — the first
|
|
// appearance must not slide in from 0,0.
|
|
tutorialPopoverStyle = "top:0;left:0;visibility:hidden;opacity:0;transform:scale(0.95);transition:opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1)"
|
|
// From the first step change on, top/left transition too, so the card SLIDES
|
|
// between targets. Written imperatively when the first transition starts.
|
|
tutorialPopoverSlide = "opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1), top 100ms cubic-bezier(0.4, 0, 0.2, 1), left 100ms cubic-bezier(0.4, 0, 0.2, 1)"
|
|
// The card's inner content, cross-faded on a step change (out, swap, in).
|
|
tutorialContentStyle = "opacity:1;transition:opacity 50ms ease-out"
|
|
// The spotlight: a zero-size box with a transparent 9999px shadow. The geometry
|
|
// and the shadow's alpha are both written imperatively; `transition: all` is what
|
|
// animates the hole between targets.
|
|
tutorialSpotlightStyle = "left:0;top:0;width:0;height:0;box-shadow:0 0 0 9999px rgba(0, 0, 0, 0);transition:all 100ms cubic-bezier(0.4, 0, 0.2, 1)"
|
|
tutorialSpotlightShadow = "0 0 0 9999px rgba(0, 0, 0, 0.5)"
|
|
// The no-target fallback: a flat dim that fades in.
|
|
tutorialBackdropStyle = "opacity:0;transition:opacity 100ms ease-out"
|
|
)
|
|
|
|
// Tailwind classes, verbatim from the TSX.
|
|
const (
|
|
tutorialPopoverClass = "fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm"
|
|
tutorialSpotlightClass = "fixed z-150 pointer-events-none rounded-default"
|
|
tutorialCatcherClass = "fixed inset-0 -z-10 cursor-pointer"
|
|
tutorialBackdropClass = "fixed inset-0 bg-black/50 z-150"
|
|
tutorialArrowClass = "absolute w-0 h-0"
|
|
)
|
|
|
|
// The arrow is two stacked CSS triangles: a 9px one in the border colour, and an 8px
|
|
// white one on top of it, which is what makes a bordered arrow out of two borders.
|
|
// Both are keyed by the RESOLVED base placement and hang off the OPPOSITE edge of the
|
|
// card — a card placed above its target carries its arrow on its bottom edge, pointing
|
|
// down at it.
|
|
var tutorialArrowOuter = map[string]string{
|
|
"top": "bottom:-9px;left:50%;transform:translateX(-50%);border-left:9px solid transparent;border-right:9px solid transparent;border-top:9px solid #e5e5e5;",
|
|
"bottom": "top:-9px;left:50%;transform:translateX(-50%);border-left:9px solid transparent;border-right:9px solid transparent;border-bottom:9px solid #e5e5e5;",
|
|
"left": "right:-9px;top:50%;transform:translateY(-50%);border-top:9px solid transparent;border-bottom:9px solid transparent;border-left:9px solid #e5e5e5;",
|
|
"right": "left:-9px;top:50%;transform:translateY(-50%);border-top:9px solid transparent;border-bottom:9px solid transparent;border-right:9px solid #e5e5e5;",
|
|
}
|
|
|
|
var tutorialArrowInner = map[string]string{
|
|
"top": "bottom:-8px;left:50%;transform:translateX(-50%);border-left:8px solid transparent;border-right:8px solid transparent;border-top:8px solid white;",
|
|
"bottom": "top:-8px;left:50%;transform:translateX(-50%);border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:8px solid white;",
|
|
"left": "right:-8px;top:50%;transform:translateY(-50%);border-top:8px solid transparent;border-bottom:8px solid transparent;border-left:8px solid white;",
|
|
"right": "left:-8px;top:50%;transform:translateY(-50%);border-top:8px solid transparent;border-bottom:8px solid transparent;border-right:8px solid white;",
|
|
}
|
|
|
|
const tutorialArrowTransition = "width:0;height:0;transition:all 100ms cubic-bezier(0.4, 0, 0.2, 1);"
|
|
|
|
// NewTutorial creates a tour engine. Call it once, outside your render function.
|
|
func NewTutorial(o TutorialOptions) *Tutorial {
|
|
if o.SpotlightPadding == 0 {
|
|
o.SpotlightPadding = tutorialDefaultPad
|
|
}
|
|
return &Tutorial{
|
|
opts: o,
|
|
active: vdom.NewSignal(false),
|
|
index: vdom.NewSignal(0),
|
|
displayed: vdom.NewSignal(0),
|
|
placement: vdom.NewSignal(PlacementBottom),
|
|
showArrow: vdom.NewSignal(false),
|
|
spotlight: vdom.NewSignal(false),
|
|
popoverRef: vdom.NewRef(),
|
|
contentRef: vdom.NewRef(),
|
|
overlayRef: vdom.NewRef(),
|
|
firstAppearance: true,
|
|
}
|
|
}
|
|
|
|
// SetSteps replaces the tour's steps (for a tour whose content depends on data that
|
|
// arrives later). Ends the tour if it is running.
|
|
func (t *Tutorial) SetSteps(steps []TutorialStep) {
|
|
if t.active.Get() {
|
|
t.End()
|
|
}
|
|
t.opts.Steps = steps
|
|
}
|
|
|
|
// IsActive, CurrentStep and TotalSteps are the tour's public state (the TSX's
|
|
// TutorialContextValue). Safe to read during render.
|
|
func (t *Tutorial) IsActive() bool { return t.active.Get() }
|
|
func (t *Tutorial) CurrentStep() int { return t.index.Get() }
|
|
func (t *Tutorial) TotalSteps() int { return len(t.opts.Steps) }
|
|
|
|
func (t *Tutorial) step(i int) *TutorialStep {
|
|
if i < 0 || i >= len(t.opts.Steps) {
|
|
return nil
|
|
}
|
|
return &t.opts.Steps[i]
|
|
}
|
|
|
|
// ---- driving the tour ----
|
|
|
|
// Start begins the tour at stepIndex (clamped). Starting an already-running tour just
|
|
// jumps to that step.
|
|
func (t *Tutorial) Start(stepIndex int) {
|
|
if len(t.opts.Steps) == 0 {
|
|
return
|
|
}
|
|
i := clampIndex(stepIndex, len(t.opts.Steps))
|
|
if t.active.Get() {
|
|
t.GoTo(i)
|
|
return
|
|
}
|
|
|
|
t.rect = wasmruntime.Rect{}
|
|
t.positioned = false
|
|
t.transitioning = false
|
|
t.revealed = false
|
|
t.firstAppearance = true
|
|
|
|
t.index.Set(i)
|
|
t.displayed.Set(i)
|
|
t.placement.Set(PlacementBottom)
|
|
t.showArrow.Set(false)
|
|
t.spotlight.Set(false)
|
|
t.active.Set(true)
|
|
|
|
if s := t.step(i); s != nil && s.OnEnter != nil {
|
|
s.OnEnter()
|
|
}
|
|
// The overlay and card do not exist yet — the signal writes only *scheduled* a
|
|
// render. Measure and animate once they do.
|
|
wasmruntime.AfterRender(t.mounted)
|
|
}
|
|
|
|
// End stops the tour.
|
|
func (t *Tutorial) End() {
|
|
if !t.active.Get() {
|
|
return
|
|
}
|
|
if s := t.step(t.index.Get()); s != nil && s.OnLeave != nil {
|
|
s.OnLeave()
|
|
}
|
|
t.teardown()
|
|
|
|
t.rect = wasmruntime.Rect{}
|
|
t.positioned = false
|
|
t.transitioning = false
|
|
t.revealed = false
|
|
t.firstAppearance = true
|
|
|
|
t.active.Set(false)
|
|
t.index.Set(0)
|
|
t.displayed.Set(0)
|
|
t.showArrow.Set(false)
|
|
t.spotlight.Set(false)
|
|
|
|
if t.opts.OnEnd != nil {
|
|
t.opts.OnEnd()
|
|
}
|
|
}
|
|
|
|
// Next advances, or ends the tour on the last step.
|
|
func (t *Tutorial) Next() {
|
|
if !t.active.Get() {
|
|
return
|
|
}
|
|
if i := t.index.Get(); i < len(t.opts.Steps)-1 {
|
|
t.GoTo(i + 1)
|
|
return
|
|
}
|
|
t.End()
|
|
}
|
|
|
|
// Previous steps back (a no-op on the first step).
|
|
func (t *Tutorial) Previous() {
|
|
if !t.active.Get() {
|
|
return
|
|
}
|
|
if i := t.index.Get(); i > 0 {
|
|
t.GoTo(i - 1)
|
|
}
|
|
}
|
|
|
|
// GoTo jumps to a step, running the leave/enter hooks and the transition choreography.
|
|
//
|
|
// The ORDER here is load-bearing, and it is the TSX's:
|
|
//
|
|
// 1. The target is re-resolved on a timer FIRST, so the new rect is in hand by the
|
|
// time the card asks where to go.
|
|
// 2. The card fades its content out, swaps the step at the midpoint of the fade,
|
|
// re-positions (now with top/left transitions on, so it slides), fades back in,
|
|
// and only then re-shows the arrow.
|
|
//
|
|
// While all that is in flight, `transitioning` suppresses scroll/resize
|
|
// re-positioning: the card is mid-slide and must not be yanked by a scroll frame
|
|
// (including the smooth scroll this very step just started).
|
|
func (t *Tutorial) GoTo(stepIndex int) {
|
|
if !t.active.Get() || len(t.opts.Steps) == 0 {
|
|
return
|
|
}
|
|
i := clampIndex(stepIndex, len(t.opts.Steps))
|
|
cur := t.index.Get()
|
|
if i == cur {
|
|
return
|
|
}
|
|
|
|
if s := t.step(cur); s != nil && s.OnLeave != nil {
|
|
s.OnLeave()
|
|
}
|
|
t.index.Set(i)
|
|
if s := t.step(i); s != nil && s.OnEnter != nil {
|
|
s.OnEnter()
|
|
}
|
|
|
|
// 1. the new target.
|
|
wasmruntime.ClearTimeout(t.targetTimer)
|
|
t.targetTimer = wasmruntime.SetTimeout(tutorialTargetMS, t.resolveTarget)
|
|
|
|
// 2. the card. A jump that lands mid-transition updates the index (and the target)
|
|
// but does not restart the choreography — the in-flight swap reads the LIVE
|
|
// index, so it lands on the newest step anyway.
|
|
if t.transitioning {
|
|
return
|
|
}
|
|
t.transitioning = true
|
|
t.firstAppearance = false
|
|
wasmruntime.SetStyle(t.popoverRef, "transition", tutorialPopoverSlide)
|
|
t.showArrow.Set(false)
|
|
wasmruntime.SetStyle(t.contentRef, "opacity", "0")
|
|
|
|
t.stepTimer = wasmruntime.SetTimeout(tutorialHalfMS, func() {
|
|
t.stepTimer = 0
|
|
if !t.active.Get() {
|
|
t.transitioning = false
|
|
return
|
|
}
|
|
t.displayed.Set(t.index.Get()) // swap at the midpoint of the fade
|
|
wasmruntime.AfterRender(func() {
|
|
// The new content is in the DOM: measure it and let the card slide.
|
|
t.updatePosition()
|
|
t.stepTimer = wasmruntime.SetTimeout(tutorialSettleMS, func() {
|
|
wasmruntime.SetStyle(t.contentRef, "opacity", "1")
|
|
t.stepTimer = wasmruntime.SetTimeout(tutorialHalfMS, func() {
|
|
t.stepTimer = 0
|
|
t.transitioning = false
|
|
t.showArrow.Set(true)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
// Dispose ends the tour and removes every listener and timer.
|
|
func (t *Tutorial) Dispose() {
|
|
t.teardown()
|
|
if t.active.Get() {
|
|
t.active.Set(false)
|
|
}
|
|
}
|
|
|
|
func (t *Tutorial) teardown() {
|
|
for _, un := range t.unsubs {
|
|
un()
|
|
}
|
|
t.unsubs = nil
|
|
wasmruntime.ClearTimeout(t.targetTimer)
|
|
wasmruntime.ClearTimeout(t.stepTimer)
|
|
wasmruntime.ClearTimeout(t.arrowTimer)
|
|
t.targetTimer, t.stepTimer, t.arrowTimer = 0, 0, 0
|
|
}
|
|
|
|
// mounted runs once the overlay and card are in the DOM.
|
|
func (t *Tutorial) mounted() {
|
|
// The spotlight's corners should match the app's theme, so read the radius token
|
|
// rather than guessing. CSSVarPx resolves rem against the root font size.
|
|
t.radius = wasmruntime.CSSVarPx("--radius-default")
|
|
if t.radius <= 0 {
|
|
t.radius = tutorialFallbackRadius
|
|
}
|
|
|
|
t.unsubs = append(t.unsubs,
|
|
wasmruntime.OnDocument(vdom.EVENT_KEYDOWN, false, t.onKeydown),
|
|
// capture=true: `scroll` does not bubble, so this is the only way to hear a
|
|
// scroll inside a nested container the target happens to live in.
|
|
wasmruntime.OnWindow(vdom.EVENT_SCROLL, true, func(vdom.Event) { t.onViewportChange() }),
|
|
wasmruntime.OnWindow(vdom.EVENT_RESIZE, false, func(vdom.Event) { t.onViewportChange() }),
|
|
)
|
|
|
|
t.writeOverlay() // no rect yet: the flat dim, fading in
|
|
t.targetTimer = wasmruntime.SetTimeout(tutorialTargetMS, t.resolveTarget)
|
|
|
|
// Position on the next frame, reveal on the one after: the initial (faded, scaled)
|
|
// style must be COMMITTED before the target lands, or there is nothing for the
|
|
// browser to interpolate from and the card just appears.
|
|
wasmruntime.RAF(func() {
|
|
t.updatePosition()
|
|
t.positioned = true
|
|
wasmruntime.RAF(func() {
|
|
wasmruntime.SetStyle(t.popoverRef, "opacity", "1")
|
|
wasmruntime.SetStyle(t.popoverRef, "transform", "scale(1)")
|
|
t.arrowTimer = wasmruntime.SetTimeout(tutorialAnimMS, func() {
|
|
t.arrowTimer = 0
|
|
if t.active.Get() {
|
|
t.showArrow.Set(true)
|
|
}
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
// onKeydown: Escape ends the tour, ArrowRight/Enter advance, ArrowLeft goes back.
|
|
func (t *Tutorial) onKeydown(ev vdom.Event) {
|
|
switch ev.Key() {
|
|
case vdom.KEY_ESCAPE:
|
|
t.End()
|
|
case vdom.KEY_ARROW_RIGHT, vdom.KEY_ENTER:
|
|
t.Next()
|
|
case vdom.KEY_ARROW_LEFT:
|
|
t.Previous()
|
|
}
|
|
}
|
|
|
|
// ---- the target ----
|
|
|
|
// targetRef finds the current step's target in the document. The selector is
|
|
// re-queried on every measurement rather than cached, so a target that is re-rendered
|
|
// (a virtualised row, a re-keyed panel) does not leave the tour pointing at a detached
|
|
// node.
|
|
func (t *Tutorial) targetRef() *vdom.Ref {
|
|
s := t.step(t.index.Get())
|
|
if s == nil || s.Target == "" {
|
|
return nil
|
|
}
|
|
r := wasmruntime.QuerySelector(s.Target)
|
|
if !r.Mounted() {
|
|
return nil
|
|
}
|
|
return r
|
|
}
|
|
|
|
// resolveTarget runs once per step, on a short delay: measure the target and bring it
|
|
// into view. The smooth scroll is deliberately NOT repeated on every scroll frame (the
|
|
// TSX did, which fought the user for the scroll position); it belongs to entering a
|
|
// step.
|
|
func (t *Tutorial) resolveTarget() {
|
|
t.targetTimer = 0
|
|
if !t.active.Get() {
|
|
return
|
|
}
|
|
r := t.targetRef()
|
|
if r == nil {
|
|
t.rect = wasmruntime.Rect{}
|
|
t.applyOverlay()
|
|
return
|
|
}
|
|
t.rect = wasmruntime.Measure(r)
|
|
wasmruntime.ScrollIntoView(r, true, wasmruntime.ScrollBlockCenter)
|
|
t.applyOverlay()
|
|
if !t.transitioning && t.positioned {
|
|
t.updatePosition()
|
|
}
|
|
}
|
|
|
|
// onViewportChange re-measures on scroll and resize. Both the spotlight and the card
|
|
// follow the target — imperatively, so a scroll frame costs two measurements and a few
|
|
// style writes rather than a re-render of the app.
|
|
func (t *Tutorial) onViewportChange() {
|
|
if !t.active.Get() {
|
|
return
|
|
}
|
|
if r := t.targetRef(); r != nil {
|
|
t.rect = wasmruntime.Measure(r)
|
|
} else {
|
|
t.rect = wasmruntime.Rect{}
|
|
}
|
|
t.applyOverlay()
|
|
if !t.transitioning && t.positioned {
|
|
t.updatePosition()
|
|
}
|
|
}
|
|
|
|
// hasSpotlight reports whether there is a measured target to cut a hole around. A step
|
|
// with no target — or one whose selector matched nothing, or nothing laid out — falls
|
|
// back to the flat dim. On the server this is always false, which is why SSR ships a
|
|
// tour that is simply not running rather than a black screen.
|
|
func (t *Tutorial) hasSpotlight() bool {
|
|
s := t.step(t.index.Get())
|
|
return s != nil && s.Target != "" && !t.rect.Empty()
|
|
}
|
|
|
|
// ---- the overlay ----
|
|
|
|
// applyOverlay reconciles the overlay with the current measurement. Flipping between
|
|
// the cutout and the flat dim swaps the rendered node, so the styles have to be
|
|
// (re)written once that render has committed; when nothing flips, the write is direct.
|
|
func (t *Tutorial) applyOverlay() {
|
|
has := t.hasSpotlight()
|
|
if t.spotlight.Get() != has {
|
|
t.spotlight.Set(has)
|
|
t.revealed = false // a new node: it must fade its dim in again
|
|
wasmruntime.AfterRender(t.writeOverlay)
|
|
return
|
|
}
|
|
t.writeOverlay()
|
|
}
|
|
|
|
// writeOverlay writes the overlay's geometry, then fades its dim in the first time it
|
|
// runs on a given node. Every property of the mode NOT in use is removed, so a node the
|
|
// reconciler reused across a flip cannot keep a stale inline `left` and drag the flat
|
|
// backdrop off screen.
|
|
func (t *Tutorial) writeOverlay() {
|
|
if !t.active.Get() || !t.overlayRef.Mounted() {
|
|
return
|
|
}
|
|
if t.spotlight.Get() {
|
|
pad := t.opts.SpotlightPadding
|
|
wasmruntime.RemoveStyle(t.overlayRef, "opacity")
|
|
wasmruntime.SetStyle(t.overlayRef, "left", px(t.rect.Left()-pad))
|
|
wasmruntime.SetStyle(t.overlayRef, "top", px(t.rect.Top()-pad))
|
|
wasmruntime.SetStyle(t.overlayRef, "width", px(t.rect.Width+pad*2))
|
|
wasmruntime.SetStyle(t.overlayRef, "height", px(t.rect.Height+pad*2))
|
|
wasmruntime.SetStyle(t.overlayRef, "border-radius", px(t.radius))
|
|
} else {
|
|
for _, p := range []string{"left", "top", "width", "height", "border-radius", "box-shadow"} {
|
|
wasmruntime.RemoveStyle(t.overlayRef, p)
|
|
}
|
|
}
|
|
|
|
if t.revealed {
|
|
t.revealOverlay() // idempotent; keeps the dim applied on a freshly swapped node
|
|
return
|
|
}
|
|
t.revealed = true
|
|
// Double rAF, for the same reason the modal needs one: the transparent initial
|
|
// state has to be committed a frame before the dim lands, or it does not fade.
|
|
wasmruntime.RAF(func() { wasmruntime.RAF(t.revealOverlay) })
|
|
}
|
|
|
|
func (t *Tutorial) revealOverlay() {
|
|
if !t.active.Get() || !t.overlayRef.Mounted() {
|
|
return
|
|
}
|
|
if t.spotlight.Get() {
|
|
wasmruntime.SetStyle(t.overlayRef, "box-shadow", tutorialSpotlightShadow)
|
|
return
|
|
}
|
|
wasmruntime.SetStyle(t.overlayRef, "opacity", "1")
|
|
}
|
|
|
|
// ---- the card ----
|
|
|
|
// updatePosition measures the card, works out where it goes, and writes it. This is the
|
|
// only place the card's coordinates are decided, and it never goes through a signal: it
|
|
// runs on every scroll frame.
|
|
//
|
|
// The placement math is ComputePosition (position.go) — the same engine the floating
|
|
// layer uses. The TSX had its own fourth copy of it, which clamped on BOTH axes and so
|
|
// could push the card on top of the very thing it was pointing at; this one shifts on
|
|
// the cross axis only and flips on the main one.
|
|
func (t *Tutorial) updatePosition() {
|
|
if !t.popoverRef.Mounted() {
|
|
return
|
|
}
|
|
card := wasmruntime.Measure(t.popoverRef)
|
|
if card.Empty() {
|
|
return // not laid out yet; stay hidden rather than paint at 0,0
|
|
}
|
|
vp := wasmruntime.Viewport()
|
|
|
|
step := t.step(t.index.Get())
|
|
placement := PlacementBottom
|
|
var top, left float64
|
|
|
|
if step != nil && step.Target != "" && !t.rect.Empty() {
|
|
offset := step.Offset
|
|
if offset == 0 {
|
|
offset = tutorialDefaultOff
|
|
}
|
|
pos := ComputePosition(t.rect, card, vp, PositionOptions{
|
|
Placement: pick(step.Placement, PlacementBottom),
|
|
Offset: offset,
|
|
Flip: true,
|
|
Shift: true,
|
|
Padding: tutorialViewportPad,
|
|
})
|
|
top, left, placement = pos.Top, pos.Left, pos.Placement
|
|
} else {
|
|
// No target (or nothing measurable): centre the card in the viewport.
|
|
top = (vp.Height - card.Height) / 2
|
|
left = (vp.Width - card.Width) / 2
|
|
}
|
|
|
|
wasmruntime.SetStyle(t.popoverRef, "top", px(top))
|
|
wasmruntime.SetStyle(t.popoverRef, "left", px(left))
|
|
wasmruntime.SetStyle(t.popoverRef, "visibility", "visible")
|
|
|
|
// The arrow's side is a rendered decision, so a flip has to go through a render.
|
|
// Guarded on change, so this converges after one extra render instead of looping.
|
|
if t.placement.Get() != placement {
|
|
t.placement.Set(placement)
|
|
}
|
|
}
|
|
|
|
// ---- rendering ----
|
|
|
|
// Render draws the tour — the overlay and the card — portaled to document.body, so no
|
|
// ancestor's `overflow: hidden` can clip the spotlight and no ancestor `transform` can
|
|
// re-root its `position: fixed`. When the tour is not running it renders an EMPTY
|
|
// portal rather than nothing, so its slot in the parent's child list (which the
|
|
// reconciler diffs by index) never disappears.
|
|
func (t *Tutorial) Render() *vdom.VNode {
|
|
if !t.active.Get() || len(t.opts.Steps) == 0 {
|
|
return vdom.Portal()
|
|
}
|
|
return vdom.Portal(t.overlay(), t.card())
|
|
}
|
|
|
|
// overlay is either the spotlight cutout (a hole in a 9999px shadow) or, with no target
|
|
// to cut around, the flat dim. Both start transparent and are faded in imperatively.
|
|
func (t *Tutorial) overlay() *vdom.VNode {
|
|
if !t.spotlight.Get() {
|
|
return vdom.Div(vdom.WithRef(t.overlayRef),
|
|
vdom.Attr("class", tutorialBackdropClass),
|
|
vdom.Attr("style", tutorialBackdropStyle),
|
|
vdom.On(vdom.EVENT_CLICK, t.End),
|
|
)
|
|
}
|
|
return vdom.Div(vdom.WithRef(t.overlayRef),
|
|
vdom.Attr("class", tutorialSpotlightClass),
|
|
vdom.Attr("style", tutorialSpotlightStyle),
|
|
// The hole itself is pointer-events:none (you can still use what it spotlights);
|
|
// this sits behind the whole page and ends the tour when the dim is clicked.
|
|
vdom.Div(vdom.Attr("class", tutorialCatcherClass), vdom.On(vdom.EVENT_CLICK, t.End)),
|
|
)
|
|
}
|
|
|
|
// card renders the popover: the two arrow triangles, then the content (header, body,
|
|
// controls, dots) in its own cross-faded wrapper.
|
|
//
|
|
// The arrows are always rendered, and merely hidden with display:none when they are not
|
|
// wanted. That is deliberate: the reconciler diffs children BY INDEX, so adding and
|
|
// removing them would shift the content div's index and force it to be rebuilt —
|
|
// discarding the imperative opacity mid-cross-fade.
|
|
func (t *Tutorial) card() *vdom.VNode {
|
|
idx := clampIndex(t.displayed.Get(), len(t.opts.Steps))
|
|
step := t.step(idx)
|
|
total := len(t.opts.Steps)
|
|
|
|
base, _ := splitPlacement(t.placement.Get())
|
|
arrowVisible := t.showArrow.Get() && step != nil && step.Target != ""
|
|
hidden := ""
|
|
if !arrowVisible {
|
|
hidden = "display:none;"
|
|
}
|
|
|
|
outer := vdom.Div(vdom.Attr("class", tutorialArrowClass),
|
|
vdom.Attr("style", tutorialArrowOuter[base]+tutorialArrowTransition+hidden),
|
|
)
|
|
inner := vdom.Div(vdom.Attr("class", tutorialArrowClass),
|
|
vdom.Attr("style", tutorialArrowInner[base]+tutorialArrowTransition+hidden),
|
|
)
|
|
|
|
return vdom.Div(vdom.WithRef(t.popoverRef),
|
|
vdom.Attr("class", cx(tutorialPopoverClass, t.opts.Class)),
|
|
vdom.Attr("style", tutorialPopoverStyle),
|
|
outer,
|
|
inner,
|
|
t.content(step, idx, total),
|
|
)
|
|
}
|
|
|
|
func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
|
|
// Header: optional title + "X of N", and a close button.
|
|
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
|
|
if step != nil && step.Title != "" {
|
|
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-ink"),
|
|
vdom.Text(step.Title)))
|
|
}
|
|
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"),
|
|
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
|
|
|
|
header := vdom.Div(vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
|
|
vdom.Div(headerLeft...),
|
|
vdom.Button(vdom.Attr("type", "button"),
|
|
vdom.Attr("class", "cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft"),
|
|
vdom.On(vdom.EVENT_CLICK, t.End),
|
|
Icon("xmark", 18, ""),
|
|
),
|
|
)
|
|
|
|
// Body.
|
|
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-ink-soft")}
|
|
if step != nil && step.Content != nil {
|
|
if c := step.Content(); c != nil {
|
|
bodyMods = append(bodyMods, c)
|
|
}
|
|
}
|
|
body := vdom.Div(bodyMods...)
|
|
|
|
// Controls: Previous on the left (absent on the first step); Next/Finish on the right.
|
|
leftMods := []vdom.Mod{}
|
|
if idx > 0 {
|
|
leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: t.Previous}))
|
|
}
|
|
advance := Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Next", OnClick: t.Next})
|
|
if idx == total-1 {
|
|
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Finish", OnClick: t.End})
|
|
}
|
|
controls := vdom.Div(vdom.Attr("class", "flex items-center justify-between px-4 pb-4 gap-2"),
|
|
vdom.Div(leftMods...),
|
|
vdom.Div(vdom.Attr("class", "flex gap-2"), advance),
|
|
)
|
|
|
|
contentMods := []vdom.Mod{
|
|
vdom.WithRef(t.contentRef),
|
|
vdom.Attr("style", tutorialContentStyle),
|
|
header, body, controls,
|
|
}
|
|
|
|
// Step dots.
|
|
if total > 1 {
|
|
dotMods := []vdom.Mod{vdom.Attr("class", "flex justify-center gap-1.5 pb-3")}
|
|
for i := 0; i < total; i++ {
|
|
state := "bg-surface-strong"
|
|
if i == idx {
|
|
state = "bg-sky-600 scale-110"
|
|
}
|
|
dotMods = append(dotMods, vdom.Div(vdom.Attr("class", cx("w-2 h-2 rounded-full transition-all duration-300 ease-in-out", state))))
|
|
}
|
|
contentMods = append(contentMods, vdom.Div(dotMods...))
|
|
}
|
|
|
|
return vdom.Div(contentMods...)
|
|
}
|
|
|
|
// StartButton is the blue button that kicks the tour off at stepIndex. With no children
|
|
// it renders the default "Start Tutorial" label.
|
|
func (t *Tutorial) StartButton(stepIndex int, class string, children ...*vdom.VNode) *vdom.VNode {
|
|
bp := ButtonProps{Color: ButtonBlue, Class: class, OnClick: func() { t.Start(stepIndex) }}
|
|
if len(children) == 0 {
|
|
bp.Text = "Start Tutorial"
|
|
}
|
|
return Button(bp, children...)
|
|
}
|