import { createContext, useContext, createSignal, createEffect, onCleanup, Show, For, JSXElement } from "solid-js"; import { Icon } from "./Icons.tsx"; import { ButtonUI, BUTTON_COLOR_WHITE, BUTTON_COLOR_BLUE } from "./Buttons.tsx"; export interface TutorialStep { title?: string; content: JSXElement; target?: string | (() => HTMLElement | null) | null; placement?: string; offset?: number; onEnter?: () => void; onLeave?: () => void; } interface TutorialProviderProps { steps: TutorialStep[]; spotlightPadding?: number; onEnd?: () => void; children?: JSXElement; } interface TutorialContextValue { isActive: boolean; currentStepIndex: number; totalSteps: number; currentStep: TutorialStep | null; start: (stepIndex?: number) => void; end: () => void; next: () => void; previous: () => void; goTo: (stepIndex: number) => void; } interface TutorialInternalContextValue { isActive: () => boolean; currentStepIndex: () => number; totalSteps: () => number; currentStep: () => TutorialStep | null; targetRect: () => DOMRect | null; start: (stepIndex?: number) => void; end: () => void; next: () => void; previous: () => void; goTo: (stepIndex: number) => void; } const TutorialContext = createContext(null); const TutorialInternalContext = createContext(null); const _TUTORIAL_ANIMATION_DURATION = 100; export function useTutorial(): TutorialContextValue { const context = useContext(TutorialContext); if (!context) { throw new Error("useTutorial must be used within a TutorialProvider"); } return context; } function useTutorialInternal(): TutorialInternalContextValue { return useContext(TutorialInternalContext)!; } interface PopoverPosition { top: number; left: number; placement: string; } function calculatePopoverPosition(targetRect: DOMRect, popoverRect: DOMRect, placement: string, offset: number): PopoverPosition { const parts = placement.split("-"); const basePlacement = parts[0]; const alignment = parts[1] || "center"; let top = 0; let left = 0; let finalPlacement = placement; const padding = 16; switch (basePlacement) { case "top": top = targetRect.top - popoverRect.height - offset; break; case "bottom": top = targetRect.bottom + offset; break; case "left": left = targetRect.left - popoverRect.width - offset; break; case "right": left = targetRect.right + offset; break; } if (basePlacement === "top" || basePlacement === "bottom") { switch (alignment) { case "start": left = targetRect.left; break; case "end": left = targetRect.right - popoverRect.width; break; default: left = targetRect.left + (targetRect.width - popoverRect.width) / 2; } } else { switch (alignment) { case "start": top = targetRect.top; break; case "end": top = targetRect.bottom - popoverRect.height; break; default: top = targetRect.top + (targetRect.height - popoverRect.height) / 2; } } const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; if (basePlacement === "bottom" && top + popoverRect.height > viewportHeight - padding) { const flippedTop = targetRect.top - popoverRect.height - offset; if (flippedTop >= padding) { top = flippedTop; finalPlacement = placement.replace("bottom", "top"); } } else if (basePlacement === "top" && top < padding) { const flippedTop = targetRect.bottom + offset; if (flippedTop + popoverRect.height <= viewportHeight - padding) { top = flippedTop; finalPlacement = placement.replace("top", "bottom"); } } else if (basePlacement === "right" && left + popoverRect.width > viewportWidth - padding) { const flippedLeft = targetRect.left - popoverRect.width - offset; if (flippedLeft >= padding) { left = flippedLeft; finalPlacement = placement.replace("right", "left"); } } else if (basePlacement === "left" && left < padding) { const flippedLeft = targetRect.right + offset; if (flippedLeft + popoverRect.width <= viewportWidth - padding) { left = flippedLeft; finalPlacement = placement.replace("left", "right"); } } if (left < padding) { left = padding; } else if (left + popoverRect.width > viewportWidth - padding) { left = viewportWidth - popoverRect.width - padding; } if (top < padding) { top = padding; } else if (top + popoverRect.height > viewportHeight - padding) { top = viewportHeight - popoverRect.height - padding; } return { top, left, placement: finalPlacement }; } interface SpotlightOverlayProps { // Solid's `h` auto-invokes zero-arg function props on read — these // are plain values inside the component body, not accessors. targetRect: DOMRect | null; hasTarget: boolean; padding: number; onclick?: () => void; } interface AnimatedRect { left: number; top: number; width: number; height: number; } function SpotlightOverlay(props: SpotlightOverlayProps) { const [borderRadius, setBorderRadius] = createSignal(3.2); const [animatedRect, setAnimatedRect] = createSignal(null); const [overlayOpacity, setOverlayOpacity] = createSignal(0); createEffect(() => { const cssValue = getComputedStyle(document.documentElement).getPropertyValue("--radius-default").trim(); if (cssValue) { const remValue = parseFloat(cssValue); if (!isNaN(remValue)) { const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); setBorderRadius(remValue * rootFontSize); } } }); createEffect(() => { requestAnimationFrame(() => setOverlayOpacity(1)); }); createEffect(() => { const target = props.targetRect; if (target) { setAnimatedRect({ left: target.left, top: target.top, width: target.width, height: target.height, }); } else { setAnimatedRect(null); } }); const pad = () => props.padding ?? 8; const baseTransition = `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`; return ( props.onclick?.()} style={{ opacity: overlayOpacity(), transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms ease-out`, }}/> }>
{ const rect = animatedRect()!; return { left: (rect.left - pad()) + "px", top: (rect.top - pad()) + "px", width: (rect.width + pad() * 2) + "px", height: (rect.height + pad() * 2) + "px", "border-radius": borderRadius() + "px", "box-shadow": `0 0 0 9999px rgba(0, 0, 0, ${0.5 * overlayOpacity()})`, transition: baseTransition, }; })()}>
props.onclick?.()}/>
); } interface PopoverArrowProps { // Solid's `h` auto-invokes zero-arg function props on read. placement: string; } function PopoverArrow(props: PopoverArrowProps) { const basePlacement = () => props.placement.split("-")[0]; const arrowStyles: Record = { 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 borderArrowStyles: Record = { 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", }, }; return [