// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by // itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you // planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves. import { useCallback, useEffect, useRef, useState } from 'react'; import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js'; const SIZES = [20, 24, 28, 34, 40, 48, 60, 72]; const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40'; export default function PresentMode({ title, subtitle = '', mode, script = '', tree = [], onClose }) { const [dark, setDark] = useState(true); const [size, setSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-size')) || 34)); } catch { return 3; } }); const [elapsed, setElapsed] = useState(0); const [running, setRunning] = useState(false); const [scrolling, setScrolling] = useState(false); const [speed, setSpeed] = useState(30); // pixels per second const [index, setIndex] = useState(0); const scroller = useRef(null); const steps = mode === 'outline' ? outlineSteps(tree) : []; const step = steps[index]; const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null; // the clock useEffect(() => { if (!running) return undefined; const t = window.setInterval(() => setElapsed((e) => e + 1), 1000); return () => window.clearInterval(t); }, [running]); // the teleprompter useEffect(() => { if (!scrolling || mode !== 'script') return undefined; let last = performance.now(); let frame; let carry = 0; const tick = (now) => { const el = scroller.current; carry += ((now - last) / 1000) * speed; last = now; if (el && carry >= 1) { el.scrollTop += Math.floor(carry); carry -= Math.floor(carry); } if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) { setScrolling(false); return; } frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [scrolling, speed, mode]); // keep the screen on useEffect(() => { let lock; let gone = false; navigator.wakeLock?.request?.('screen').then((l) => { if (gone) l.release?.(); else lock = l; }).catch(() => {}); return () => { gone = true; lock?.release?.().catch?.(() => {}); }; }, []); const bigger = (d) => setSize((s) => { const next = Math.min(SIZES.length - 1, Math.max(0, s + d)); try { localStorage.setItem('present-size', String(SIZES[next])); } catch { /* storage unavailable */ } return next; }); const go = useCallback((d) => setIndex((i) => Math.min(steps.length - 1, Math.max(0, i + d))), [steps.length]); useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') { onClose(); return; } if (e.target?.tagName === 'INPUT') return; if (mode === 'outline') { if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); } } else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); } if (e.key === '+' || e.key === '=') bigger(1); if (e.key === '-') bigger(-1); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [mode, go, onClose]); const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]'; const fontSize = `${SIZES[size]}px`; const total = steps.length ? steps[steps.length - 1].plannedEnd : 0; return (

{title}

{subtitle &&

{subtitle}

}
{formatClock(elapsed)}
{pace && {paceText(pace)} · plan {formatClock(pace.totalSeconds)}}
{mode === 'script' && (
)}
{mode === 'script' ? (
{script.trim() ?
{script} :

There’s no script to present yet. Write one on the Script page.

}
) : (
{step ? (

{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}

{step.label}{step.text || '(untitled)'}

{step.ref &&

{step.ref}

} {step.scripture?.text &&
“{step.scripture.text}” — {step.scripture.ref}
} {step.note &&

{step.note}

} {step.attachments.length > 0 &&

{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}

}
) :

There’s no outline to present yet. Build one on the Outline page.

}
{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move
)}
); }