EduDemo/src/components/OnboardingTour.tsx

216 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useRef, useState } from 'react';
export type Step = {
id: string;
title: string;
description: string;
// percentage-based hotspot region over the base image (left, top, width, height)
region: { leftPct: number; topPct: number; widthPct: number; heightPct: number };
media?: { type: 'image' | 'video'; src: string; alt?: string };
};
type OnboardingTourProps = {
imageSrc: string;
steps: Step[];
editable?: boolean;
onStepsChange?: (steps: Step[]) => void;
};
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
export default function OnboardingTour({ imageSrc, steps, editable = false, onStepsChange }: OnboardingTourProps) {
const [activeIndex, setActiveIndex] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const activeStep = steps[activeIndex];
const handlePrev = () => setActiveIndex((i) => clamp(i - 1, 0, steps.length - 1));
const handleNext = () => setActiveIndex((i) => clamp(i + 1, 0, steps.length - 1));
const handleSkip = () => setActiveIndex(steps.length - 1);
const progressPct = useMemo(() => ((activeIndex + 1) / steps.length) * 100, [activeIndex, steps.length]);
const updateRegion = (delta: Partial<Step['region']>) => {
if (!editable) return;
const next = steps.map((s, i) => {
if (i !== activeIndex) return s;
const r = s.region;
const newRegion = {
leftPct: clamp((delta.leftPct ?? r.leftPct), 0, 100),
topPct: clamp((delta.topPct ?? r.topPct), 0, 100),
widthPct: clamp((delta.widthPct ?? r.widthPct), 1, 100),
heightPct: clamp((delta.heightPct ?? r.heightPct), 1, 100),
};
return { ...s, region: newRegion };
});
onStepsChange?.(next);
};
const nudge = (dx = 0, dy = 0) => {
const r = activeStep.region;
updateRegion({ leftPct: clamp(r.leftPct + dx, 0, 100), topPct: clamp(r.topPct + dy, 0, 100) });
};
const resize = (dw = 0, dh = 0) => {
const r = activeStep.region;
updateRegion({ widthPct: clamp(r.widthPct + dw, 1, 100), heightPct: clamp(r.heightPct + dh, 1, 100) });
};
const POP_W = 24; // approximate popover width in percent of canvas
const POP_H = 22; // approximate popover height in percent of canvas
const GAP = 2; // gap between region and popover in percent
const computePopoverPlacement = () => {
const r = activeStep.region;
const spaceBelow = 100 - (r.topPct + r.heightPct);
const spaceAbove = r.topPct;
const spaceRight = 100 - (r.leftPct + r.widthPct);
// const spaceLeft = r.leftPct; // not needed currently
// Special case: Step 5 (globe) must always be to the LEFT, stuck to region's left side
if (activeStep.id === 'globe') {
// Shift a bit more to the left to sit clearer outside of the region
const left = clamp(r.leftPct - POP_W - 3, 2, 100 - POP_W - 2);
const top = clamp(r.topPct, 2, 100 - POP_H - 2);
return { left, top, cls: '' };
}
// Prefer bottom → top → right → left, never overlap the region and never leave the canvas
if (spaceBelow >= POP_H) {
return {
left: clamp(r.leftPct, 2, 100 - POP_W - 2),
top: r.topPct + r.heightPct + GAP,
cls: '',
};
}
if (spaceAbove >= POP_H) {
return {
left: clamp(r.leftPct, 2, 100 - POP_W - 2),
top: clamp(r.topPct - POP_H - GAP, 2, 100 - POP_H - 2),
cls: '',
};
}
if (spaceRight >= POP_W) {
return {
left: r.leftPct + r.widthPct + GAP,
top: clamp(r.topPct, 2, 100 - POP_H - 2),
cls: 'side-right',
};
}
// fallback: place to left
const left = clamp(r.leftPct - POP_W - GAP, 2, 100 - POP_W - 2);
return {
left,
top: clamp(r.topPct, 2, 100 - POP_H - 2),
cls: 'side-left',
};
};
return (
<div className="tour-root">
<div className="tour-canvas" ref={containerRef}>
<img className="tour-image" src={imageSrc} alt="Dashboard overview" />
{steps.map((step, index) => {
const isActive = index === activeIndex;
return (
<button
key={step.id}
className={`tour-hotspot ${isActive ? 'active' : ''}`}
style={{
left: `${step.region.leftPct}%`,
top: `${step.region.topPct}%`,
width: `${step.region.widthPct}%`,
height: `${step.region.heightPct}%`,
}}
onClick={() => setActiveIndex(index)}
aria-label={`Go to step ${index + 1}: ${step.title}`}
/>
);
})}
{/* Selective overlay - darkens everything except active step */}
<div className="tour-selective-overlay">
<div
className="tour-highlight-window"
style={{
left: `${activeStep.region.leftPct}%`,
top: `${activeStep.region.topPct}%`,
width: `${activeStep.region.widthPct}%`,
height: `${activeStep.region.heightPct}%`,
}}
/>
</div>
{activeStep && (() => {
const pos = computePopoverPlacement();
const cls = `tour-popover${pos.cls ? ` ${pos.cls}` : ''}`;
return (
<div className={cls} style={{ left: `${pos.left}%`, top: `${pos.top}%` }}>
<div className="tour-popover-header">
<div className="tour-step-pill">Step {activeIndex + 1} / {steps.length}</div>
<div className="tour-title">{activeStep.title}</div>
</div>
<div className={`tour-body${activeStep.media ? (activeStep.id === 'globe' ? ' has-media-below' : ' has-media-left') : ''}`}>
{activeStep.media && activeStep.media.type === 'image' && (
<img
className={`tour-media${activeStep.id === 'globe' ? ' media-small-below' : ''}`}
src={activeStep.media.src}
alt={activeStep.media.alt ?? ''}
/>
)}
{activeStep.media && activeStep.media.type === 'video' && (
<video className="tour-media" src={activeStep.media.src} autoPlay loop muted playsInline />
)}
<div className="tour-text">
<p className="tour-desc">{activeStep.description}</p>
</div>
</div>
{editable ? (
<div className="tour-editor">
<div className="row">
<button className="btn" onClick={() => nudge(0, -1)}></button>
</div>
<div className="row">
<button className="btn" onClick={() => nudge(-1, 0)}></button>
<button className="btn" onClick={() => nudge(1, 0)}></button>
</div>
<div className="row">
<button className="btn" onClick={() => nudge(0, 1)}></button>
</div>
<div className="row" style={{ gap: 6, marginTop: 8 }}>
<button className="btn" onClick={() => resize(1, 0)}>genişlet </button>
<button className="btn" onClick={() => resize(-1, 0)}>daralt </button>
<button className="btn" onClick={() => resize(0, 1)}>uzat </button>
<button className="btn" onClick={() => resize(0, -1)}>kısalt </button>
</div>
<pre className="region-pre">{JSON.stringify(activeStep.region, null, 2)}</pre>
</div>
) : null}
<div className="tour-actions">
<button className="btn ghost" onClick={handlePrev} disabled={activeIndex === 0}>Previous</button>
<div className="grow" />
<button className="btn ghost" onClick={handleSkip}>Skip</button>
<button className="btn primary" onClick={handleNext} disabled={activeIndex === steps.length - 1}>
{activeIndex === steps.length - 1 ? 'Done' : 'Next'}
</button>
</div>
</div>
);
})()}
</div>
<div className="tour-progress">
<div className="tour-progress-bar" style={{ width: `${progressPct}%` }} />
</div>
<div className="tour-step-dots">
{steps.map((s, i) => (
<button key={s.id} className={`dot ${i === activeIndex ? 'active' : ''}`} onClick={() => setActiveIndex(i)} aria-label={`Step ${i + 1}`} />
))}
</div>
</div>
);
}