// First-run tutorial: a short, skippable coach-mark sequence over the real reading room. Each step rings the real // control and puts a small card beside it โ€” never a full-screen overlay, so the film and every control stay usable // while it runs. Next / Back / Skip; โ† โ†’ and Esc work while the card has focus. import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { track } from '../analytics'; import { availableSteps, markTutorialDone, placeCard, tourStep, type Rect, type Step } from './steps'; import s from './Tutorial.module.css'; const CARD_W = 320; const rectOf = (el: Element | null): Rect | null => { if (!el) return null; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 ? { left: r.left, top: r.top, width: r.width, height: r.height } : null; }; const same = (a: Rect | null, b: Rect | null) => a === b || (!!a && !!b && Math.abs(a.left - b.left) < 0.5 && Math.abs(a.top - b.top) < 0.5 && Math.abs(a.width - b.width) < 0.5 && Math.abs(a.height - b.height) < 0.5); /** `onClose(finished)`: finished = walked to the end; false = skipped. Either way it is remembered as done. * `modality` (round 4): 'ct' | 'mr' adds the slice and measure steps, whose controls the volume viewer marks with * data-tour="slices" and data-tour="measure"; a chest film (or no modality) gets the unchanged X-ray tour. */ export function Tutorial({ onClose, modality, steps: stepsIn, heading = 'How to read here', finishLabel = 'Start reading', remember = markTutorialDone }: { onClose: (finished: boolean) => void; modality?: string | null; /** Round 5: another short sequence (the search tip after the first read) with its own heading, last button and memory. */ steps?: Step[]; heading?: string; finishLabel?: string; remember?: () => void; }) { const volumetric = modality === 'ct' || modality === 'mr'; const steps = useMemo(() => availableSteps((sel) => !!document.querySelector(sel), stepsIn, volumetric), [volumetric, stepsIn]); const [i, setI] = useState(0); const [target, setTarget] = useState(null); const [film, setFilm] = useState(null); const [cardH, setCardH] = useState(190); const cardRef = useRef(null); const nextRef = useRef(null); const step = steps[i]; const end = (finished: boolean) => { remember(); if (finished && !stepsIn) track('tutorial_done'); onClose(finished); }; const endRef = useRef(end); useLayoutEffect(() => { endRef.current = end; }); // No controls to point at (should not happen): close quietly rather than show an orphan card. useEffect(() => { if (steps.length === 0) endRef.current(false); }, [steps.length]); // Bring the control into view (the rail scrolls), then keep the ring on it as the layout moves. useLayoutEffect(() => { if (!step) return; document.querySelector(step.target)?.scrollIntoView({ block: 'nearest' }); const measure = () => { const t = rectOf(document.querySelector(step.target)); const f = rectOf(document.querySelector('[data-tour="film"]')); setTarget((prev) => (same(prev, t) ? prev : t)); setFilm((prev) => (same(prev, f) ? prev : f)); }; measure(); const id = window.setInterval(measure, 250); window.addEventListener('resize', measure); window.addEventListener('scroll', measure, true); return () => { clearInterval(id); window.removeEventListener('resize', measure); window.removeEventListener('scroll', measure, true); }; }, [step]); useLayoutEffect(() => { const el = cardRef.current; if (!el) return; const ro = new ResizeObserver(() => setCardH((prev) => (Math.abs(el.offsetHeight - prev) > 1 ? el.offsetHeight : prev))); ro.observe(el); return () => ro.disconnect(); }, [step]); useEffect(() => { nextRef.current?.focus({ preventScroll: true }); }, [i]); if (!step) return null; const go = (a: 'next' | 'back') => { const n = tourStep({ i, n: steps.length }, a); if (n) setI(n.i); else end(true); }; const vp = { w: window.innerWidth, h: window.innerHeight }; const place = target ? placeCard(target, { w: CARD_W, h: cardH }, vp, film) : { left: Math.max(8, vp.w - CARD_W - 24), top: 80, side: 'inside' as const }; const last = i === steps.length - 1; return (
{target && ( ); }