Files
study-bible-project/src/components/PresentMode.jsx
T
nmemmertandClaude Sonnet 5.5 d29a66f984
Build and Push Docker Image / build-and-push (push) Successful in 7m32s
Make the app work better on iPhone and iPad
- Text fields are at least 16px on touch screens (stops iOS zooming on focus)
- Chapter swipe ignores sheets, fields, sliders, sideways-scrolling rows and text selection
- vh -> dvh for sheets, palette and dialogs
- Bigger tap targets for small links, chips and the logo; no double-tap delay
- No tap flash or landscape text inflation

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:08:07 -04:00

138 lines
8.5 KiB
React
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.
// 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 (
<div role="dialog" aria-modal="true" aria-label={`Presenting ${title}`} className={`fixed inset-0 z-[80] flex flex-col ${colors}`}>
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-current/15 px-4 py-2.5">
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{title}</p>{subtitle && <p className="truncate text-xs opacity-60">{subtitle}</p>}</div>
<div className="flex items-center gap-2" role="group" aria-label="Timer">
<span className="min-w-[4.5rem] text-center font-mono text-xl tabular-nums" aria-live="off">{formatClock(elapsed)}</span>
<button type="button" onClick={() => setRunning((r) => !r)} className={btn}>{running ? 'Pause' : elapsed ? 'Resume' : 'Start'}</button>
<button type="button" onClick={() => { setElapsed(0); setRunning(false); }} disabled={!elapsed} className={btn}>Reset</button>
</div>
{pace && <span className={`text-sm ${pace.diffSeconds < -20 ? 'text-amber-400' : 'opacity-80'}`} role="status">{paceText(pace)} · plan {formatClock(pace.totalSeconds)}</span>}
<div className="flex items-center gap-1.5" role="group" aria-label="Text size">
<button type="button" onClick={() => bigger(-1)} disabled={size === 0} aria-label="Smaller text" className={btn}>A−</button>
<button type="button" onClick={() => bigger(1)} disabled={size === SIZES.length - 1} aria-label="Larger text" className={btn}>A+</button>
<button type="button" onClick={() => setDark((d) => !d)} aria-pressed={dark} className={btn}>{dark ? 'Light' : 'Dark'}</button>
</div>
{mode === 'script' && (
<div className="flex items-center gap-2" role="group" aria-label="Auto-scroll">
<button type="button" onClick={() => setScrolling((s) => !s)} aria-pressed={scrolling} className={btn}>{scrolling ? 'Stop scrolling' : 'Auto-scroll'}</button>
<label className="flex items-center gap-1.5 text-xs opacity-80">Speed
<input type="range" min="8" max="120" value={speed} onChange={(e) => setSpeed(Number(e.target.value))} aria-label="Scroll speed" />
</label>
</div>
)}
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
</div>
{mode === 'script' ? (
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
{script.trim()
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div>
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
{step ? (
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p>
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
{step.note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{step.note}</p>}
{step.attachments.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
</div>
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
<button type="button" onClick={() => go(1)} disabled={index >= steps.length - 1} className={btn}>Next →</button>
</div>
</div>
)}
</div>
);
}