File size: 6,534 Bytes
2706212
 
 
 
4b71cdf
2706212
 
 
 
 
 
 
 
 
 
 
 
7e30344
 
 
2e6a666
 
 
 
 
7e30344
2e6a666
2706212
 
 
 
 
 
 
 
 
2e6a666
 
2706212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e6a666
2706212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e6a666
2706212
 
 
 
 
2e6a666
2706212
2e6a666
2706212
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// 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<Step[]>(() => availableSteps((sel) => !!document.querySelector(sel), stepsIn, volumetric), [volumetric, stepsIn]);
  const [i, setI] = useState(0);
  const [target, setTarget] = useState<Rect | null>(null);
  const [film, setFilm] = useState<Rect | null>(null);
  const [cardH, setCardH] = useState(190);
  const cardRef = useRef<HTMLDivElement>(null);
  const nextRef = useRef<HTMLButtonElement>(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 (
    <div className={s.root} data-testid="tutorial" data-step={step.id} data-tour-kind={stepsIn ? 'tip' : 'tour'}>
      {target && (
        <div className={s.ring} aria-hidden="true" data-testid="tutorial-ring"
          style={{ left: target.left - 4, top: target.top - 4, width: target.width + 8, height: target.height + 8 }} />
      )}
      <div
        ref={cardRef}
        className={s.card}
        role="dialog"
        aria-labelledby="tutorial-title"
        aria-describedby="tutorial-body"
        // Above its target the card hangs from its bottom edge, so a taller text never grows down over the control.
        style={place.side === 'above'
          ? { left: place.left, bottom: Math.max(8, vp.h - (place.top + cardH)), width: CARD_W }
          : { left: place.left, top: place.top, width: CARD_W }}
        data-side={place.side}
        data-testid="tutorial-card"
        onKeyDown={(e) => {
          if (e.key === 'ArrowRight') { e.preventDefault(); go('next'); }
          else if (e.key === 'ArrowLeft') { e.preventDefault(); go('back'); }
          else if (e.key === 'Escape') { e.preventDefault(); end(false); }
          // Keys pressed on the card stay here: reading-room shortcuts must not fire behind it.
          if (e.key !== 'Tab') e.stopPropagation();
        }}
      >
        <p className={s.count} data-testid="tutorial-count">{heading}{steps.length > 1 ? ` · step ${i + 1} of ${steps.length}` : ''}</p>
        <h2 id="tutorial-title" className={s.title}>{step.title}</h2>
        <p id="tutorial-body" className={s.body}>{step.body}</p>
        <div className={s.row}>
          <button type="button" className={s.skip} onClick={() => end(false)} data-testid="tutorial-skip">Skip</button>
          <span className={s.spacer} />
          {steps.length > 1 && <button type="button" className={s.back} onClick={() => go('back')} disabled={i === 0} data-testid="tutorial-back">Back</button>}
          <button ref={nextRef} type="button" className={s.next} onClick={() => go('next')} data-testid="tutorial-next">
            {last ? finishLabel : 'Next'}
          </button>
        </div>
      </div>
    </div>
  );
}