686 lines
24 KiB
TypeScript
686 lines
24 KiB
TypeScript
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<TutorialContextValue | null>(null);
|
|
const TutorialInternalContext = createContext<TutorialInternalContextValue | null>(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<AnimatedRect | null>(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 (
|
|
<Show when={props.hasTarget && animatedRect()} fallback={
|
|
<div class="fixed inset-0 bg-black/50 z-150" onclick={() => props.onclick?.()} style={{
|
|
opacity: overlayOpacity(),
|
|
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms ease-out`,
|
|
}}/>
|
|
}>
|
|
<div class="fixed z-150 pointer-events-none rounded-default" style={(() => {
|
|
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,
|
|
};
|
|
})()}>
|
|
<div class="fixed inset-0 -z-10 cursor-pointer" onclick={() => props.onclick?.()}/>
|
|
</div>
|
|
</Show>
|
|
);
|
|
}
|
|
|
|
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<string, object> = {
|
|
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<string, object> = {
|
|
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 [
|
|
<div class="absolute w-0 h-0" style={{
|
|
...borderArrowStyles[basePlacement()],
|
|
width: "0",
|
|
height: "0",
|
|
transition: `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
|
}}/>,
|
|
<div class="absolute w-0 h-0" style={{
|
|
...arrowStyles[basePlacement()],
|
|
transition: `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
|
}}/>,
|
|
];
|
|
}
|
|
|
|
function TutorialPopover() {
|
|
const ctx = useTutorialInternal();
|
|
let popoverRef: HTMLDivElement | undefined;
|
|
const [position, setPosition] = createSignal<{ top: number; left: number } | null>(null);
|
|
const [currentPlacement, setCurrentPlacement] = createSignal("bottom");
|
|
const [displayedPlacement, setDisplayedPlacement] = createSignal("bottom");
|
|
const [isVisible, setIsVisible] = createSignal(false);
|
|
const [isPositioned, setIsPositioned] = createSignal(false);
|
|
const [contentOpacity, setContentOpacity] = createSignal(1);
|
|
const [displayedStep, setDisplayedStep] = createSignal<TutorialStep | null>(ctx.currentStep());
|
|
const [displayedStepIndex, setDisplayedStepIndex] = createSignal(ctx.currentStepIndex());
|
|
const [showArrow, setShowArrow] = createSignal(false);
|
|
|
|
let prevStepIndex = ctx.currentStepIndex();
|
|
let isTransitioning = false;
|
|
|
|
const placement = () => ctx.currentStep()?.placement ?? "bottom";
|
|
const offset = () => ctx.currentStep()?.offset ?? 16;
|
|
const hasTarget = () => !!ctx.currentStep()?.target;
|
|
const displayHasTarget = () => !!displayedStep()?.target;
|
|
|
|
const updatePosition = (immediate: boolean = false) => {
|
|
if (!popoverRef) return;
|
|
const popoverRect = popoverRef.getBoundingClientRect();
|
|
|
|
let newTop, newLeft;
|
|
let newPlacement = "bottom";
|
|
|
|
if (!hasTarget()) {
|
|
newTop = (window.innerHeight - popoverRect.height) / 2;
|
|
newLeft = (window.innerWidth - popoverRect.width) / 2;
|
|
} else if (ctx.targetRect()) {
|
|
const newPosition = calculatePopoverPosition(ctx.targetRect()!, popoverRect, placement(), offset());
|
|
newTop = newPosition.top;
|
|
newLeft = newPosition.left;
|
|
newPlacement = newPosition.placement;
|
|
} else {
|
|
newTop = (window.innerHeight - popoverRect.height) / 2;
|
|
newLeft = (window.innerWidth - popoverRect.width) / 2;
|
|
}
|
|
|
|
setPosition({ top: newTop, left: newLeft });
|
|
setCurrentPlacement(newPlacement);
|
|
|
|
if (immediate || !isPositioned()) {
|
|
setDisplayedPlacement(newPlacement);
|
|
}
|
|
};
|
|
|
|
createEffect(() => {
|
|
if (!popoverRef) return;
|
|
|
|
requestAnimationFrame(() => {
|
|
updatePosition(true);
|
|
setIsPositioned(true);
|
|
requestAnimationFrame(() => {
|
|
setIsVisible(true);
|
|
setTimeout(() => setShowArrow(true), _TUTORIAL_ANIMATION_DURATION);
|
|
});
|
|
});
|
|
});
|
|
|
|
createEffect(() => {
|
|
const currentIdx = ctx.currentStepIndex();
|
|
if (prevStepIndex === currentIdx) return;
|
|
|
|
prevStepIndex = currentIdx;
|
|
|
|
if (isTransitioning) return;
|
|
isTransitioning = true;
|
|
|
|
setIsFirstAppearance(false);
|
|
setShowArrow(false);
|
|
setContentOpacity(0);
|
|
|
|
setTimeout(() => {
|
|
setDisplayedStep(ctx.currentStep());
|
|
setDisplayedStepIndex(currentIdx);
|
|
|
|
requestAnimationFrame(() => {
|
|
updatePosition(true);
|
|
setTimeout(() => {
|
|
setContentOpacity(1);
|
|
setTimeout(() => {
|
|
setShowArrow(true);
|
|
isTransitioning = false;
|
|
}, _TUTORIAL_ANIMATION_DURATION / 2);
|
|
}, 50);
|
|
});
|
|
}, _TUTORIAL_ANIMATION_DURATION / 2);
|
|
});
|
|
|
|
createEffect(() => {
|
|
ctx.targetRect();
|
|
if (!isTransitioning && isPositioned()) {
|
|
updatePosition();
|
|
setDisplayedPlacement(currentPlacement());
|
|
}
|
|
});
|
|
|
|
createEffect(() => {
|
|
if (!isPositioned()) return;
|
|
|
|
const handleUpdate = () => {
|
|
if (!isTransitioning) {
|
|
updatePosition();
|
|
setDisplayedPlacement(currentPlacement());
|
|
}
|
|
};
|
|
|
|
window.addEventListener("scroll", handleUpdate, true);
|
|
window.addEventListener("resize", handleUpdate);
|
|
|
|
onCleanup(() => {
|
|
window.removeEventListener("scroll", handleUpdate, true);
|
|
window.removeEventListener("resize", handleUpdate);
|
|
});
|
|
});
|
|
|
|
const isFirstStep = () => displayedStepIndex() === 0;
|
|
const isLastStep = () => displayedStepIndex() === ctx.totalSteps() - 1;
|
|
|
|
const [isFirstAppearance, setIsFirstAppearance] = createSignal(true);
|
|
|
|
const getPopoverStyle = () => {
|
|
const pos = position();
|
|
if (!pos) {
|
|
return {
|
|
visibility: "hidden" as const,
|
|
top: "-9999px",
|
|
left: "-9999px",
|
|
};
|
|
}
|
|
|
|
if (isFirstAppearance()) {
|
|
return {
|
|
top: pos.top + "px",
|
|
left: pos.left + "px",
|
|
opacity: isVisible() ? 1 : 0,
|
|
transform: isVisible() ? "scale(1)" : "scale(0.95)",
|
|
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
top: pos.top + "px",
|
|
left: pos.left + "px",
|
|
opacity: isVisible() ? 1 : 0,
|
|
transform: isVisible() ? "scale(1)" : "scale(0.95)",
|
|
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), top ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), left ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
|
};
|
|
};
|
|
|
|
const getContentStyle = () => ({
|
|
opacity: contentOpacity(),
|
|
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION / 2}ms ease-out`,
|
|
});
|
|
|
|
return (
|
|
<div ref={(el: HTMLDivElement) => popoverRef = el} class="fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm" style={getPopoverStyle()}>
|
|
<Show when={displayHasTarget() && showArrow()}>
|
|
<PopoverArrow placement={displayedPlacement()}/>
|
|
</Show>
|
|
|
|
<div style={getContentStyle()}>
|
|
<div class="flex items-center justify-between p-4 pb-2">
|
|
<div class="flex items-center gap-2">
|
|
<Show when={displayedStep()?.title}>
|
|
<span class="font-medium text-ink">{displayedStep()?.title}</span>
|
|
</Show>
|
|
<span class="text-xs text-ink-muted">
|
|
{(displayedStepIndex() + 1) + " of " + ctx.totalSteps()}
|
|
</span>
|
|
</div>
|
|
<button onclick={() => ctx.end()} class="cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft">
|
|
<Icon icon="xmark" size={18}/>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="px-4 pb-4 text-sm text-ink">
|
|
{displayedStep()?.content}
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between px-4 pb-4 gap-2">
|
|
<div>
|
|
<Show when={!isFirstStep()}>
|
|
<ButtonUI color={BUTTON_COLOR_WHITE} small onclick={() => ctx.previous()}>
|
|
Previous
|
|
</ButtonUI>
|
|
</Show>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<Show when={isLastStep()} fallback={
|
|
<ButtonUI color={BUTTON_COLOR_BLUE} small onclick={() => ctx.next()}>
|
|
Next
|
|
</ButtonUI>
|
|
}>
|
|
<ButtonUI color={BUTTON_COLOR_BLUE} small onclick={() => ctx.end()}>
|
|
Finish
|
|
</ButtonUI>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={ctx.totalSteps() > 1}>
|
|
<div class="flex justify-center gap-1.5 pb-3">
|
|
<For each={Array.from({ length: ctx.totalSteps() })}>{(_, i) => (
|
|
<div class={"w-2 h-2 rounded-full transition-all duration-300 ease-in-out " + (i() === displayedStepIndex() ? "bg-sky-600 scale-110" : "bg-surface-strong")}/>
|
|
)}</For>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TutorialProvider(props: TutorialProviderProps) {
|
|
const [isActive, setIsActive] = createSignal(false);
|
|
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
|
|
const [targetRect, setTargetRect] = createSignal<DOMRect | null>(null);
|
|
|
|
const totalSteps = () => props.steps.length;
|
|
const currentStep = () => isActive() && props.steps[currentStepIndex()] ? props.steps[currentStepIndex()] : null;
|
|
|
|
createEffect(() => {
|
|
if (!isActive() || !currentStep()) {
|
|
setTargetRect(null);
|
|
return;
|
|
}
|
|
|
|
const step = currentStep()!;
|
|
if (!step.target) {
|
|
setTargetRect(null);
|
|
return;
|
|
}
|
|
|
|
const findTarget = (): HTMLElement | null => {
|
|
if (typeof step.target === "function") {
|
|
return step.target();
|
|
}
|
|
if (typeof step.target === "string") {
|
|
return document.querySelector(step.target);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const updateTargetRect = () => {
|
|
const target = findTarget();
|
|
if (target) {
|
|
setTargetRect(target.getBoundingClientRect());
|
|
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
} else {
|
|
setTargetRect(null);
|
|
}
|
|
};
|
|
|
|
const timeoutId = setTimeout(updateTargetRect, 50);
|
|
|
|
window.addEventListener("scroll", updateTargetRect, true);
|
|
window.addEventListener("resize", updateTargetRect);
|
|
|
|
onCleanup(() => {
|
|
clearTimeout(timeoutId);
|
|
window.removeEventListener("scroll", updateTargetRect, true);
|
|
window.removeEventListener("resize", updateTargetRect);
|
|
});
|
|
});
|
|
|
|
createEffect(() => {
|
|
if (isActive() && currentStep()?.onEnter) {
|
|
currentStep()!.onEnter!();
|
|
}
|
|
});
|
|
|
|
const start = (stepIndex: number = 0) => {
|
|
setCurrentStepIndex(Math.max(0, Math.min(stepIndex, props.steps.length - 1)));
|
|
setIsActive(true);
|
|
};
|
|
|
|
const end = () => {
|
|
if (currentStep()?.onLeave) {
|
|
currentStep()!.onLeave!();
|
|
}
|
|
setIsActive(false);
|
|
setCurrentStepIndex(0);
|
|
props.onEnd?.();
|
|
};
|
|
|
|
const next = () => {
|
|
if (currentStep()?.onLeave) {
|
|
currentStep()!.onLeave!();
|
|
}
|
|
if (currentStepIndex() < props.steps.length - 1) {
|
|
setCurrentStepIndex(currentStepIndex() + 1);
|
|
} else {
|
|
end();
|
|
}
|
|
};
|
|
|
|
const previous = () => {
|
|
if (currentStep()?.onLeave) {
|
|
currentStep()!.onLeave!();
|
|
}
|
|
if (currentStepIndex() > 0) {
|
|
setCurrentStepIndex(currentStepIndex() - 1);
|
|
}
|
|
};
|
|
|
|
const goTo = (stepIndex: number) => {
|
|
if (currentStep()?.onLeave) {
|
|
currentStep()!.onLeave!();
|
|
}
|
|
setCurrentStepIndex(Math.max(0, Math.min(stepIndex, props.steps.length - 1)));
|
|
};
|
|
|
|
createEffect(() => {
|
|
if (!isActive()) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
end();
|
|
} else if (e.key === "ArrowRight" || e.key === "Enter") {
|
|
next();
|
|
} else if (e.key === "ArrowLeft") {
|
|
previous();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
|
|
});
|
|
|
|
const publicValue = (): TutorialContextValue => ({
|
|
isActive: isActive(),
|
|
currentStepIndex: currentStepIndex(),
|
|
totalSteps: props.steps.length,
|
|
currentStep: currentStep(),
|
|
start,
|
|
end,
|
|
next,
|
|
previous,
|
|
goTo,
|
|
});
|
|
|
|
const internalValue: TutorialInternalContextValue = {
|
|
isActive,
|
|
currentStepIndex,
|
|
totalSteps,
|
|
currentStep,
|
|
targetRect,
|
|
start,
|
|
end,
|
|
next,
|
|
previous,
|
|
goTo,
|
|
};
|
|
|
|
return (
|
|
<TutorialContext.Provider value={publicValue()}>
|
|
<TutorialInternalContext.Provider value={internalValue}>
|
|
{props.children}
|
|
<Show when={isActive() && currentStep()}>
|
|
<SpotlightOverlay targetRect={targetRect()} hasTarget={!!currentStep()?.target} padding={props.spotlightPadding ?? 8} onclick={() => end()}/>
|
|
<TutorialPopover/>
|
|
</Show>
|
|
</TutorialInternalContext.Provider>
|
|
</TutorialContext.Provider>
|
|
);
|
|
}
|
|
|
|
interface StartTutorialButtonProps {
|
|
stepIndex?: number;
|
|
class?: string;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function StartTutorialButton(props: StartTutorialButtonProps) {
|
|
const { start } = useTutorial();
|
|
|
|
return (
|
|
<ButtonUI color={BUTTON_COLOR_BLUE} onclick={() => start(props.stepIndex ?? 0)} class={props.class || ""}>
|
|
{props.children ?? "Start Tutorial"}
|
|
</ButtonUI>
|
|
);
|
|
}
|